Node.js integration

Wire Drumbeats into Node.js or TypeScript, including the failure modes that skip your error handler entirely.

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 text lands on the incident timeline. The success ping needs no body, so a GET is fine.

Node 18 or later for built-in fetch. The same shapes work on Bun and Deno.

Wrap it once#

Past two monitored jobs, hoist the IDs and the wrapper into one module. Adding a monitor then costs one line.

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

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;

/** Best effort. Never throws, never blocks longer than PING_TIMEOUT_MS. */
async function ping(
  monitor: string,
  event: "start" | "success" | "failure" | "log",
  runId?: string,
  payload?: string,
): Promise<void> {
  const url = new URL(`${BASE}/ping/${monitor}/${event}`);
  if (runId) url.searchParams.set("run_id", runId);

  const init: RequestInit = { signal: AbortSignal.timeout(PING_TIMEOUT_MS) };
  if (payload !== undefined) {
    init.method = "POST";
    init.headers = { "Content-Type": "application/json" };
    init.body = JSON.stringify({ payload: payload.slice(0, MAX_PAYLOAD) });
  }

  try {
    await fetch(url, init);
  } catch {
    // Side channel. A monitoring failure must never fail the job.
  }
}

export async function withMonitor<T>(
  key: MonitorKey,
  fn: () => Promise<T>,
  options?: { runId?: string },
): Promise<T> {
  const monitorId = MONITORS[key];
  const runId = options?.runId ?? `${key}-${crypto.randomUUID()}`;

  await ping(monitorId, "start", runId);
  try {
    const result = await fn();
    await ping(monitorId, "success", runId);
    return result;
  } catch (err) {
    const detail = err instanceof Error ? (err.stack ?? err.message) : String(err);
    await ping(monitorId, "failure", runId, detail);
    throw err;
  }
}
const BASE = process.env.DRUMBEATS_BASE_URL ?? "https://api.drumbeats.io/v1";
const PING_TIMEOUT_MS = 3_000;
const MAX_PAYLOAD = 20_000;

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;

/** Best effort. Never throws, never blocks longer than PING_TIMEOUT_MS. */
async function ping(
  monitor: string,
  event: "start" | "success" | "failure" | "log",
  runId?: string,
  payload?: string,
): Promise<void> {
  const url = new URL(`${BASE}/ping/${monitor}/${event}`);
  if (runId) url.searchParams.set("run_id", runId);

  const init: RequestInit = { signal: AbortSignal.timeout(PING_TIMEOUT_MS) };
  if (payload !== undefined) {
    init.method = "POST";
    init.headers = { "Content-Type": "application/json" };
    init.body = JSON.stringify({ payload: payload.slice(0, MAX_PAYLOAD) });
  }

  try {
    await fetch(url, init);
  } catch {
    // Side channel. A monitoring failure must never fail the job.
  }
}

export async function withMonitor<T>(
  key: MonitorKey,
  fn: () => Promise<T>,
  options?: { runId?: string },
): Promise<T> {
  const monitorId = MONITORS[key];
  const runId = options?.runId ?? `${key}-${crypto.randomUUID()}`;

  await ping(monitorId, "start", runId);
  try {
    const result = await fn();
    await ping(monitorId, "success", runId);
    return result;
  } catch (err) {
    const detail = err instanceof Error ? (err.stack ?? err.message) : String(err);
    await ping(monitorId, "failure", runId, detail);
    throw err;
  }
}

Every job becomes 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();
  });
}

The wrapper keeps the tail of the stack trace by slicing at 20 000 characters. Do this rather than letting Drumbeats truncate, because Drumbeats keeps the leading bytes and the useful part of a stack trace is usually at the end.

Handle each message separately in a queue worker#

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}`,
  });
});

Using the message ID as run_id means the Drumbeats run history lines up with your queue's own records, which makes tracing a specific failed message straightforward.

What happens when it breaks#

SituationWhat Drumbeats seesWhat to do
runMyJob() throwsfailure with the stack trace attachedNothing. This is the path the wrapper handles
A rejection escapes an unawaited promiseNothing at all. The wrapper already returned successAwait every promise inside fn, or ping from process.on("unhandledRejection")
A throw inside a setInterval callbackNothing. The scheduler swallows itWrap the callback body, not the setInterval call
process.exit() before the ping resolvesNothing. The socket never flushedAwait the ping before exiting, and never call process.exit() inside a wrapped job
The process is SIGKILLed or hits the OOM killerA start with no finishSet max_duration_seconds on the monitor so the hang is caught server-side

The last row is the one that catches people. No amount of JavaScript error handling survives a killed process. Server-side hang detection is the only thing that does, and it needs both max_duration_seconds on the monitor and a start ping from your code.

How you get alerted#

A failure ping opens a FAILED incident once failure_tolerance is reached, and a hung run does the same when max_duration_seconds elapses. Either pages every notification group on the monitor. The stack trace you attached shows up as a preview in the alert and in full on the incident timeline.

Run one build across environments#

The slug endpoint keeps the code identical and moves the difference into config:

typescript
const url = `${BASE}/s-ping/${process.env.DRUMBEATS_PROJECT_ID}/${slug}/${event}`;
const url = `${BASE}/s-ping/${process.env.DRUMBEATS_PROJECT_ID}/${slug}/${event}`;

Set the same slug in staging and production, change only the project ID.

Next#

Production hardening for the language-agnostic patterns. Event-driven pings for concurrency. Monitor types if you have not picked one yet. Alternatives if you are still choosing a vendor.