Node.js integration

Wire Drumbeats into a Node.js or TypeScript codebase. Quick example, centralized setup, error handling, and production patterns.

This guide shows how to wire Drumbeats into a Node.js or TypeScript project. By the end you have a single helper that wraps any job with start / success / failure pings and surfaces errors to the team without any per-job boilerplate.

The patterns below assume Node 18+ for built-in fetch. They work the same on Bun and Deno; replace fetch with the platform equivalent if you are on Node 16.

Quick example

The minimal shape — one ping at the start, one at the end:

typescript
const API = "https://api.drumbeats.io/v1/ping/<monitor-id>";

await fetch(`${API}/start`);
try {
  await runMyJob();
  await fetch(`${API}/success`);
} catch (err) {
  await fetch(`${API}/failure`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ payload: String(err) }),
  });
  throw err;
}
const API = "https://api.drumbeats.io/v1/ping/<monitor-id>";

await fetch(`${API}/start`);
try {
  await runMyJob();
  await fetch(`${API}/success`);
} catch (err) {
  await fetch(`${API}/failure`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ payload: String(err) }),
  });
  throw err;
}

POST the failure with a payload so the error message shows up on the incident timeline. The success ping can stay as a GET — it does not need a body.

Centralized setup

For apps with more than two monitored jobs, hoist the monitor IDs and the wrapper into a single module so adding a monitor never requires copy-pasting glue code.

src/monitoring/drumbeats.ts
const BASE = process.env.DRUMBEATS_BASE_URL ?? "https://api.drumbeats.io/v1";

export const MONITORS = {
  dailyBackup: "11111111-2222-3333-4444-555555555555",
  hourlySync: "66666666-7777-8888-9999-aaaaaaaaaaaa",
  newsletterSend: "bbbbbbbb-cccc-dddd-eeee-ffffffffffff",
} as const;

export type MonitorKey = keyof typeof MONITORS;

async function ping(monitor: string, event: "start" | "success" | "failure" | "log", runId?: string, payload?: unknown) {
  const url = new URL(`${BASE}/ping/${monitor}/${event}`);
  if (runId) url.searchParams.set("run_id", runId);
  if (payload === undefined) {
    await fetch(url.toString());
    return;
  }
  await fetch(url.toString(), {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ payload: typeof payload === "string" ? payload : JSON.stringify(payload) }),
  });
}

export async function withMonitor<T>(
  key: MonitorKey,
  fn: () => Promise<T>,
  options?: { runId?: string },
): Promise<T> {
  const monitorId = MONITORS[key];
  const runId = options?.runId ?? `${key}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
  await ping(monitorId, "start", runId);
  try {
    const result = await fn();
    await ping(monitorId, "success", runId);
    return result;
  } catch (err) {
    await ping(monitorId, "failure", runId, err instanceof Error ? err.stack ?? err.message : String(err));
    throw err;
  }
}
const BASE = process.env.DRUMBEATS_BASE_URL ?? "https://api.drumbeats.io/v1";

export const MONITORS = {
  dailyBackup: "11111111-2222-3333-4444-555555555555",
  hourlySync: "66666666-7777-8888-9999-aaaaaaaaaaaa",
  newsletterSend: "bbbbbbbb-cccc-dddd-eeee-ffffffffffff",
} as const;

export type MonitorKey = keyof typeof MONITORS;

async function ping(monitor: string, event: "start" | "success" | "failure" | "log", runId?: string, payload?: unknown) {
  const url = new URL(`${BASE}/ping/${monitor}/${event}`);
  if (runId) url.searchParams.set("run_id", runId);
  if (payload === undefined) {
    await fetch(url.toString());
    return;
  }
  await fetch(url.toString(), {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ payload: typeof payload === "string" ? payload : JSON.stringify(payload) }),
  });
}

export async function withMonitor<T>(
  key: MonitorKey,
  fn: () => Promise<T>,
  options?: { runId?: string },
): Promise<T> {
  const monitorId = MONITORS[key];
  const runId = options?.runId ?? `${key}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
  await ping(monitorId, "start", runId);
  try {
    const result = await fn();
    await ping(monitorId, "success", runId);
    return result;
  } catch (err) {
    await ping(monitorId, "failure", runId, err instanceof Error ? err.stack ?? err.message : String(err));
    throw err;
  }
}

Now every job is a one-liner:

src/jobs/daily-backup.ts
import { withMonitor } from "../monitoring/drumbeats";

export async function runDailyBackup() {
  return withMonitor("dailyBackup", async () => {
    await dumpDatabase();
    await uploadToS3();
  });
}
import { withMonitor } from "../monitoring/drumbeats";

export async function runDailyBackup() {
  return withMonitor("dailyBackup", async () => {
    await dumpDatabase();
    await uploadToS3();
  });
}

Add a new monitor: update MONITORS, wrap the job in withMonitor. That is the entire change.

Error handling

The wrapper above only catches errors that runMyJob() rethrows. A few cases need explicit handling:

  • Synchronous throws in a setInterval / setTimeout callback. Wrap the callback body in withMonitor instead of the scheduler call.
  • Unhandled promise rejections. If you rely on process.on('unhandledRejection'), send a failure ping there too so detached promises still alert.
  • Process-level exits. A worker that process.exit(1)s before the failure ping returns will lose the alert. Use the slug-based ping with await so the network request completes before exit, or send a final start only after the process is alive enough to send the matching failure.

For workers handling messages (queue consumers), each message gets its own withMonitor call with a unique run_id:

typescript
queue.consume(async (message) => {
  await withMonitor("messageProcessor", () => processMessage(message), {
    runId: `msg-${message.id}`,
  });
});
queue.consume(async (message) => {
  await withMonitor("messageProcessor", () => processMessage(message), {
    runId: `msg-${message.id}`,
  });
});

Production patterns

A small set of patterns turn the basic wrapper into a production-grade integration.

Time-bound the ping itself

A slow or hung Drumbeats request should not block your job. Wrap the fetch in AbortSignal.timeout:

typescript
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 3_000);
await fetch(url, { signal: controller.signal }).catch(() => {});
clearTimeout(timer);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 3_000);
await fetch(url, { signal: controller.signal }).catch(() => {});
clearTimeout(timer);

A failed ping should never throw — your job is the priority, not the side-channel.

Cap payload size

Drumbeats stores up to 25KB per ping payload for free; anything beyond costs additional beats (see Beats and usage). Truncate long stack traces before sending:

typescript
const MAX_PAYLOAD = 20_000;
const payload = err instanceof Error ? (err.stack ?? err.message) : String(err);
const truncated = payload.length > MAX_PAYLOAD ? `${payload.slice(0, MAX_PAYLOAD)}…` : payload;
const MAX_PAYLOAD = 20_000;
const payload = err instanceof Error ? (err.stack ?? err.message) : String(err);
const truncated = payload.length > MAX_PAYLOAD ? `${payload.slice(0, MAX_PAYLOAD)}…` : payload;

Send progress logs

For jobs that take more than a few seconds, send log pings so the incident timeline shows progress instead of a single start/finish pair:

typescript
await ping(MONITORS.dailyBackup, "log", runId, "Compressed dump (45MB)");
await ping(MONITORS.dailyBackup, "log", runId, "Compressed dump (45MB)");

log pings count as beats but are the cheapest observability you can add to a long-running job.

Use the slug endpoint for multi-environment

If you run the same job in staging and production, the slug-based endpoint avoids per-env monitor-ID juggling:

typescript
const url = `${BASE}/s-ping/${PROJECT_ID}/${SLUG}/${event}`;
const url = `${BASE}/s-ping/${PROJECT_ID}/${SLUG}/${event}`;

Set SLUG = "daily-backup" in both environments; Drumbeats routes to the right monitor based on the project ID. See Ping API: scheduled pings for the parameter list.

  • Monitor types — pick Cron, Heartbeat, or Event-driven before wiring pings.
  • Ping API — every endpoint, query parameter, and event type.
  • Production hardening — retries, timeouts, observability patterns for any language.
  • Alternatives — how Drumbeats compares to Cronitor and Healthchecks.io when you're picking a vendor.