Short answer: configure Node.js error email around each scheduled customer-support import's last committed result. Record the run identifier, timing, and error category, then have a separate watchdog send one message when the import fails or misses its progress deadline. The message should explain where reconstruction starts.
A process can stay alive while its import produces nothing. A useful alert therefore needs an expectation: which import should have delivered results, by when, and what evidence shows its last progress? Without that expectation, exception emails leave a hole in the incident timeline.
The implementation below is a local, single-watchdog example. Its numbers are illustrative configuration, not measurements: a support-ticket import runs every 15 minutes, and each run gets 10 minutes to commit results. Adjust those assumptions to the actual schedule before enabling delivery.
1. How should email notifications for import errors support observability?
Start with reconstruction. A support operator needs to know whether tickets stopped arriving before the run began, during fetching, or before results were committed. Sending only an exception stack leaves the operator searching for the relevant account and execution. Sending every ticket payload creates a different problem: the notification becomes a second store of customer conversations.
Keep the evidence small.
Use a stable import identifier and a new run identifier for every attempt. Record a start, a committed result count, and a classified failure. Count results after the destination commit succeeds; a fetched page is not evidence that support staff can see the tickets. The example stores these records as a local event journal, and its emails carry an import ID and run ID rather than customer content. This is a deliberate boundary. The journal describes progress and failure; it does not contain ticket bodies, credentials, or arbitrary exception messages. A production integration can keep a sanitized stack trace in restricted logs and use the same run ID to connect it to the email. For the synthetic incident below, the recipient gets both run-current and the time of the previous commit. Those two pieces of evidence answer different questions: which attempt needs investigation, and how far back should the operator look for missing results? A stack trace alone cannot supply the second answer unless the application records that progress context too.
The constraint that changes the design is silence. An error callback cannot report that it never ran. Put the watchdog outside the importer, and provision the schedule independently so an import that never starts still has a deadline.
For a one-person SaaS, the revenue-per-hour lens favors a small path from notification to diagnosis. Ship weekly. Outsource the undifferentiated mail transport when appropriate, while keeping the progress rule and evidence schema under your control. Those choices preserve time for support features without making incident reconstruction depend on a particular service.
2. Write the smallest useful progress journal
These examples use TypeScript with Node.js built-in modules. Use a runtime that supports native TypeScript type stripping, and save the three snippets in the same directory. Execute each file with Node; no package dependency is needed. The final section links the runtime documentation.
Save this first file as seed.ts and run it to create a synthetic incident. It overwrites only the example journal, so use a disposable directory. The relative timestamps deliberately represent one successful execution followed by a failed execution; they are test data, not a customer incident.
import { writeFileSync } from "node:fs"; const now = Date.now(); const events = [ { importId: "support-tickets", runId: "run-previous", kind: "committed", at: now - 30 * 60_000, count: 42, }, { importId: "support-tickets", runId: "run-current", kind: "started", at: now - 15 * 60_000, }, { importId: "support-tickets", runId: "run-current", kind: "failed", at: now - 12 * 60_000, errorCode: "SOURCE_TIMEOUT", }, ]; writeFileSync( "./support-import-events.jsonl", events.map((event) => JSON.stringify(event)).join("\n") + "\n", { mode: 0o600 }, ); For the real importer, emit the equivalent records at the actual boundaries. The committed event belongs after the database transaction, with the number of results committed by that run. A failed event belongs in the job's error handling path, with a bounded classification such as SOURCE_TIMEOUT. Retain the original failure behavior; recording an event should not turn a failed job into a reported success.
A zero-result run deserves an explicit decision.
Here, zero committed tickets does not reset the progress deadline because the stated job is to detect imports that stop producing results. If an empty source is a normal business condition, add a separate verified-empty event and define when it satisfies the schedule. Otherwise, quiet days will produce ambiguous alerts. Do not silently redefine “completed” as “delivered results.”
Metric naming guidance also matters if this journal later feeds counters. Use a total counter for committed results, with units and naming that make aggregation understandable. Keep unique run identifiers in the journal rather than metric names. The supplied naming reference explains why unbounded dimensions are a poor fit for metrics.
3. Evaluate the deadline and hand off one email
Save the next file as watchdog.ts. Its default mode prints the proposed email. To enable delivery, configure MAIL_MODE=send, MAIL_FROM, and MAIL_TO in the watchdog's environment, and provide an operational local mail transport at /usr/sbin/sendmail. That executable is a deployment prerequisite, not something Node installs.
The delivery path uses standard input, not a shell command assembled from event data. Mailbox values are intentionally restricted to a simple address form. This keeps the example small; it is not a general email address parser.
import { existsSync, readFileSync, renameSync, writeFileSync, } from "node:fs"; import { execFileSync } from "node:child_process"; import { pathToFileURL } from "node:url"; export type Event = { importId: string; runId: string; kind: "started" | "committed" | "failed"; at: number; count?: number; errorCode?: string; }; export function classify(events: Event[], now: number) { const importId = "support-tickets"; const scheduleMs = 15 * 60_000; const graceMs = 10 * 60_000; const history = events .filter((event) => event.importId === importId) .sort((a, b) => a.at - b.at); const progress = history.filter( (event) => event.kind === "committed" && (event.count ?? 0) > 0, ).at(-1); const latest = history.at(-1); const failure = history.filter( (event) => event.kind === "failed" && event.at > (progress?.at ?? -Infinity), ).at(-1); const lastProgressAt = progress?.at ?? now; if (!failure && progress && now - lastProgressAt <= scheduleMs + graceMs) { return null; } return { key: JSON.stringify([importId, progress?.runId ?? "no-progress"]), importId, reason: failure ? "recorded failure" : "progress deadline missed", runId: failure?.runId ?? latest?.runId ?? "no run recorded", lastProgress: progress ? new Date(progress.at).toISOString() : "none recorded", lastEvent: latest ? new Date(latest.at).toISOString() : "none recorded", errorCode: failure?.errorCode ?? "NO_PROGRESS", }; } function mailbox(name: string): string { const value = process.env[name] ?? ""; if (!/^[A-Za-z0-9._+-]+@[A-Za-z0-9.-]+$/.test(value)) { throw new Error("Configure a simple mailbox for " + name); } return value; } function main() { const journal = "./support-import-events.jsonl"; const statePath = "./support-import-notified.json"; const events = readFileSync(journal, "utf8") .split("\n") .filter(Boolean) .map((line) => JSON.parse(line) as Event); const incident = classify(events, Date.now()); if (!incident) return; const state = existsSync(statePath) ? JSON.parse(readFileSync(statePath, "utf8")) as { key: string } : null; if (state?.key === incident.key) return; const subject = "Support import needs attention"; const body = [ "Import: " + incident.importId, "Reason: " + incident.reason, "Run: " + incident.runId, "Last committed results: " + incident.lastProgress, "Last recorded event: " + incident.lastEvent, "Error category: " + incident.errorCode, "Next: inspect this run in the restricted import journal.", ].join("\n"); if (process.env.MAIL_MODE !== "send") { console.log(subject + "\n\n" + body); return; } const message = [ "From: " + mailbox("MAIL_FROM"), "To: " + mailbox("MAIL_TO"), "Subject: " + subject, "Content-Type: text/plain; charset=UTF-8", "", body, "", ].join("\n"); execFileSync("/usr/sbin/sendmail", ["-t", "-i"], { input: message, timeout: 10_000, stdio: ["pipe", "pipe", "pipe"], }); writeFileSync(statePath + ".tmp", JSON.stringify({ key: incident.key }), { mode: 0o600, }); renameSync(statePath + ".tmp", statePath); } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { main(); } Run the watchdog after seeding the journal. The dry run should identify run-current, show SOURCE_TIMEOUT, and preserve the timestamp of the previous committed results. That is the judgment criterion: the recipient can distinguish the failing attempt from the last useful result.
The state key groups failures under the last successful run. Once the transport accepts the message, later watchdog invocations suppress that incident. A new successful run changes the grouping boundary, so a later failure can notify again. Dry runs do not write notification state.
Duplicates remain possible.
The trade-off is a smaller implementation at the cost of weaker delivery guarantees. Acceptance by a local transport is not proof that a human received the email. A crash after handoff but before saving the state can also produce a duplicate. Treat the sample as a single-process demonstration with persistent local files, one watchdog invocation at a time, and trusted journal input. It does not provide exactly-once delivery or concurrent journal access.
An unreadable or malformed journal throws instead of producing a healthy result. Configure the scheduler to surface watchdog execution failures through a separate operational channel. Otherwise, the component responsible for detecting silence can become silent itself.
4. Test the reconstruction rule before scheduling it
Save this as check.ts and execute it with the same runtime. These assertions test the decisions that change an operator's response: fresh results, overdue results, explicit failure, and no execution history.
import assert from "node:assert/strict"; import { classify } from "./watchdog.ts"; import type { Event } from "./watchdog.ts"; const now = 1_800_000; const committed: Event = { importId: "support-tickets", runId: "run-good", kind: "committed", at: now - 60_000, count: 8, }; assert.equal(classify([committed], now), null); assert.equal( classify([{ ...committed, at: now - 26 * 60_000 }], now)?.reason, "progress deadline missed", ); assert.equal( classify([ committed, { importId: "support-tickets", runId: "run-bad", kind: "failed", at: now, errorCode: "SOURCE_TIMEOUT", }, ], now)?.runId, "run-bad", ); assert.equal(classify([], now)?.lastProgress, "none recorded"); assert.equal( classify([{ ...committed, count: 0 }], now)?.reason, "progress deadline missed", ); console.log("Import alert decisions passed."); Empty history alerts immediately in this example. Enable the watchdog when the import is expected to be active, or introduce an explicit activation timestamp if onboarding has a grace period. An absent execution must not inherit a fresh deadline on every poll.
Schedule the watchdog at a cadence that matches the response requirement. For example, checking once per minute adds up to roughly one checking interval of delay after a deadline, excluding scheduler delays and execution time. Deploy in dry-run mode first and inspect the resulting evidence. Then enable mail and verify receipt through the actual transport, including its failure reporting.
Keep the state file across restarts.
Losing it reopens the same incident. A weekly deployment should not erase the only record that the operator was already notified.
5. What would change at scale?
Keep the reconstruction contract. Replace the local journal and notification marker with durable shared storage, validate event schemas, and use transactional claiming when multiple workers can evaluate the same import. Store the configured schedule for each import rather than hard-coding one identifier. Bound journal retention according to the investigation window, and retain the evidence needed to explain the alert.
Separate incident state from delivery attempts. A durable outbox can track pending messages, transport failures, and retry timing, while the incident remains open until results resume. Retries need limits and backoff; reminders need a deliberate policy. Neither should turn one stuck import into a stream of indistinguishable emails.
The operating choice is about time to reconstruct the missing results. Preserve the last commit, the failing run, and the expected deadline. Add infrastructure when those records can no longer be maintained safely by the small implementation. That gives a solo founder a concrete reason to expand the system while keeping weekly shipping practical.
References
These sources document the naming convention and runtime mechanisms used above. The schedule, fixture, alert policy, and storage boundaries are design choices in this example.
- Metric naming and label guidance: https://prometheus.io/docs/practices/naming/
- Native TypeScript execution: https://nodejs.org/api/typescript.html
- File operations and their API behavior: https://nodejs.org/api/fs.html
- Child-process execution and standard input: https://nodejs.org/api/child_process.html


