@diia-inhouse/workflow 2.5.0 → 2.6.0

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/dist/index.d.ts CHANGED
@@ -1,7 +1,6 @@
1
- import { SchedulesExporterConfig } from "./interfaces/services/schedulesExporter.js";
2
1
  import { TemporalConfig } from "./interfaces/config.js";
3
2
  import { getDataConverter } from "./encryption/dataConverter.js";
4
3
  import { EncryptionCodec } from "./encryption/encryptionCodec.js";
5
4
  import { decrypt, encrypt } from "./encryption/crypto.js";
6
5
  import * as proto from "@temporalio/proto";
7
- export { EncryptionCodec, type SchedulesExporterConfig, type TemporalConfig, decrypt, encrypt, getDataConverter, proto };
6
+ export { EncryptionCodec, type TemporalConfig, decrypt, encrypt, getDataConverter, proto };
@@ -1,4 +1,3 @@
1
- import { SchedulesExporterConfig } from "./services/schedulesExporter.js";
2
1
  import { ClientOptions, ConnectionOptions } from "@temporalio/client";
3
2
  import { MetricsConfig } from "@diia-inhouse/diia-metrics";
4
3
  import { QueueConnectionConfig } from "@diia-inhouse/diia-queue";
@@ -31,21 +30,6 @@ interface TemporalConfig extends Omit<ClientOptions, "dataConverter"> {
31
30
  * (i.e. when `configFactory` and `deps` are provided).
32
31
  */
33
32
  disableQueueConsumers?: boolean;
34
- /**
35
- * Controls the SchedulesExporter, which polls Temporal Schedule and Visibility APIs and
36
- * emits per-schedule + in-flight workflow gauges (`diia_schedule_*`, `diia_workflows_running`,
37
- * etc.). Auto-started by `bootstrapWorker` / `initTemporalWorker` — services do not need
38
- * to instantiate it themselves.
39
- *
40
- * Runs only in the worker process: when `workerInProcess === false` and the service is
41
- * started without `bootstrapWorker`'s lifecycle path, the exporter is skipped (the worker
42
- * process owns it).
43
- *
44
- * - Omit (default) — exporter starts with the defaults in `SchedulesExporterConfig`.
45
- * - `false` — disable the exporter entirely (e.g. for clusters without advanced visibility).
46
- * - Object — override polling intervals or other knobs. See `SchedulesExporterConfig`.
47
- */
48
- schedulesExporter?: SchedulesExporterConfig | false;
49
33
  }
50
34
  interface AppConfig {
51
35
  temporal: TemporalConfig;
@@ -2,7 +2,6 @@ import { getDataConverter } from "../encryption/dataConverter.js";
2
2
  import "../encryption/index.js";
3
3
  import { traceExporter } from "../instrumentation.js";
4
4
  import { AsyncLocalStorageBridgeInterceptor } from "../interceptors/asyncLocalStorageBridge.js";
5
- import { SchedulesExporter } from "./schedulesExporter.js";
6
5
  import { buildWorkerIdentity } from "./worker/identity.js";
7
6
  import { WorkerHealthService } from "./workerHealth.js";
8
7
  import { EnvService } from "@diia-inhouse/env";
@@ -12,7 +11,6 @@ import { fileURLToPath } from "node:url";
12
11
  import { Resource } from "@opentelemetry/resources";
13
12
  import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
14
13
  import { OpenTelemetryActivityInboundInterceptor, OpenTelemetryActivityOutboundInterceptor, makeWorkflowExporter } from "@temporalio/interceptors-opentelemetry/lib/worker/index.js";
15
- import * as promClient from "prom-client";
16
14
  //#region src/services/worker.ts
17
15
  /**
18
16
  * Applies service process configuration overrides when the worker runs separately.
@@ -148,17 +146,11 @@ async function initTemporalWorker(app, options) {
148
146
  const logger = app.container.resolve("logger");
149
147
  const asyncLocalStorage = app.container.resolve("asyncLocalStorage");
150
148
  const instantiatedActivities = instantiateActivities(app, activities);
151
- const worker = await initWorker(config, {
149
+ await (await initWorker(config, {
152
150
  ...workerOptions,
153
151
  workflowsPath,
154
152
  activities: instantiatedActivities
155
- }, envService, logger, nodeTracerProvider, asyncLocalStorage);
156
- const schedulesExporter = await startSchedulesExporter(app, config, logger);
157
- try {
158
- await worker.run();
159
- } finally {
160
- await schedulesExporter?.onDestroy().catch((err) => logger.error("SchedulesExporter shutdown failed", { err }));
161
- }
153
+ }, envService, logger, nodeTracerProvider, asyncLocalStorage)).run();
162
154
  }
163
155
  /**
164
156
  * Bootstraps and runs Temporal worker with graceful shutdown.
@@ -218,7 +210,6 @@ async function bootstrapWorker(app, options) {
218
210
  activities: instantiatedActivities,
219
211
  identity
220
212
  }, envService, logger, nodeTracerProvider, asyncLocalStorage);
221
- const schedulesExporter = await startSchedulesExporter(app, config, logger);
222
213
  logger.info("Starting Temporal worker", {
223
214
  taskQueue,
224
215
  identity
@@ -237,7 +228,6 @@ async function bootstrapWorker(app, options) {
237
228
  await worker.run();
238
229
  } finally {
239
230
  for (const signal of shutdownSignals) process.off(signal, signalHandler);
240
- await schedulesExporter?.onDestroy().catch((err) => logger.error("SchedulesExporter shutdown failed", { err }));
241
231
  }
242
232
  }
243
233
  function tryResolve(container, key) {
@@ -248,27 +238,6 @@ function tryResolve(container, key) {
248
238
  }
249
239
  }
250
240
  /**
251
- * Wires the SchedulesExporter to the in-process worker. Auto-enabled; opt out by setting
252
- * `config.temporal.schedulesExporter` to `false`. Returns the exporter so callers can stop
253
- * it on shutdown.
254
- */
255
- async function startSchedulesExporter(app, config, logger) {
256
- const exporterConfig = config.temporal.schedulesExporter;
257
- if (exporterConfig === false) return;
258
- const temporalClient = tryResolve(app.container, "temporalClient");
259
- if (!temporalClient) {
260
- logger.warn("SchedulesExporter not started: temporalClient is not registered in the DI container");
261
- return;
262
- }
263
- const exporter = new SchedulesExporter({
264
- client: temporalClient,
265
- taskQueue: config.temporal.taskQueue,
266
- logger
267
- }, exporterConfig ?? {});
268
- await exporter.onInit();
269
- return exporter;
270
- }
271
- /**
272
241
  * Initializes Temporal worker.
273
242
  *
274
243
  * @param config - Application configuration
@@ -298,7 +267,7 @@ async function initWorker({ temporal: temporalConfig, metrics: { custom: metrics
298
267
  const workflowsPath = options.workflowsPath ? toWorkflowsPath(options.workflowsPath) : void 0;
299
268
  const mergedInterceptors = mergeInterceptors(buildWorkerInterceptors(tracingEnabled, asyncLocalStorage, logger, workflowsPath), options.interceptors);
300
269
  try {
301
- const worker = await Worker.create({
270
+ return await Worker.create({
302
271
  namespace,
303
272
  taskQueue,
304
273
  connection: await NativeConnection.connect({ address }),
@@ -308,46 +277,10 @@ async function initWorker({ temporal: temporalConfig, metrics: { custom: metrics
308
277
  sinks: tracingEnabled ? { exporter: makeWorkflowExporter(traceExporter, resource) } : void 0,
309
278
  interceptors: mergedInterceptors
310
279
  });
311
- if (workflowsPath && taskQueue) await emitRegisteredWorkflowTypes(workflowsPath, taskQueue, logger);
312
- return worker;
313
280
  } catch (err) {
314
281
  logger?.error("Failed to create Temporal worker", { err });
315
282
  throw new Error("Failed to create Temporal worker", { cause: err });
316
283
  }
317
284
  }
318
- /**
319
- * Dynamic-imports the workflows entrypoint module and emits
320
- * `diia_workflow_registered_type_info{workflow_type, task_queue}=1` for every exported
321
- * function. Pairs with `diia_schedule_*` series so a dashboard can detect zombie schedules
322
- * (schedules whose workflow type is no longer registered on the same task queue).
323
- *
324
- * Failures are logged and swallowed — this is observability, not load-bearing.
325
- */
326
- async function emitRegisteredWorkflowTypes(workflowsPath, taskQueue, logger) {
327
- try {
328
- const mod = await (workflowsPath.startsWith("file://") ? import(workflowsPath) : import(`file://${workflowsPath}`));
329
- const existing = promClient.register.getSingleMetric("diia_workflow_registered_type_info");
330
- const gauge = existing instanceof promClient.Gauge ? existing : new promClient.Gauge({
331
- name: "diia_workflow_registered_type_info",
332
- help: "1 if the workflow type is registered on this task queue (function exported from the worker entrypoint module)",
333
- labelNames: ["workflow_type", "task_queue"]
334
- });
335
- const registered = [];
336
- for (const [name, value] of Object.entries(mod)) if (typeof value === "function") {
337
- gauge.set({
338
- workflow_type: name,
339
- task_queue: taskQueue
340
- }, 1);
341
- registered.push(name);
342
- }
343
- logger?.info("Registered workflow types emitted to Prometheus", {
344
- taskQueue,
345
- count: registered.length,
346
- types: registered
347
- });
348
- } catch (err) {
349
- logger?.warn("Failed to enumerate registered workflow types", { err });
350
- }
351
- }
352
285
  //#endregion
353
286
  export { applyServiceProcessConfig, applyWorkerProcessConfig, bootstrapWorker, initTemporalWorker, initWorker, instantiateActivities, toWorkflowsPath };
package/dist/worker.d.ts CHANGED
@@ -1,9 +1,7 @@
1
- import { ScheduleCalendarEvent, ScheduleRecentAction, SchedulesExporterDeps } from "./interfaces/services/schedulesExporter.js";
2
1
  import { workflowInterceptors } from "./interceptors.js";
3
2
  import { ActivityClass, App, WorkerBootstrapOptions } from "./interfaces/services/worker.js";
4
3
  import { WorkerHealthDetails, WorkerHealthService } from "./services/workerHealth.js";
5
4
  import { buildWorkerIdentity } from "./services/worker/identity.js";
6
5
  import { applyServiceProcessConfig, applyWorkerProcessConfig, bootstrapWorker, initTemporalWorker, initWorker, instantiateActivities, toWorkflowsPath } from "./services/worker.js";
7
- import { SchedulesExporter } from "./services/schedulesExporter.js";
8
6
  import { NativeConnection, Runtime, State, Worker, WorkerInterceptors, WorkerOptions, WorkerStatus, bundleWorkflowCode } from "@temporalio/worker";
9
- export { ActivityClass, App, NativeConnection, Runtime, type ScheduleCalendarEvent, type ScheduleRecentAction, SchedulesExporter, type SchedulesExporterDeps, type State, Worker, WorkerBootstrapOptions, WorkerHealthDetails, WorkerHealthService, type WorkerInterceptors, type WorkerOptions, type WorkerStatus, applyServiceProcessConfig, applyWorkerProcessConfig, bootstrapWorker, buildWorkerIdentity, bundleWorkflowCode, initTemporalWorker, initWorker, instantiateActivities, toWorkflowsPath, workflowInterceptors };
7
+ export { ActivityClass, App, NativeConnection, Runtime, type State, Worker, WorkerBootstrapOptions, WorkerHealthDetails, WorkerHealthService, type WorkerInterceptors, type WorkerOptions, type WorkerStatus, applyServiceProcessConfig, applyWorkerProcessConfig, bootstrapWorker, buildWorkerIdentity, bundleWorkflowCode, initTemporalWorker, initWorker, instantiateActivities, toWorkflowsPath, workflowInterceptors };
package/dist/worker.js CHANGED
@@ -1,7 +1,6 @@
1
1
  import { workflowInterceptors } from "./interceptors.js";
2
- import { SchedulesExporter } from "./services/schedulesExporter.js";
3
2
  import { buildWorkerIdentity } from "./services/worker/identity.js";
4
3
  import { WorkerHealthService } from "./services/workerHealth.js";
5
4
  import { applyServiceProcessConfig, applyWorkerProcessConfig, bootstrapWorker, initTemporalWorker, initWorker, instantiateActivities, toWorkflowsPath } from "./services/worker.js";
6
5
  import { NativeConnection, Runtime, Worker, bundleWorkflowCode } from "@temporalio/worker";
7
- export { NativeConnection, Runtime, SchedulesExporter, Worker, WorkerHealthService, applyServiceProcessConfig, applyWorkerProcessConfig, bootstrapWorker, buildWorkerIdentity, bundleWorkflowCode, initTemporalWorker, initWorker, instantiateActivities, toWorkflowsPath, workflowInterceptors };
6
+ export { NativeConnection, Runtime, Worker, WorkerHealthService, applyServiceProcessConfig, applyWorkerProcessConfig, bootstrapWorker, buildWorkerIdentity, bundleWorkflowCode, initTemporalWorker, initWorker, instantiateActivities, toWorkflowsPath, workflowInterceptors };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diia-inhouse/workflow",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "description": "Workflow",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -88,8 +88,7 @@
88
88
  "@diia-inhouse/env": ">=3.0.0",
89
89
  "@diia-inhouse/healthcheck": ">=2.0.0",
90
90
  "@diia-inhouse/types": ">=13.0.0",
91
- "@diia-inhouse/utils": ">=6.0.0",
92
- "prom-client": ">=15.0.0"
91
+ "@diia-inhouse/utils": ">=6.0.0"
93
92
  },
94
93
  "peerDependenciesMeta": {
95
94
  "@diia-inhouse/diia-queue": {
@@ -101,15 +100,15 @@
101
100
  },
102
101
  "devDependencies": {
103
102
  "@diia-inhouse/configs": "7.0.0",
104
- "@diia-inhouse/diia-logger": "4.3.0",
105
- "@diia-inhouse/diia-metrics": "7.1.5",
106
- "@diia-inhouse/diia-queue": "14.0.12",
107
- "@diia-inhouse/env": "3.3.1",
108
- "@diia-inhouse/healthcheck": "2.1.10",
103
+ "@diia-inhouse/diia-logger": "4.3.5",
104
+ "@diia-inhouse/diia-metrics": "7.1.13",
105
+ "@diia-inhouse/diia-queue": "14.0.14",
106
+ "@diia-inhouse/env": "3.3.5",
107
+ "@diia-inhouse/healthcheck": "2.1.17",
109
108
  "@diia-inhouse/oxc-config": "1.10.0",
110
- "@diia-inhouse/test": "8.2.1",
109
+ "@diia-inhouse/test": "8.2.4",
111
110
  "@diia-inhouse/types": "13.2.0",
112
- "@diia-inhouse/utils": "6.0.11",
111
+ "@diia-inhouse/utils": "6.0.16",
113
112
  "@types/lodash": "4.17.24",
114
113
  "@types/node": "25.6.2",
115
114
  "@types/yargs": "17.0.35",
@@ -1,96 +0,0 @@
1
- import { Logger } from "@diia-inhouse/types";
2
- import { Client } from "@temporalio/client";
3
-
4
- //#region src/interfaces/services/schedulesExporter.d.ts
5
- interface SchedulesExporterConfig {
6
- /**
7
- * Polling interval for `client.schedule.list()` and `describe()`. Default 30s.
8
- */
9
- pollIntervalMs?: number;
10
- /**
11
- * Polling interval for the visibility query that powers `diia_workflows_running`.
12
- * Default 10s. Ignored when `pollVisibility` is `false`.
13
- */
14
- visibilityPollIntervalMs?: number;
15
- /**
16
- * If `false`, skip the visibility query (`workflow.list({ status=Running })`). Use this
17
- * when the Temporal cluster does not have advanced visibility (Elasticsearch) enabled.
18
- * Default `true`.
19
- */
20
- pollVisibility?: boolean;
21
- /**
22
- * How many upcoming fire times to expose per schedule as `slot=0..N-1` gauges.
23
- * Default 5.
24
- */
25
- nextActionSlots?: number;
26
- /**
27
- * Maximum number of recent action events kept in memory for the `getRecentActions()`
28
- * snapshot. Default 200.
29
- */
30
- recentActionsHistorySize?: number;
31
- /**
32
- * If `false`, skip the completed-executions query that powers `diia_workflow_duration_seconds`.
33
- * Default `true`. Disable when you have no advanced visibility, or when duration
34
- * tracking is handled elsewhere (e.g. via the SDK's `workflow_endtoend_latency` metric).
35
- */
36
- pollCompletions?: boolean;
37
- /**
38
- * Polling interval for the completed-executions query. Default 60s.
39
- */
40
- completionsPollIntervalMs?: number;
41
- /**
42
- * How far back the FIRST completions poll looks (subsequent polls use a sliding window
43
- * based on the previous poll time). Set this to roughly the histogram retention you want
44
- * after a fresh restart. Default 1h.
45
- */
46
- completionsLookbackMs?: number;
47
- /**
48
- * Maximum number of recently-observed completed run IDs kept in memory for de-duplication
49
- * across overlapping polls. LRU-trimmed. Default 2000.
50
- */
51
- completionsSeenCapacity?: number;
52
- }
53
- interface SchedulesExporterDeps {
54
- /**
55
- * Temporal client used to call `schedule.list`, `schedule.getHandle().describe`, and
56
- * `workflow.list`. Typed as `Pick<Client, 'schedule' | 'workflow'>` so this interface
57
- * stays decoupled from the `TemporalClient` wrapper class (avoids a cycle through
58
- * `interfaces/config.ts`); the wrapper's `schedule`/`workflow` getters satisfy this shape.
59
- */
60
- client: Pick<Client, "schedule" | "workflow">;
61
- /**
62
- * The worker's task queue. Schedules whose `action.taskQueue` does not match are
63
- * ignored — this guarantees each service emits metrics only for the schedules it owns.
64
- */
65
- taskQueue: string;
66
- logger?: Logger;
67
- }
68
- /**
69
- * Single upcoming schedule fire returned by `SchedulesExporter.getCalendarEvents()`.
70
- * Each schedule contributes up to `nextActionSlots` events, ordered by `slot` ascending
71
- * (slot 0 is the next fire).
72
- */
73
- interface ScheduleCalendarEvent {
74
- scheduleId: string;
75
- workflowType: string;
76
- taskQueue: string;
77
- cadence: string;
78
- slot: number;
79
- fireAt: string;
80
- fireAtMs: number;
81
- paused: number;
82
- lastSucceeded: number | null;
83
- }
84
- /**
85
- * Single recent schedule action returned by `SchedulesExporter.getRecentActions()`.
86
- */
87
- interface ScheduleRecentAction {
88
- scheduleId: string;
89
- workflowType: string;
90
- taskQueue: string;
91
- firedAt: string;
92
- firedAtMs: number;
93
- succeeded: number;
94
- }
95
- //#endregion
96
- export { ScheduleCalendarEvent, ScheduleRecentAction, SchedulesExporterConfig, SchedulesExporterDeps };
@@ -1,101 +0,0 @@
1
- import { ScheduleCalendarEvent, ScheduleRecentAction, SchedulesExporterConfig, SchedulesExporterDeps } from "../interfaces/services/schedulesExporter.js";
2
- import { OnDestroy, OnInit } from "@diia-inhouse/types";
3
-
4
- //#region src/services/schedulesExporter.d.ts
5
- /**
6
- * Periodically polls the Temporal Schedule and Visibility APIs, exposing per-schedule and
7
- * per-workflow-type business metrics that the SDK does not provide out of the box:
8
- *
9
- * - `diia_schedule_paused` (0/1)
10
- * - `diia_schedule_last_action_age_seconds`
11
- * - `diia_schedule_last_action_succeeded` (0/1)
12
- * - `diia_schedule_next_action_at_seconds{slot}` — Unix seconds of upcoming fire
13
- * - `diia_schedule_next_action_eta_seconds{slot}` — seconds from now until upcoming fire
14
-
15
- * - `diia_schedule_cadence_seconds` — approximate gap between fires (slot1 − slot0, falls back to spec)
16
- * - `diia_schedule_fires_total{result="ok"|"failed"}` — counter of observed schedule fires; pair
17
- * with cadence to compute expected/day and detect missed runs
18
- * - `diia_workflows_running` — count of executions in `Running` state for this task queue
19
- * - `diia_workflows_oldest_running_age_seconds` — age of the oldest running workflow
20
- *
21
- * The `service` label is supplied by the `diia-metrics` default labels at scrape time, so it
22
- * is intentionally absent from the metric labelNames.
23
- *
24
- * Schedules with `action.taskQueue` other than `deps.taskQueue` are ignored, so a service
25
- * only emits metrics for schedules it actually owns. This avoids fan-out when many services
26
- * share a Temporal namespace.
27
- *
28
- * The exporter does NOT run its own HTTP server. Metrics flow through the existing
29
- * `diia-metrics` `/metrics` endpoint. If a service needs to expose the calendar event list
30
- * or recent action feed as JSON (e.g. for a Grafana Infinity datasource), it should call
31
- * {@link SchedulesExporter.getCalendarEvents} and {@link SchedulesExporter.getRecentActions}
32
- * from a route on its existing HTTP framework.
33
- */
34
- declare class SchedulesExporter implements OnInit, OnDestroy {
35
- private readonly deps;
36
- private readonly pollIntervalMs;
37
- private readonly visibilityPollIntervalMs;
38
- private readonly pollVisibility;
39
- private readonly nextActionSlots;
40
- private readonly historySize;
41
- private readonly logger;
42
- private readonly calendarEvents;
43
- private readonly recentActionsLog;
44
- private readonly paused;
45
- private readonly lastActionAge;
46
- private readonly lastActionSucceeded;
47
- private readonly nextActionAt;
48
- private readonly nextActionEta;
49
- private readonly cadence;
50
- private readonly running;
51
- private readonly oldestRunningAge;
52
- private readonly firesTotal;
53
- private readonly workflowDuration;
54
- private readonly seenCompletedRunIds;
55
- private readonly seenCompletedRunIdSet;
56
- private readonly seenActionKeys;
57
- private readonly seenActionKeySet;
58
- private static readonly ACTIONS_SEEN_CAPACITY;
59
- private readonly lastSeenScheduleLabels;
60
- private readonly pollCompletions;
61
- private readonly completionsPollIntervalMs;
62
- private readonly completionsLookbackMs;
63
- private readonly completionsSeenCapacity;
64
- private completionsLastPollMs;
65
- private scheduleTimer;
66
- private visibilityTimer;
67
- private completionsTimer;
68
- private schedulesPolling;
69
- private runningPolling;
70
- private completionsPolling;
71
- private activeSchedulesPoll;
72
- private activeRunningPoll;
73
- private activeCompletionsPoll;
74
- private stopped;
75
- constructor(deps: SchedulesExporterDeps, config?: SchedulesExporterConfig);
76
- onInit(): Promise<void>;
77
- onDestroy(): Promise<void>;
78
- private armSchedulesTimer;
79
- private armVisibilityTimer;
80
- private armCompletionsTimer;
81
- private runSchedulesPoll;
82
- private runRunningPoll;
83
- private runCompletionsPoll;
84
- /**
85
- * Snapshot of the next-N upcoming fires across all tracked schedules.
86
- * Each schedule contributes up to `nextActionSlots` events, ordered by `slot`.
87
- * Mount on a service HTTP route to feed the Grafana Business Calendar panel via the
88
- * Infinity datasource — the data shape is already calendar-event-shaped.
89
- */
90
- getCalendarEvents(): readonly ScheduleCalendarEvent[];
91
- /**
92
- * Snapshot of the most recent schedule actions across all tracked schedules,
93
- * ordered newest first. Capped at `recentActionsHistorySize` entries.
94
- */
95
- getRecentActions(limit?: number): readonly ScheduleRecentAction[];
96
- private pollSchedules;
97
- private pollRunning;
98
- private pollCompleted;
99
- }
100
- //#endregion
101
- export { SchedulesExporter };
@@ -1,456 +0,0 @@
1
- import * as promClient from "prom-client";
2
- //#region src/services/schedulesExporter.ts
3
- const SCHEDULE_LABELS = [
4
- "schedule_id",
5
- "workflow_type",
6
- "task_queue"
7
- ];
8
- const SCHEDULE_SLOT_LABELS = [...SCHEDULE_LABELS, "slot"];
9
- const RUNNING_LABELS = ["workflow_type", "task_queue"];
10
- /**
11
- * Periodically polls the Temporal Schedule and Visibility APIs, exposing per-schedule and
12
- * per-workflow-type business metrics that the SDK does not provide out of the box:
13
- *
14
- * - `diia_schedule_paused` (0/1)
15
- * - `diia_schedule_last_action_age_seconds`
16
- * - `diia_schedule_last_action_succeeded` (0/1)
17
- * - `diia_schedule_next_action_at_seconds{slot}` — Unix seconds of upcoming fire
18
- * - `diia_schedule_next_action_eta_seconds{slot}` — seconds from now until upcoming fire
19
-
20
- * - `diia_schedule_cadence_seconds` — approximate gap between fires (slot1 − slot0, falls back to spec)
21
- * - `diia_schedule_fires_total{result="ok"|"failed"}` — counter of observed schedule fires; pair
22
- * with cadence to compute expected/day and detect missed runs
23
- * - `diia_workflows_running` — count of executions in `Running` state for this task queue
24
- * - `diia_workflows_oldest_running_age_seconds` — age of the oldest running workflow
25
- *
26
- * The `service` label is supplied by the `diia-metrics` default labels at scrape time, so it
27
- * is intentionally absent from the metric labelNames.
28
- *
29
- * Schedules with `action.taskQueue` other than `deps.taskQueue` are ignored, so a service
30
- * only emits metrics for schedules it actually owns. This avoids fan-out when many services
31
- * share a Temporal namespace.
32
- *
33
- * The exporter does NOT run its own HTTP server. Metrics flow through the existing
34
- * `diia-metrics` `/metrics` endpoint. If a service needs to expose the calendar event list
35
- * or recent action feed as JSON (e.g. for a Grafana Infinity datasource), it should call
36
- * {@link SchedulesExporter.getCalendarEvents} and {@link SchedulesExporter.getRecentActions}
37
- * from a route on its existing HTTP framework.
38
- */
39
- var SchedulesExporter = class SchedulesExporter {
40
- deps;
41
- pollIntervalMs;
42
- visibilityPollIntervalMs;
43
- pollVisibility;
44
- nextActionSlots;
45
- historySize;
46
- logger;
47
- calendarEvents = [];
48
- recentActionsLog = [];
49
- paused;
50
- lastActionAge;
51
- lastActionSucceeded;
52
- nextActionAt;
53
- nextActionEta;
54
- cadence;
55
- running;
56
- oldestRunningAge;
57
- firesTotal;
58
- workflowDuration;
59
- seenCompletedRunIds = [];
60
- seenCompletedRunIdSet = /* @__PURE__ */ new Set();
61
- seenActionKeys = [];
62
- seenActionKeySet = /* @__PURE__ */ new Set();
63
- static ACTIONS_SEEN_CAPACITY = 2e3;
64
- lastSeenScheduleLabels = /* @__PURE__ */ new Map();
65
- pollCompletions;
66
- completionsPollIntervalMs;
67
- completionsLookbackMs;
68
- completionsSeenCapacity;
69
- completionsLastPollMs;
70
- scheduleTimer;
71
- visibilityTimer;
72
- completionsTimer;
73
- schedulesPolling = false;
74
- runningPolling = false;
75
- completionsPolling = false;
76
- activeSchedulesPoll;
77
- activeRunningPoll;
78
- activeCompletionsPoll;
79
- stopped = false;
80
- constructor(deps, config = {}) {
81
- this.deps = deps;
82
- this.pollIntervalMs = config.pollIntervalMs ?? 3e4;
83
- this.visibilityPollIntervalMs = config.visibilityPollIntervalMs ?? 1e4;
84
- this.pollVisibility = config.pollVisibility ?? true;
85
- this.nextActionSlots = config.nextActionSlots ?? 5;
86
- this.historySize = config.recentActionsHistorySize ?? 200;
87
- this.pollCompletions = config.pollCompletions ?? true;
88
- this.completionsPollIntervalMs = config.completionsPollIntervalMs ?? 6e4;
89
- this.completionsLookbackMs = config.completionsLookbackMs ?? 3600 * 1e3;
90
- this.completionsSeenCapacity = config.completionsSeenCapacity ?? 2e3;
91
- this.completionsLastPollMs = Date.now() - this.completionsLookbackMs;
92
- this.logger = deps.logger;
93
- const scheduleLabels = [...SCHEDULE_LABELS];
94
- const scheduleSlotLabels = [...SCHEDULE_SLOT_LABELS];
95
- const runningLabels = [...RUNNING_LABELS];
96
- this.paused = getOrCreateGauge("diia_schedule_paused", "1 if the schedule is paused", scheduleLabels);
97
- this.lastActionAge = getOrCreateGauge("diia_schedule_last_action_age_seconds", "Seconds since the schedule last fired (across all action results)", scheduleLabels);
98
- this.lastActionSucceeded = getOrCreateGauge("diia_schedule_last_action_succeeded", "1 if the most recent schedule action started a workflow successfully", scheduleLabels);
99
- this.nextActionAt = getOrCreateGauge("diia_schedule_next_action_at_seconds", "Unix seconds of an upcoming scheduled action (slot=0 is the next fire)", scheduleSlotLabels);
100
- this.nextActionEta = getOrCreateGauge("diia_schedule_next_action_eta_seconds", "Seconds from now until an upcoming scheduled action (slot=0 is the next fire)", scheduleSlotLabels);
101
- this.cadence = getOrCreateGauge("diia_schedule_cadence_seconds", "Approximate cadence between fires, derived from the gap between the next two upcoming actions (falls back to spec for single-slot schedules)", scheduleLabels);
102
- this.running = getOrCreateGauge("diia_workflows_running", "Currently running workflow executions for this task queue, by workflow type", runningLabels);
103
- this.oldestRunningAge = getOrCreateGauge("diia_workflows_oldest_running_age_seconds", "Age of the oldest running workflow per workflow type", runningLabels);
104
- this.firesTotal = getOrCreateCounter("diia_schedule_fires_total", "Total schedule fires observed by the exporter, partitioned by result (ok/failed). Compare with expected fires from `diia_schedule_cadence_seconds` to detect missed runs.", [...scheduleLabels, "result"]);
105
- this.workflowDuration = getOrCreateHistogram("diia_workflow_duration_seconds", "End-to-end duration of completed (status=Completed) workflow executions per workflow_type. Sample is taken from Temporal Visibility close_time − start_time. Pair with `diia_schedule_fires_total` to compute per-schedule cost (fires/day × p95 duration).", ["workflow_type", "task_queue"], [
106
- 1,
107
- 5,
108
- 15,
109
- 60,
110
- 180,
111
- 600,
112
- 1800,
113
- 3600,
114
- 7200,
115
- 21600,
116
- 43200,
117
- 86400
118
- ]);
119
- }
120
- async onInit() {
121
- this.runSchedulesPoll("initial");
122
- if (this.pollVisibility) this.runRunningPoll("initial");
123
- if (this.pollCompletions) this.runCompletionsPoll("initial");
124
- this.armSchedulesTimer();
125
- if (this.pollVisibility) this.armVisibilityTimer();
126
- if (this.pollCompletions) this.armCompletionsTimer();
127
- }
128
- async onDestroy() {
129
- this.stopped = true;
130
- if (this.scheduleTimer) clearTimeout(this.scheduleTimer);
131
- if (this.visibilityTimer) clearTimeout(this.visibilityTimer);
132
- if (this.completionsTimer) clearTimeout(this.completionsTimer);
133
- await Promise.allSettled([
134
- this.activeSchedulesPoll,
135
- this.activeRunningPoll,
136
- this.activeCompletionsPoll
137
- ]);
138
- }
139
- armSchedulesTimer() {
140
- if (this.stopped) return;
141
- this.scheduleTimer = setTimeout(() => {
142
- this.runSchedulesPoll("interval").finally(() => this.armSchedulesTimer());
143
- }, this.pollIntervalMs);
144
- this.scheduleTimer.unref();
145
- }
146
- armVisibilityTimer() {
147
- if (this.stopped) return;
148
- this.visibilityTimer = setTimeout(() => {
149
- this.runRunningPoll("interval").finally(() => this.armVisibilityTimer());
150
- }, this.visibilityPollIntervalMs);
151
- this.visibilityTimer.unref();
152
- }
153
- armCompletionsTimer() {
154
- if (this.stopped) return;
155
- this.completionsTimer = setTimeout(() => {
156
- this.runCompletionsPoll("interval").finally(() => this.armCompletionsTimer());
157
- }, this.completionsPollIntervalMs);
158
- this.completionsTimer.unref();
159
- }
160
- runSchedulesPoll(source) {
161
- if (this.schedulesPolling) return Promise.resolve();
162
- this.schedulesPolling = true;
163
- const promise = (async () => {
164
- try {
165
- await this.pollSchedules();
166
- } catch (err) {
167
- this.logger?.error(`SchedulesExporter ${source} schedule poll failed`, { err });
168
- } finally {
169
- this.schedulesPolling = false;
170
- this.activeSchedulesPoll = void 0;
171
- }
172
- })();
173
- this.activeSchedulesPoll = promise;
174
- return promise;
175
- }
176
- runRunningPoll(source) {
177
- if (this.runningPolling) return Promise.resolve();
178
- this.runningPolling = true;
179
- const promise = (async () => {
180
- try {
181
- await this.pollRunning();
182
- } catch (err) {
183
- this.logger?.error(`SchedulesExporter ${source} visibility poll failed`, { err });
184
- } finally {
185
- this.runningPolling = false;
186
- this.activeRunningPoll = void 0;
187
- }
188
- })();
189
- this.activeRunningPoll = promise;
190
- return promise;
191
- }
192
- runCompletionsPoll(source) {
193
- if (this.completionsPolling) return Promise.resolve();
194
- this.completionsPolling = true;
195
- const promise = (async () => {
196
- try {
197
- await this.pollCompleted();
198
- } catch (err) {
199
- this.logger?.error(`SchedulesExporter ${source} completions poll failed`, { err });
200
- } finally {
201
- this.completionsPolling = false;
202
- this.activeCompletionsPoll = void 0;
203
- }
204
- })();
205
- this.activeCompletionsPoll = promise;
206
- return promise;
207
- }
208
- /**
209
- * Snapshot of the next-N upcoming fires across all tracked schedules.
210
- * Each schedule contributes up to `nextActionSlots` events, ordered by `slot`.
211
- * Mount on a service HTTP route to feed the Grafana Business Calendar panel via the
212
- * Infinity datasource — the data shape is already calendar-event-shaped.
213
- */
214
- getCalendarEvents() {
215
- return this.calendarEvents;
216
- }
217
- /**
218
- * Snapshot of the most recent schedule actions across all tracked schedules,
219
- * ordered newest first. Capped at `recentActionsHistorySize` entries.
220
- */
221
- getRecentActions(limit = 50) {
222
- return this.recentActionsLog.slice(0, limit).map(({ key: _key, ...rest }) => rest);
223
- }
224
- async pollSchedules() {
225
- const events = [];
226
- const currentSeen = /* @__PURE__ */ new Map();
227
- for await (const summary of this.deps.client.schedule.list()) {
228
- const description = await this.deps.client.schedule.getHandle(summary.scheduleId).describe();
229
- const action = description.action;
230
- if (action.type !== "startWorkflow") continue;
231
- if (action.taskQueue !== this.deps.taskQueue) continue;
232
- const workflowType = action.workflowType;
233
- const baseLabels = {
234
- schedule_id: summary.scheduleId,
235
- workflow_type: workflowType,
236
- task_queue: this.deps.taskQueue
237
- };
238
- currentSeen.set(summary.scheduleId, baseLabels);
239
- const info = description.info;
240
- const state = description.state;
241
- const recentActions = info.recentActions ?? [];
242
- const upcoming = info.nextActionTimes ?? info.futureActionTimes ?? [];
243
- const pausedFlag = state.paused ? 1 : 0;
244
- const cadenceLabel = describeCadence(description.spec);
245
- this.paused.set(baseLabels, pausedFlag);
246
- const lastAction = recentActions[recentActions.length - 1];
247
- const lastSucceeded = lastAction ? lastAction.action ? 1 : 0 : null;
248
- if (lastAction) {
249
- const at = lastAction.takenAt ?? lastAction.scheduledAt;
250
- if (at) this.lastActionAge.set(baseLabels, (Date.now() - new Date(at).getTime()) / 1e3);
251
- this.lastActionSucceeded.set(baseLabels, lastAction.action ? 1 : 0);
252
- }
253
- for (const recent of recentActions) {
254
- const at = recent.takenAt ?? recent.scheduledAt;
255
- if (!at) continue;
256
- const key = `${summary.scheduleId}|${new Date(at).toISOString()}`;
257
- if (this.seenActionKeySet.has(key)) continue;
258
- this.seenActionKeySet.add(key);
259
- this.seenActionKeys.push(key);
260
- const succeeded = recent.action ? 1 : 0;
261
- this.recentActionsLog.push({
262
- key,
263
- scheduleId: summary.scheduleId,
264
- workflowType,
265
- taskQueue: this.deps.taskQueue,
266
- firedAt: new Date(at).toISOString(),
267
- firedAtMs: new Date(at).getTime(),
268
- succeeded
269
- });
270
- this.firesTotal.inc({
271
- ...baseLabels,
272
- result: succeeded ? "ok" : "failed"
273
- });
274
- }
275
- for (let slot = 0; slot < this.nextActionSlots; slot++) {
276
- const at = upcoming[slot];
277
- const slotLabels = {
278
- ...baseLabels,
279
- slot: String(slot)
280
- };
281
- if (!at) {
282
- this.nextActionAt.remove(slotLabels);
283
- this.nextActionEta.remove(slotLabels);
284
- continue;
285
- }
286
- const ms = new Date(at).getTime();
287
- this.nextActionAt.set(slotLabels, ms / 1e3);
288
- this.nextActionEta.set(slotLabels, Math.max(0, (ms - Date.now()) / 1e3));
289
- events.push({
290
- scheduleId: summary.scheduleId,
291
- workflowType,
292
- taskQueue: this.deps.taskQueue,
293
- cadence: cadenceLabel,
294
- slot,
295
- fireAt: new Date(ms).toISOString(),
296
- fireAtMs: ms,
297
- paused: pausedFlag,
298
- lastSucceeded
299
- });
300
- }
301
- const cadenceSeconds = computeCadenceSeconds(upcoming, description.spec);
302
- if (cadenceSeconds !== void 0) this.cadence.set(baseLabels, cadenceSeconds);
303
- else this.cadence.remove(baseLabels);
304
- }
305
- this.calendarEvents.length = 0;
306
- this.calendarEvents.push(...events);
307
- this.recentActionsLog.sort((a, b) => b.firedAtMs - a.firedAtMs);
308
- if (this.recentActionsLog.length > this.historySize) this.recentActionsLog.length = this.historySize;
309
- while (this.seenActionKeys.length > SchedulesExporter.ACTIONS_SEEN_CAPACITY) {
310
- const evicted = this.seenActionKeys.shift();
311
- if (evicted) this.seenActionKeySet.delete(evicted);
312
- }
313
- for (const [scheduleId, labels] of this.lastSeenScheduleLabels) {
314
- if (currentSeen.has(scheduleId)) continue;
315
- this.paused.remove(labels);
316
- this.lastActionAge.remove(labels);
317
- this.lastActionSucceeded.remove(labels);
318
- this.cadence.remove(labels);
319
- this.firesTotal.remove({
320
- ...labels,
321
- result: "ok"
322
- });
323
- this.firesTotal.remove({
324
- ...labels,
325
- result: "failed"
326
- });
327
- for (let slot = 0; slot < this.nextActionSlots; slot++) {
328
- const slotLabels = {
329
- ...labels,
330
- slot: String(slot)
331
- };
332
- this.nextActionAt.remove(slotLabels);
333
- this.nextActionEta.remove(slotLabels);
334
- }
335
- }
336
- this.lastSeenScheduleLabels.clear();
337
- for (const [scheduleId, labels] of currentSeen) this.lastSeenScheduleLabels.set(scheduleId, labels);
338
- }
339
- async pollRunning() {
340
- const buckets = /* @__PURE__ */ new Map();
341
- const query = `TaskQueue="${this.deps.taskQueue}" AND ExecutionStatus="Running"`;
342
- for await (const wf of this.deps.client.workflow.list({ query })) {
343
- const startMs = wf.startTime ? new Date(wf.startTime).getTime() : Date.now();
344
- const existing = buckets.get(wf.type) ?? {
345
- count: 0,
346
- oldestStartMs: Date.now()
347
- };
348
- existing.count += 1;
349
- if (startMs < existing.oldestStartMs) existing.oldestStartMs = startMs;
350
- buckets.set(wf.type, existing);
351
- }
352
- this.running.reset();
353
- this.oldestRunningAge.reset();
354
- for (const [workflowType, { count, oldestStartMs }] of buckets) {
355
- const labels = {
356
- workflow_type: workflowType,
357
- task_queue: this.deps.taskQueue
358
- };
359
- this.running.set(labels, count);
360
- this.oldestRunningAge.set(labels, (Date.now() - oldestStartMs) / 1e3);
361
- }
362
- }
363
- async pollCompleted() {
364
- const sinceMs = this.completionsLastPollMs;
365
- const nowMs = Date.now();
366
- const sinceIso = new Date(sinceMs).toISOString();
367
- const query = `TaskQueue="${this.deps.taskQueue}" AND ExecutionStatus="Completed" AND CloseTime>="${sinceIso}"`;
368
- let observed = 0;
369
- for await (const wf of this.deps.client.workflow.list({ query })) {
370
- const raw = wf.raw;
371
- const rawJson = raw && typeof raw.toJSON === "function" ? raw.toJSON() : raw;
372
- const runId = rawJson?.execution?.runId;
373
- const workflowType = rawJson?.type?.name;
374
- const startTimeRaw = rawJson?.startTime ?? (wf.startTime ? new Date(wf.startTime).toISOString() : void 0);
375
- const closeTimeRaw = rawJson?.closeTime ?? (wf.closeTime ? new Date(wf.closeTime).toISOString() : void 0);
376
- if (!runId || !workflowType || !startTimeRaw || !closeTimeRaw) continue;
377
- if (this.seenCompletedRunIdSet.has(runId)) continue;
378
- const durationSec = (new Date(closeTimeRaw).getTime() - new Date(startTimeRaw).getTime()) / 1e3;
379
- if (durationSec < 0) continue;
380
- this.workflowDuration.observe({
381
- workflow_type: workflowType,
382
- task_queue: this.deps.taskQueue
383
- }, durationSec);
384
- this.seenCompletedRunIdSet.add(runId);
385
- this.seenCompletedRunIds.push(runId);
386
- observed++;
387
- }
388
- while (this.seenCompletedRunIds.length > this.completionsSeenCapacity) {
389
- const evicted = this.seenCompletedRunIds.shift();
390
- if (evicted) this.seenCompletedRunIdSet.delete(evicted);
391
- }
392
- this.completionsLastPollMs = nowMs - this.completionsPollIntervalMs;
393
- if (observed > 0) this.logger?.debug("SchedulesExporter pollCompleted observed completions", {
394
- taskQueue: this.deps.taskQueue,
395
- observed
396
- });
397
- }
398
- };
399
- function getOrCreateGauge(name, help, labelNames) {
400
- const existing = promClient.register.getSingleMetric(name);
401
- if (existing instanceof promClient.Gauge) return existing;
402
- return new promClient.Gauge({
403
- name,
404
- help,
405
- labelNames
406
- });
407
- }
408
- function getOrCreateCounter(name, help, labelNames) {
409
- const existing = promClient.register.getSingleMetric(name);
410
- if (existing instanceof promClient.Counter) return existing;
411
- return new promClient.Counter({
412
- name,
413
- help,
414
- labelNames
415
- });
416
- }
417
- function getOrCreateHistogram(name, help, labelNames, buckets) {
418
- const existing = promClient.register.getSingleMetric(name);
419
- if (existing instanceof promClient.Histogram) return existing;
420
- return new promClient.Histogram({
421
- name,
422
- help,
423
- labelNames,
424
- buckets
425
- });
426
- }
427
- function describeCadence(spec) {
428
- const s = spec;
429
- if (!s) return "unknown";
430
- if (s.intervals?.length) {
431
- const every = s.intervals[0].every;
432
- if (typeof every === "number") return `every ${Math.round(every / 1e3)}s`;
433
- return `every ${every.toString()}`;
434
- }
435
- if (s.cronExpressions?.length) return s.cronExpressions[0];
436
- if (s.calendars?.length) {
437
- const calendar = s.calendars[0];
438
- return `cal ${calendar.hour ?? "*"}:${String(calendar.minute ?? 0).padStart(2, "0")}`;
439
- }
440
- return "unknown";
441
- }
442
- function computeCadenceSeconds(upcoming, spec) {
443
- if (upcoming.length >= 2) {
444
- const gap = new Date(upcoming[1]).getTime() - new Date(upcoming[0]).getTime();
445
- if (gap > 0) return gap / 1e3;
446
- }
447
- const s = spec;
448
- if (!s) return;
449
- if (s.intervals?.length) {
450
- const every = s.intervals[0].every;
451
- if (typeof every === "number") return every / 1e3;
452
- }
453
- if (s.calendars?.length) return 86400;
454
- }
455
- //#endregion
456
- export { SchedulesExporter };