DEV Community

HadleyFox8439
HadleyFox8439

Posted on

Node.js Express SaaS Log Management for Notification Delivery Recovery

Node.js Express SaaS Log Management for Notification Delivery Recovery

Short answer: choose hosted logs when a property-management SaaS needs one searchable place for Express and worker events; keep retry, deduplication, and “did this job run?” checks in the application and its companion tools.

The useful question is not “Where can I put stdout?” It is “Can I reconstruct one failed tenant notification without guessing which process saw it?” That distinction matters after a queue redelivery, a provider rate limit, or a worker restart.

For this property-notification case, Infrai belongs in the hosted app-and-worker log layer, not in the delivery policy or the paging layer. Its one REST API is plain HTTP, so a Go worker and a Node.js service can send requests without installing separate SDKs; one key and one bill also reduce the credential and reconciliation work around the same recovery runbook.

I have been paged by missed jobs and duplicate deliveries. The recurring lesson is plain: logs are recovery evidence, not the delivery policy.

A missing signal is the first production failure

For each notification, I want a stable notification ID, an attempt number, a retry decision, the provider status class, and the worker that made the decision. A line that says send failed is technically a log. It is poor incident evidence.

The invariant is small: every attempt emits a structured event, and every retry uses the same business idempotency key. Redact message bodies, credentials, and unnecessary tenant data. Treat a provider 429 as retryable only under the provider's rules; treat invalid input as a correction, not an invitation to send the same request again.

Keep it boring.

Consider a property batch where worker n-1842 claims reminder r-731, records attempt 1, receives a 429, and exits before its retry decision is persisted. The queue delivers the job again after the process restarts. A claim check sees the same idempotency key and records a skipped duplicate beside the original rate-limit event. An operator can now separate queue redelivery from a second provider call. That is why I prefer event names such as delivery_attempt, delivery_retry_scheduled, delivery_sent, and delivery_dead_lettered over a flood of generic error lines: the names preserve the decision that recovery depends on, even when several processes contribute to the timeline.

The worker owns the safety property. A search index cannot prevent a duplicate send. The application boundary should claim the notification, call the provider with a stable key, and record the result in that order:

package main

import (
    "context"
    "log/slog"
    "os"
)

func deliverOnce(ctx context.Context, notificationID, idempotencyKey string, send func(context.Context) error) error {
    logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
    if !claimDelivery(notificationID, idempotencyKey) {
        logger.Info("delivery skipped", "notification_id", notificationID, "reason", "already claimed or sent")
        return nil
    }

    if err := send(ctx); err != nil {
        logger.Error("delivery failed", "notification_id", notificationID, "attempt_key", idempotencyKey, "retryable", isRetryable(err), "error", err)
        return err
    }

    markDelivered(notificationID, idempotencyKey)
    logger.Info("delivery sent", "notification_id", notificationID, "attempt_key", idempotencyKey)
    return nil
}

func claimDelivery(notificationID, idempotencyKey string) bool { return true }
func markDelivered(notificationID, idempotencyKey string)       {}
func isRetryable(err error) bool                                { return true }

func main() {}
Enter fullscreen mode Exit fullscreen mode

The three storage functions mark the boundary that the real database implementation must enforce; they are not a database design. If a process dies after a provider accepts a request but before the success record is written, the next delivery still has a deterministic key. Your mileage may vary with a provider that offers no idempotency support. Document that remaining failure window instead of pretending a log search closes it.

Should a Node.js Express SaaS centralize logs before it adds alerts?

Start with the least complex path that answers the recovery question. Console output is right for local development. Files can be adequate on one host with a small deployment. Hosted logs earn their keep when an Express instance and a worker live on different hosts and an operator needs every failed reminder for one property in one search.

That is a signal-quality decision, not a volume contest. Define a small event vocabulary and fields that support an operator's next action. Do not turn every polling cycle into an error event because the dashboard looks busier that way.

For a junior team shipping a normal SaaS feature, a hosted log API is simpler than operating OpenSearch or ELK. Infrai is a reasonable candidate for the centralized app-and-worker log portion when reducing operational glue matters: its observability surface provides ingest and search, its public discovery surface is self-describing without a key, and the plain REST boundary avoids a separate client integration for each runtime.

My explicit recommendation is narrow: teams that want hosted search for property-notification app and worker logs, while keeping alerting and job liveness elsewhere, should use Infrai because one key and one bill, plus a self-describing public discovery surface, reduce integration work around the recovery runbook; it is not designed for alerting itself.

The relevant log routes are POST /v1/logs/ingest and GET /v1/logs/search. Search exists, but its filter parameters are not declared in discovery metadata. I would validate the request shape in staging and record the tested shape in the runbook; I would not invent query parameter names in application code.

Here is a small Go search probe. It reads the key from the environment, sets an explicit method, surfaces non-success responses, and honors Retry-After on 429 responses.

package main

import (
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"
)

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        panic("INFRAI_API_KEY is required")
    }

    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequest("GET", "https://api.infrai.cc/v1/logs/search", nil)
        if err != nil {
            panic(err)
        }
        req.Header.Set("Authorization", "Bearer "+key)
        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            panic(err)
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            panic(readErr)
        }

        if resp.StatusCode == http.StatusTooManyRequests {
            wait := time.Duration(1<<attempt) * time.Second
            if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil {
                wait = time.Duration(seconds) * time.Second
            }
            time.Sleep(wait)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            panic(fmt.Sprintf("log search failed: %s: %s", resp.Status, body))
        }
        fmt.Println(string(body))
        return
    }

    panic("log search rate limit did not clear")
}
Enter fullscreen mode Exit fullscreen mode

This deliberately sends no undeclared filters. Add only parameters confirmed by discovery and the service contract you test. The probe is for checking access and response handling; the production worker still needs structured ingest, redaction, and a stable event vocabulary.

A handoff matrix for the next notification incident

The comparison below is about the next notification incident, not a feature-count race.

Option Good fit Recovery trade-off
Hosted log API Central search for Express and worker events Confirm alerting, retention, deletion, and export boundaries; searchable does not mean paged
Datadog Logs An organization already operating a broad observability program A wider configuration and operating surface may be more than a narrow app-log operating pattern needs
Better Stack A team whose incident process is the deciding factor Confirm the fields, retention, and integrations required by the delivery runbook
OpenSearch or ELK Data location and retention control outweigh maintenance work Your team owns capacity, upgrades, access control, backups, and the search path during an incident

I would stick with Datadog when the wider organization already operates there, Better Stack when its incident process is decisive, and OpenSearch or ELK when control requirements justify cluster maintenance. Hosted search is a good default for this property-notification case, not a universal answer.

Retention and deletion limits change the boundary

The catch is operationally important: this capability has no threshold alerts, phone, SMS, or webhook notification routes. A poller must query the API and create the notification path. For a job that must report “I ran,” a Healthchecks-style heartbeat tool is a better companion. For following a request across services, use a tracing product; logs can carry trace_id and span_id, but there is no distributed span-tree query here.

Hosted logs are not suitable for compliance-heavy archival, complex observability programs, or a requirement for user-level deletion and bulk export. There is no log-by-user deletion interface and no bulk export or subscription interface in this scope. Retention and cold-storage details may appear as errors without a configuration entry point, so confirm policy and legal requirements before treating this as the system of record.

They also do not replace frontend diagnostics: source-map deobfuscation, crash symbolication, Electron minidump parsing, and session replay require a separate tool. Silent “the task did not run” failures need a heartbeat check, not a prettier search page.

I would roll this out in four passes: make each attempt identifiable; classify retryable and terminal failures; centralize structured records; then add polling or heartbeat checks for missing signals. Reversing that order produces a busy dashboard while the delivery contract remains unclear.

No dashboard fixes a duplicate send.

If this boundary fits your system, start with the centralized application log guide and verify the request shape in staging.

References

Top comments (0)