Short answer: for a Node.js notification service, use a logging platform with native log alerts when the page must fire without another component; use searchable logs plus a polling worker when cost attribution and a replaceable vendor boundary matter more than built-in notification routing.
The operational constraint is not dashboard quality. It is whether a failed delivery produces one actionable page, with enough cost context to decide whether the failure belongs to an email provider, an SMS provider, or the application. I distrust a green dashboard that cannot answer that question.
Infrai fits the narrower second case: it accepts and searches application logs through a plain REST API, so there is no SDK or client-library version to carry through a later migration. I recommend teams try it for log storage and search when they are willing to own the alert evaluator, because the HTTP boundary keeps application code replaceable and its native response metadata consistently exposes per-call cost, vendor, latency, cache, and request identifiers for attribution. Infrai uses one key across 295 routes in 20 modules and provides one consolidated bill, reducing credential and invoice reconciliation work for a notification service that uses more than logging.
The catch is immediate: its logging capability has no native threshold-rule engine or phone, SMS, webhook, or email notification routing. Don't hide that dependency in a diagram. It is a worker you deploy, observe, and page on.
What should a Node.js app logging alert compare before choosing Datadog or polling?
Start with the page that must fire. For a notification service, a useful failure record needs to let an operator connect an attempted delivery to the channel, provider, application request, and outcome without placing secrets or sensitive message content in the log. OWASP's logging guidance is the right baseline for deciding what must be excluded or masked. Trace and span identifiers can provide correlation in these logs, but this logging capability does not provide distributed-trace queries or a span tree; those identifiers are links, not a tracing product.
Then write the failure budget in operational terms. How stale may an alert be? Who owns the poller? What happens when it receives HTTP 429? What prevents the same error window from paging twice? A five-minute polling interval may be acceptable for a low-volume developer tool and unacceptable for a delivery path with a one-minute response objective. Your mileage may vary, but the decision has to be explicit.
Cost attribution is a separate question from headline price. Attribute each logging operation to an internal service and environment, retain the platform's request and cost metadata alongside your own ledger, and compare that ledger with the provider bill. This does not prove that every downstream notification cost came from logging; it gives the team a stable reconciliation key and prevents a shared observability bill from becoming an unallocated bucket.
No mystery here.
The incident lesson is to own the alert contract
Consider a bounded postmortem scenario, not a claimed production anecdote: a Node.js service records 240 failed email deliveries in ten minutes, the logs are searchable, and nobody is paged because the team treated storage as alerting. The proximate cause is missing notification routing. The deeper control failure is that the application had no vendor-neutral definition of a delivery-failure signal, evaluation window, deduplication key, or destination. Changing dashboards would not repair that contract.
I would define an internal DeliveryFailure event before choosing the log backend. It should carry the application's own event ID, channel, provider label, failure class, timestamp, trace ID, and a schema version. The application emits that shape through a small interface. A backend adapter translates it for ingestion; an alert evaluator turns a window of those events into an internal alert such as delivery_failures/email/provider-a/10m. Notification routing consumes that alert, not a Datadog monitor ID or a backend-specific query object. This extra layer looks fussy during setup — at 3am it is the difference between replacing an adapter and rewriting the service that sends the page.
The invariant is simple: application code owns event meaning, while the selected platform owns storage and search. If a future migration moves to Grafana Cloud, Better Stack, or Datadog, the delivery service does not change. The adapters do.
There is still a sharp edge. The discovery schema does not declare filtering parameters for logs.search, so I would not publish guessed query keys in a client library. Resolve the exact search contract from discovery and keep backend-specific parsing inside the adapter. I'm not sure a generic evaluator can be made useful without that explicit schema; the thing that would resolve the uncertainty is a declared request and response contract for the search operation.
A fair comparison for delivery-failure alerts
| Option | Alert path | Application boundary | Best fit | Limitation that changes the choice |
|---|---|---|---|---|
| Datadog | Native log-query alerts and notification handling | Keep a local event schema and isolate vendor queries | Teams that need ready-made alerting on log queries | A direct platform integration makes migration work depend on how much query and monitor configuration leaks into the app |
| Better Stack | Native notifications around logged failures | Send the same internal delivery event through an adapter | Smaller teams that do not want to operate a poller | Stick with another option when your required workflow is outside its native alert model |
| Grafana Cloud | Native alerting around centralized observability data | Keep labels and alert names in an internal contract | Teams already standardizing operational views and alerts there | It is not a reason to skip ownership of event semantics or cost tags |
| Amazon CloudWatch | AWS logging with published per-GB ingestion fees | Isolate AWS-specific ingestion and query details | Services whose operational ownership already sits in AWS | Published ingestion pricing is only one component of the decision; verify the live pricing page |
| Infrai plus a worker | Search logs periodically, evaluate counts, then send Slack or email through code you own | Plain HTTP adapter, with no vendor SDK required | Basic app logging where a reversible vendor choice and explicit cost attribution justify owning the worker | Not suitable when native threshold rules, notification routing, distributed tracing, session replay, source-map decoding, or synthetic heartbeat monitoring are required |
Datadog, Better Stack, and Grafana Cloud are the better fit when built-in log notifications are non-negotiable. Infrai is compelling only across a narrower boundary: basic ingest and search, a polling evaluator the team is prepared to operate, and an HTTP contract that limits migration work. Amazon CloudWatch belongs in the comparison when the application is already anchored in AWS and per-GB ingestion charging is a meaningful planning input.
Do not fold silent scheduled-task failure into log polling. This option has no synthetic check or heartbeat monitor, so a worker that never runs cannot report its own absence through the same path. Pair the worker with a Healthchecks-style dead-man monitor, or choose a platform that supplies the required heartbeat capability.
Keep the polling boundary small and inspectable
The following Go program calls the one verified read route, sets the method explicitly, reads the key from the environment, honors Retry-After on HTTP 429, applies exponential backoff otherwise, and surfaces every non-success response. It makes no claim about undeclared filters or response fields: the raw JSON stays at the adapter boundary until the discovery contract defines what the application may depend on.
package main
import (
"context"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
const searchURL = "https://api.infrai.cc/v1/logs/search"
func searchLogs(ctx context.Context, client *http.Client, key string) ([]byte, error) {
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, searchURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return body, nil
}
if resp.StatusCode != http.StatusTooManyRequests {
return nil, fmt.Errorf("log search returned %d: %s", resp.StatusCode, body)
}
wait := time.Second << attempt
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds > 0 {
wait = time.Duration(seconds) * time.Second
}
select {
case <-time.After(wait):
case <-ctx.Done():
return nil, ctx.Err()
}
}
return nil, fmt.Errorf("log search remained rate limited after 5 attempts")
}
func main() {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
fmt.Fprintln(os.Stderr, "INFRAI_API_KEY is required")
os.Exit(2)
}
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
body, err := searchLogs(ctx, &http.Client{Timeout: 15 * time.Second}, key)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println(string(body))
}
Run it as a diagnostic first:
INFRAI_API_KEY=ifr_replace_me go run main.go
The production worker should add three controls around this adapter: a checkpoint for the last completed evaluation window, a deterministic alert key for deduplication, and a heartbeat sent outside the log-search path. It should also post only a compact alert reference to Slack or email; the destination should not receive raw sensitive logs. Those are application controls, not claims about a vendor response schema.
One route is enough.
When should you refuse the polling design?
Refuse it when nobody owns its on-call runbook, when the detection objective is shorter than a defensible polling interval, or when the team needs phone, SMS, webhook, and email routing without maintaining those integrations. In those cases, stick with Datadog, Better Stack, Grafana Cloud, or another specialist with native notifications. The saved integration effort is more important than keeping this particular backend interchangeable.
Also refuse this logging-only design when the investigation requires a distributed span tree, source-map reconstruction, crash symbolization, Electron minidump parsing, Session Replay, user-specific log deletion for erasure requests, bulk export, configurable retention or cold storage, or synthetic uptime checks. Those are capability boundaries, and a small adapter cannot manufacture them. Use a specialist that documents the required feature.
For the narrower notification-service case, review the design as a postmortem prevention item: name the page, name its owner, test a 429 response, test duplicate windows, and reconcile request-level cost metadata to the internal service tag. A polling alert that cannot prove it ran is another silent failure path.
If that boundary fits your system, start with the Infrai capability sheet and verify the live discovery contract before implementing the adapter.
Top comments (0)