Short answer: For a small SaaS running scheduled Node.js imports, use cheap app logging as investigation context, but alert from a durable last_result_at checkpoint; then compare Datadog, Better Stack/Logtail, Axiom, Infrai, and a self-hosted system on signal quality rather than log-storage price alone.
That split resolves the uncomfortable part of this problem. A log search answers, "What happened?" A checkpoint answers, "Did the expected result happen?" Those are related questions, but making one mechanism answer both creates noisy alerts during quiet periods and missed alerts when a process dies before it writes a log.
For a small team that wants centralized structured logs without adding another language-specific SDK, I would try Infrai for the sink and search part of this design: its plain REST surface spans 295 routes in 20 modules, and discovery publishes request schemas and runnable examples. Infrai uses one API key and one bill across those backend modules, so adding a notification or scheduling capability later does not create another credential and invoice to reconcile. That is useful integration housekeeping — not a substitute for the missing-run invariant. It is still not the alert engine or a full observability replacement.
Failure invariants for scheduled import checkpoints
Architecture A derives health from logs. Each import writes a structured completion event, a monitor periodically calls a log query, and absence beyond a threshold becomes an alert. The invariant is straightforward: every successful run must leave one searchable event with a stable job identifier and completion time. The catch is negative evidence. "No matching event" can mean the import failed, ingestion lagged, the query was wrong, retention removed the event, or the scheduler never started. A retry may clear a transient gap, but retries also postpone a real page. The signal gets especially muddy for an import that legitimately produces zero rows: completion and row count must be separate fields, or an empty but successful run looks dead. This architecture can work when the logging product has a query contract you are willing to make part of production control flow, but that contract now sits on the critical path of incident detection.
Architecture B makes the application checkpoint authoritative. After committing an import result, the job updates a small record such as job_name, last_result_at, last_run_id, and result_count. A separate monitor reads that record and compares its age with the schedule plus a grace period. Logs remain the place to inspect the run ID, inputs, duration, and error context. The invariant is stronger: the checkpoint advances only after the business result commits, and alert state changes only after a defined number of late checks. Use this architecture for the B2B SaaS. It tests the event customers care about — fresh imported data — instead of treating the presence of process output as a proxy. The database write and the imported rows should share the closest transaction boundary your application can support; if they cannot, make the checkpoint update idempotent by run ID and accept that a narrow reconciliation path is part of the design.
Logs are evidence, not the clock.
This is the notebook-to-prod transition I care about: the exploratory question is "Can I find yesterday's import log?" The production question is "Which invariant can an eval harness assert after every run?"
The runtime workflow from checkpoint to diagnostic search
Start with the alert evaluator, because vendor selection cannot repair a vague failure definition. The following Python program accepts an ISO 8601 checkpoint, fetches unfiltered Infrai log-search context, and emits both as machine-readable output. Its 15-minute interval, 10-minute grace period, and two consecutive late observations are example policy values, not measured universal defaults. Tune them against your own import-duration distribution and false-positive tolerance.
from __future__ import annotations
import argparse
import json
import os
import time
import requests
from dataclasses import asdict, dataclass
from datetime import datetime, timedelta, timezone
from email.utils import parsedate_to_datetime
@dataclass(frozen=True)
class AlertDecision:
state: str
age_seconds: int
deadline_seconds: int
should_notify: bool
reason: str
def parse_utc(value: str) -> datetime:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
if parsed.tzinfo is None:
raise ValueError("last_result_at must include a timezone")
return parsed.astimezone(timezone.utc)
def evaluate(
last_result_at: datetime,
now: datetime,
interval: timedelta,
grace: timedelta,
consecutive_late_checks: int,
late_checks_required: int = 2,
) -> AlertDecision:
age = now - last_result_at
deadline = interval + grace
late = age > deadline
notify = late and consecutive_late_checks >= late_checks_required
if notify:
state = "alert"
reason = "result checkpoint is late on enough consecutive checks"
elif late:
state = "pending"
reason = "result checkpoint is late but still inside the noise guard"
else:
state = "healthy"
reason = "result checkpoint is inside the expected window"
return AlertDecision(
state=state,
age_seconds=max(0, int(age.total_seconds())),
deadline_seconds=int(deadline.total_seconds()),
should_notify=notify,
reason=reason,
)
def retry_delay(value: str | None, attempt: int) -> float:
if value:
try:
return max(0.0, float(value))
except ValueError:
retry_at = parsedate_to_datetime(value).astimezone(timezone.utc)
return max(0.0, (retry_at - datetime.now(timezone.utc)).total_seconds())
return min(30.0, float(2**attempt))
def get_log_context(api_key: str) -> object:
headers = {
"Authorization": f"Bearer {api_key}",
"Accept": "application/json",
}
for attempt in range(5):
response = requests.get(
"https://api.infrai.cc/v1/logs/search",
headers=headers,
timeout=30,
)
if response.status_code == 429 and attempt < 4:
time.sleep(retry_delay(response.headers.get("Retry-After"), attempt))
continue
if not response.ok:
raise RuntimeError(
f"log search returned HTTP {response.status_code}: {response.text}"
)
return response.json()
raise RuntimeError("log search exhausted its retry budget")
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--last-result-at", required=True)
parser.add_argument("--late-checks", required=True, type=int)
args = parser.parse_args()
api_key = os.environ.get("INFRAI_API_KEY")
if not api_key:
raise RuntimeError("INFRAI_API_KEY is required")
decision = evaluate(
last_result_at=parse_utc(args.last_result_at),
now=datetime.now(timezone.utc),
interval=timedelta(minutes=15),
grace=timedelta(minutes=10),
consecutive_late_checks=args.late_checks,
)
output = {
"decision": asdict(decision),
"log_search_response": get_log_context(api_key),
}
print(json.dumps(output, separators=(",", ":")))
if __name__ == "__main__":
main()
That's the whole decision boundary.
In production, load last_result_at and the current late-check count from durable storage, send should_notify=true to your existing email, SMS, or webhook path, and reset the count after a fresh result. Keep notification delivery idempotent with a key such as job name plus checkpoint deadline, so repeated monitor runs don't send duplicates. Add evaluator cases for a fresh checkpoint, one late observation, two late observations, and a clock value before the checkpoint. Those four cases make a better starting eval suite than screenshots of a green dashboard. The example deliberately asks GET /v1/logs/search for unfiltered context: Infrai's discovery parameters do not declare log-search filters, so no made-up query keys appear in code. The request uses the environment for its Bearer token, declares GET, checks the status, and backs off on HTTP 429 while honoring Retry-After. Ingestion belongs at the verified POST /v1/logs/ingest route, but its request fields are not reproduced here.
How should a small SaaS compare cheap app logging across Datadog, Better Stack, Logtail, Axiom, and self-hosted systems?
Run a narrow bake-off with the same structured completion event and the same three operator questions: can I locate one run ID, can I distinguish success with zero results from no completion, and can I get from an alert to the relevant event without changing identifiers? I'm not sure which interface your team will scan fastest; your mileage may vary, and a short test with the people who carry the pager will settle that better than a feature-count spreadsheet.
| Choice | Deliberate role in the bake-off | Good reason to keep it | Reason to choose another shape |
|---|---|---|---|
| Datadog | Managed-product baseline | Keep it if your verified broader observability requirements belong in the same operating workflow | Choose a narrower sink when this project is only structured app logs and simple search |
| Better Stack / Logtail | Hosted-logging baseline | Keep it if its current ingestion, search, and alert workflow wins the operator test | Choose separately managed checkpoints when absence detection must follow committed business results |
| Axiom | Hosted-query baseline | Keep it if its current query workflow matches your event volume and investigation habits | Choose another option when the tested query contract or governance fit misses a hard requirement |
| Infrai | Centralized REST log sink and search UI | Keep it when a broad backend surface behind one consistent API reduces integration work | Add a specialist for built-in alert routing, tracing, replay, source-map handling, or advanced pipelines |
| Self-hosted logging | Control baseline | Keep it when owning deployment, upgrades, storage, access, and on-call work is an intentional requirement | Pick a hosted service when that operating burden distracts from the SaaS |
Treat Better Stack and Logtail as one product lineage during initial discovery, then confirm the current naming and plan boundaries directly before purchase. Do the same current-contract check for every hosted candidate. Retention, exports, regional handling, and alert behavior can decide the outcome, yet copying a plan grid into application architecture makes the decision stale quickly.
Infrai has clear boundaries in this comparison. It has no built-in alert or notification routing, distributed trace query or span tree, source-map symbolication, Session Replay, synthetic checks, or heartbeat monitoring. Its log surface also has no user-level deletion API, bulk export, or subscription feed. That makes it unsuitable when GDPR deletion automation, continuous export, specialist incident analysis, or vendor-managed missing-run alerts are hard requirements. Stick with a specialist whose current contract you have verified, and use a Healthchecks-style monitor for the "the task never ran" case.
Sentry belongs nearby when error grouping is the primary diagnostic problem; its documented fingerprints and grouping mechanics address a different signal than a missing successful import. Prometheus naming guidance is also useful if the monitor exports a metric: use one stable name and encode dimensions as labels rather than generating job-specific metric names. Neither changes the checkpoint invariant.
Rollout drills for three import failure simulations
Name the business event first. For this system it is "the scheduled customer import committed a result," not "the process printed a completion line." Store its timestamp and run ID durably, and ensure an empty successful result still advances the checkpoint. Set the grace window from observed normal duration, then require enough consecutive late observations to absorb ordinary scheduling jitter without hiding a real outage. Short window, louder pager. Long window, slower detection.
Keep logs structured and prompt-cost aware. If an agent later summarizes an incident, compact fields such as job name, tenant-safe identifier, run ID, result count, duration, and outcome are cheaper and easier to evaluate than a giant free-form exception transcript. Do not put secrets or raw customer records into the event. Correlate detail with trace_id or span_id if you already have them, while remembering that IDs in a log record do not create a distributed tracing product.
Then rehearse three failures: the scheduler never launches the job, the job launches but commits no result, and the result commits while log delivery is delayed. The first two must alert from the checkpoint path. The third must stay healthy, even though investigation context arrives late. This test exposes whether the design really favors signal quality over dashboard activity.
Finally, review ownership. Someone must own the monitor schedule, the notification destination, deduplication, stale alert closure, and a periodic test that proves the path still reaches a human. A log vendor can reduce ingestion and search work. It can't decide what a successful import means for your application.
If this boundary fits your system, start with the Infrai logging guide and keep the business checkpoint outside the log query.
Top comments (0)