@runsignal/node 0.1.0 → 0.2.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/README.md CHANGED
@@ -1,17 +1,30 @@
1
1
  # RunSignal for Node.js
2
2
 
3
- Monitor Node.js background work that no HTTP status code describes, such as BullMQ queues, job schedulers and
4
- sync loops, with OpenTelemetry. It emits the same signals and manifest as every other RunSignal SDK, so the
5
- `runsignal` CLI provisions the same New Relic alerts for Node.js services as for .NET and Go ones. See the
6
- [main README](../../README.md) for the concepts and the alerts.
3
+ [![npm](https://img.shields.io/npm/v/@runsignal/node)](https://www.npmjs.com/package/@runsignal/node)
4
+ [![npm downloads](https://img.shields.io/npm/dm/@runsignal/node)](https://www.npmjs.com/package/@runsignal/node)
5
+ [![license](https://img.shields.io/npm/l/@runsignal/node)](https://github.com/gokhanertogan/RunSignal/blob/main/LICENSE)
6
+
7
+ **Cron job and background job monitoring for Node.js, on OpenTelemetry.** Get an alert when a scheduled job
8
+ does not run, fails, runs too long or processes nothing, and when a BullMQ worker or sync loop stops making
9
+ progress: the failures no HTTP status code describes. Works with node-cron, croner, cron, node-schedule and
10
+ BullMQ, with no hosted service and no ping URLs.
11
+
12
+ RunSignal records runs, items and heartbeats as OpenTelemetry metrics, with the same names as every other
13
+ RunSignal SDK, so the `runsignal` CLI provisions the same alerts for Node.js services as for .NET, Go, Java and
14
+ Rust ones: New Relic alerts and dashboards, or Prometheus rules and a Grafana dashboard. The
15
+ [main README](https://github.com/gokhanertogan/RunSignal#readme) covers the concepts, the alerts and the
16
+ backends.
17
+
18
+ ## Installation
7
19
 
8
20
  ```bash
9
- npm install @runsignal/node @opentelemetry/api # once published; until then, build from source
21
+ npm install @runsignal/node @opentelemetry/api
10
22
  ```
11
23
 
12
- Requires Node.js 20 or later. The package is ESM only and ships its own TypeScript types.
24
+ Requires Node.js 20 or later. The package is ESM only, ships its own TypeScript types, and depends only on
25
+ `@opentelemetry/api` (peer). BullMQ 5.16+ is an optional peer for the BullMQ integration.
13
26
 
14
- ## Usage
27
+ ## Quick start
15
28
 
16
29
  ```ts
17
30
  import { defaultRegistry } from '@runsignal/node';
@@ -23,45 +36,55 @@ const invoices = defaultRegistry.workload('nightly-invoice', {
23
36
  severity: 'critical',
24
37
  });
25
38
 
26
- // One run: succeeds when fn resolves, is cancelled if the signal was aborted, fails otherwise.
27
- await invoices.run(async (signal) => {
39
+ await invoices.run(async () => {
28
40
  for (const order of await unbilledOrders()) {
29
- await createInvoice(order, { signal });
41
+ await createInvoice(order);
30
42
  invoices.processed();
31
43
  }
32
- }, { signal: shutdown.signal });
44
+ });
33
45
  ```
34
46
 
35
- A long-running loop sends heartbeats, so a loop that is alive but stuck is noticed:
36
-
37
- ```ts
38
- const sync = defaultRegistry.workload('stock-sync', { expectEvery: '5m' });
39
- setInterval(async () => {
40
- await syncStock();
41
- sync.beat();
42
- }, 60_000);
43
- ```
47
+ The registry follows the global meter provider, so it works even when it is created by an import before the
48
+ OpenTelemetry SDK starts. For an explicit provider, use `new WorkloadRegistry({ meterProvider })`.
44
49
 
45
- Manual runs, and failures without an error:
50
+ ## Recording work
46
51
 
47
- ```ts
48
- const run = imports.start();
49
- const file = await fetchStatement();
50
- if (!file) {
51
- run.failCode('statement_missing', 'no bank statement today');
52
- } else {
53
- run.succeed();
54
- }
55
- ```
52
+ | Call | Records |
53
+ |---|---|
54
+ | `w.run(fn, { signal })` | One run. It succeeds when `fn` resolves, is cancelled when `signal` was aborted, and fails otherwise. The run's span is active inside `fn` (with a context manager, which `NodeSDK` registers). |
55
+ | `const run = w.start()` then `run.succeed()` / `fail(err)` / `failCode(code, msg)` / `cancel()` | A run ended by hand. Later calls are ignored. |
56
+ | `w.processed(n)` / `w.itemFailed(n)` | Items handled. |
57
+ | `w.beat()` | A heartbeat from a long-running loop. |
56
58
 
57
59
  `error.type` is the error's `errorType` property if it has one, then its `code` if that is a string (such as
58
60
  `ECONNREFUSED`), then its class name.
59
61
 
60
- The registry follows the global meter provider. It works even when it is created, for example by an import,
61
- before the OpenTelemetry SDK starts. Run spans are nested under the active span when a context manager is
62
- registered, which `NodeSDK` does for you.
62
+ ## Cron libraries
63
63
 
64
- ### BullMQ
64
+ Since 0.2.0, `cronJob` declares a scheduled job once, with its schedule, and gives you a tick handler for any cron library.
65
+ The workload is registered immediately, so a job that never fires still gets a `missed` alert.
66
+
67
+ ```ts
68
+ import { cronJob } from '@runsignal/node/cron';
69
+
70
+ const invoices = cronJob('nightly-invoice', { schedule: '0 2 * * *', maxDuration: '30m', minProcessed: 100 }, async (w) => {
71
+ for (const order of await unbilledOrders()) {
72
+ await createInvoice(order);
73
+ w.processed();
74
+ }
75
+ });
76
+
77
+ cron.schedule(invoices.schedule, invoices.run); // node-cron
78
+ new Cron(invoices.schedule, invoices.run); // croner
79
+ CronJob.from({ cronTime: invoices.schedule, onTick: invoices.run, start: true }); // cron
80
+ schedule.scheduleJob(invoices.schedule, invoices.run); // node-schedule
81
+ ```
82
+
83
+ - **Failures:** a failed tick is recorded with its error type and passed to `onError` (default `console.error`). It is not rethrown, because most cron libraries turn a rejected tick into an unhandled rejection.
84
+ - **Options:** everything `workload()` takes, plus `registry` and `onError`. Declaring the same name twice throws.
85
+ - **Time zones:** if your scheduler runs the pattern in a time zone, pass the same one to the CLI with `--time-zone`.
86
+
87
+ ## BullMQ
65
88
 
66
89
  ```ts
67
90
  import { discoverSchedulers, instrument, workloadOf } from '@runsignal/node/bullmq';
@@ -75,20 +98,20 @@ await queue.upsertJobScheduler('Daily Sales Report', { pattern: '0 0 6 * * *' },
75
98
  await discoverSchedulers(queue); // registers schedulers before they first fire
76
99
  ```
77
100
 
78
- - **What gets recorded:** every job, including success, failure with its error type, cancellation (when BullMQ aborts the job) and duration. Results and errors pass through, so retries work as before.
101
+ - **What is recorded:** every job, including success, failure with its error type, cancellation (when BullMQ aborts the job) and duration. Results and errors pass through, so retries work as before.
79
102
  - **Naming:** jobs from a job scheduler are named after the scheduler (`Daily Sales Report` becomes `daily-sales-report`). Other jobs are named after their queue and job name (`emails.send-welcome`).
80
- - **Discovery:** `discoverSchedulers` takes each scheduler's cron pattern as its schedule, so a scheduler that never fires gets a `missed` alert. Schedulers that use `every` get no schedule.
81
- - **Redis client:** in ESM, BullMQ 6 needs a constructed Redis client, such as `connection: new Redis({ maxRetriesPerRequest: null })` from `ioredis`, instead of connection options.
103
+ - **Discovery:** `discoverSchedulers` takes each scheduler's cron pattern as its schedule, so a scheduler that never fires gets a `missed` alert.
104
+ - **Redis client:** in ESM, BullMQ 6 needs a constructed client, such as `connection: new Redis({ maxRetriesPerRequest: null })` from `ioredis`.
82
105
 
83
- ### Manifest
106
+ ## Manifest
84
107
 
85
108
  ```ts
86
109
  defaultRegistry.writeManifestFromEnv('billing-service'); // writes to RUNSIGNAL_MANIFEST_PATH when it is set
87
110
  ```
88
111
 
89
- ### Exporting to New Relic
112
+ ## Exporting
90
113
 
91
- Use delta temporality. With cumulative, a stopped workload never looks stopped:
114
+ **New Relic** needs delta temporality. With cumulative, a stopped workload never looks stopped:
92
115
 
93
116
  ```ts
94
117
  new OTLPMetricExporter({
@@ -98,12 +121,15 @@ new OTLPMetricExporter({
98
121
  });
99
122
  ```
100
123
 
101
- A complete program is in [examples/worker](examples/worker).
124
+ **Prometheus 3** receives OTLP at `/api/v1/otlp/v1/metrics` with the default (cumulative) temporality. Generate
125
+ its rules with `runsignal export prometheus`.
126
+
127
+ A complete program is in [examples/worker](https://github.com/gokhanertogan/RunSignal/tree/main/src/RunSignal.Node/examples/worker).
102
128
 
103
129
  ## Performance
104
130
 
105
131
  Measured on Apple Silicon with Node.js 26 and the OpenTelemetry metrics SDK attached; the times include its
106
- aggregation. Run with `npm run bench`.
132
+ aggregation. Run `npm run bench`.
107
133
 
108
134
  | Operation | Time |
109
135
  |---|---|
@@ -115,6 +141,10 @@ create the registry with `new WorkloadRegistry({ tracing: false })`.
115
141
 
116
142
  ## Development
117
143
 
118
- ```bash
119
- npm test # needs redis-server on the PATH; the BullMQ tests start their own instance
120
- ```
144
+ `npm test` needs `redis-server` on the `PATH`; the BullMQ tests start their own instance.
145
+
146
+ ## Links
147
+
148
+ [Specification](https://github.com/gokhanertogan/RunSignal/blob/main/spec/README.md) ·
149
+ [Changelog](https://github.com/gokhanertogan/RunSignal/blob/main/CHANGELOG.md) ·
150
+ [Other languages](https://github.com/gokhanertogan/RunSignal#quick-start)
package/dist/cron.d.ts ADDED
@@ -0,0 +1,33 @@
1
+ import { type Workload, type WorkloadOptions, type WorkloadRegistry } from './registry.js';
2
+ export interface CronJobOptions extends WorkloadOptions {
3
+ /** Cron expression the job runs on. Pass `job.schedule` to your scheduler so the alert and the job agree. */
4
+ schedule: string;
5
+ /** The registry to record on. Defaults to `defaultRegistry`. */
6
+ registry?: WorkloadRegistry;
7
+ /**
8
+ * Called with the error of a failed run, after the failure is recorded. Defaults to `console.error`. The error
9
+ * is not rethrown, because most cron libraries turn a rejected tick into an unhandled rejection.
10
+ */
11
+ onError?: (error: unknown, workload: Workload) => void;
12
+ }
13
+ export interface CronJob {
14
+ /** The workload each tick runs as, for counting items or heartbeats outside `fn`. */
15
+ readonly workload: Workload;
16
+ /** The cron expression the job was declared with. */
17
+ readonly schedule: string;
18
+ /** Runs `fn` once as a run of the workload. Pass it to your cron library as the tick handler. */
19
+ readonly run: () => Promise<void>;
20
+ }
21
+ /**
22
+ * Declares a scheduled job as a workload with `options.schedule` as its schedule, so it gets a missed alert from
23
+ * startup, even before it first fires. Works with any cron library: node-cron, croner, cron or node-schedule.
24
+ *
25
+ * ```ts
26
+ * const invoices = cronJob('nightly-invoice', { schedule: '0 2 * * *', maxDuration: '30m' }, async (w) => {
27
+ * for (const order of await unbilledOrders()) { await createInvoice(order); w.processed(); }
28
+ * });
29
+ * cron.schedule(invoices.schedule, invoices.run); // node-cron
30
+ * new Cron(invoices.schedule, invoices.run); // croner
31
+ * ```
32
+ */
33
+ export declare function cronJob(name: string, options: CronJobOptions, fn: (workload: Workload) => unknown): CronJob;
package/dist/cron.js ADDED
@@ -0,0 +1,33 @@
1
+ import { defaultRegistry } from './registry.js';
2
+ /**
3
+ * Declares a scheduled job as a workload with `options.schedule` as its schedule, so it gets a missed alert from
4
+ * startup, even before it first fires. Works with any cron library: node-cron, croner, cron or node-schedule.
5
+ *
6
+ * ```ts
7
+ * const invoices = cronJob('nightly-invoice', { schedule: '0 2 * * *', maxDuration: '30m' }, async (w) => {
8
+ * for (const order of await unbilledOrders()) { await createInvoice(order); w.processed(); }
9
+ * });
10
+ * cron.schedule(invoices.schedule, invoices.run); // node-cron
11
+ * new Cron(invoices.schedule, invoices.run); // croner
12
+ * ```
13
+ */
14
+ export function cronJob(name, options, fn) {
15
+ const { registry = defaultRegistry, onError = defaultOnError, ...expectations } = options;
16
+ const workload = registry.workload(name, expectations);
17
+ return {
18
+ workload,
19
+ schedule: options.schedule,
20
+ run: async () => {
21
+ try {
22
+ await workload.run(() => fn(workload));
23
+ }
24
+ catch (error) {
25
+ onError(error, workload);
26
+ }
27
+ },
28
+ };
29
+ }
30
+ function defaultOnError(error, workload) {
31
+ console.error(`runsignal: a run of ${workload.name} failed`, error);
32
+ }
33
+ //# sourceMappingURL=cron.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cron.js","sourceRoot":"","sources":["../src/cron.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAA8D,MAAM,eAAe,CAAC;AAuB5G;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,OAAO,CAAC,IAAY,EAAE,OAAuB,EAAE,EAAmC;IAChG,MAAM,EAAE,QAAQ,GAAG,eAAe,EAAE,OAAO,GAAG,cAAc,EAAE,GAAG,YAAY,EAAE,GAAG,OAAO,CAAC;IAC1F,MAAM,QAAQ,GAAG,QAAQ,CAAC,QAAQ,CAAC,IAAI,EAAE,YAAY,CAAC,CAAC;IACvD,OAAO;QACL,QAAQ;QACR,QAAQ,EAAE,OAAO,CAAC,QAAQ;QAC1B,GAAG,EAAE,KAAK,IAAI,EAAE;YACd,IAAI,CAAC;gBACH,MAAM,QAAQ,CAAC,GAAG,CAAC,GAAG,EAAE,CAAC,EAAE,CAAC,QAAQ,CAAC,CAAC,CAAC;YACzC,CAAC;YAAC,OAAO,KAAK,EAAE,CAAC;gBACf,OAAO,CAAC,KAAK,EAAE,QAAQ,CAAC,CAAC;YAC3B,CAAC;QACH,CAAC;KACF,CAAC;AACJ,CAAC;AAED,SAAS,cAAc,CAAC,KAAc,EAAE,QAAkB;IACxD,OAAO,CAAC,KAAK,CAAC,uBAAuB,QAAQ,CAAC,IAAI,SAAS,EAAE,KAAK,CAAC,CAAC;AACtE,CAAC"}
@@ -3,7 +3,7 @@
3
3
  * SDK (see spec/README.md in the repository), so one set of alert rules covers every language.
4
4
  */
5
5
  export declare const SCOPE_NAME = "RunSignal";
6
- export declare const VERSION = "0.1.0";
6
+ export declare const VERSION = "0.2.0";
7
7
  export declare const Metrics: {
8
8
  readonly runs: 'runsignal.workload.runs';
9
9
  readonly duration: 'runsignal.workload.duration';
package/dist/telemetry.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * SDK (see spec/README.md in the repository), so one set of alert rules covers every language.
4
4
  */
5
5
  export const SCOPE_NAME = 'RunSignal';
6
- export const VERSION = '0.1.0';
6
+ export const VERSION = '0.2.0';
7
7
  export const Metrics = {
8
8
  runs: 'runsignal.workload.runs',
9
9
  duration: 'runsignal.workload.duration',
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@runsignal/node",
3
- "version": "0.1.0",
4
- "description": "Monitor Node.js background work that no HTTP status code describes (scheduled jobs, BullMQ queues, sync loops) with OpenTelemetry, and alert on missed, overrunning, stalled or low-output jobs.",
3
+ "version": "0.2.0",
4
+ "description": "Cron job and background job monitoring for Node.js with OpenTelemetry: alert when a scheduled job is missed, fails, overruns or stalls, or a BullMQ queue stops processing. Works with node-cron, croner, cron, node-schedule and BullMQ; alerts for New Relic, Prometheus and Grafana.",
5
5
  "license": "MIT",
6
6
  "author": "Gokhan Ertogan",
7
7
  "repository": {
@@ -10,15 +10,29 @@
10
10
  "directory": "src/RunSignal.Node"
11
11
  },
12
12
  "keywords": [
13
- "observability",
14
- "opentelemetry",
15
- "monitoring",
16
- "alerting",
17
- "background-jobs",
13
+ "cron-monitoring",
18
14
  "cron",
15
+ "job-monitoring",
16
+ "background-jobs",
17
+ "scheduled-jobs",
19
18
  "heartbeat",
19
+ "dead-mans-switch",
20
+ "healthcheck",
20
21
  "bullmq",
21
- "newrelic"
22
+ "node-cron",
23
+ "croner",
24
+ "node-schedule",
25
+ "queue-monitoring",
26
+ "worker",
27
+ "opentelemetry",
28
+ "otel",
29
+ "observability",
30
+ "monitoring",
31
+ "alerting",
32
+ "prometheus",
33
+ "grafana",
34
+ "newrelic",
35
+ "typescript"
22
36
  ],
23
37
  "type": "module",
24
38
  "engines": {
@@ -32,6 +46,10 @@
32
46
  "./bullmq": {
33
47
  "types": "./dist/bullmq.d.ts",
34
48
  "default": "./dist/bullmq.js"
49
+ },
50
+ "./cron": {
51
+ "types": "./dist/cron.d.ts",
52
+ "default": "./dist/cron.js"
35
53
  }
36
54
  },
37
55
  "files": [