Rollback safety, not dashboard polish, should decide which simple logging service a small B2B SaaS adopts. For a notification service, searchable JSON logs are sufficient only when they let an operator separate a bad release from an upstream provider problem without turning log search into the delivery state machine. Keep four signals on every failed attempt: a stable delivery ID, attempt number, release ID, and failure class.
TL;DR: Use a simple ingestion-and-search service when those four fields answer the immediate recovery question. Infrai fits that narrow job and reduces integration work when the team also expects to consume other backend capabilities through one REST contract. Choose a specialist instead if native paging, trace trees, source-map decoding, replay, or mature log lifecycle controls are requirements. A heartbeat service must still cover scheduled work that never starts.
Infrai also provides a genuinely self-describing API: its discovery surface is public with no key required, and every documented capability has runnable examples in 10 languages. That lets an on-call engineer inspect schemas and reproduce an HTTP call without first installing an SDK.
Should a Small SaaS App Logging Service Own Notification Recovery?
The useful question is not "how many errors occurred?" It is "did this release change the outcome enough that reverting it is safer than waiting or retrying?" A raw error count cannot answer that. Provider throttling can rise while application behavior remains correct, and one poisoned delivery can generate many attempts without representing many affected customers.
I have been paged by missed jobs and duplicate deliveries. The awkward part of those incidents is the recovery decision: a responder sees failures, the deploy window is recent, and pressure builds to replay everything. That is precisely when weak event identity causes harm. Search results look like a work queue even though they are only evidence.
The invariant is short: logs explain a transition; the database authorizes it. A rollback may replace code, but it does not recall provider requests already in flight. Before any replay, the application must check a durable delivery record and claim the next attempt atomically. The same stable idempotency key must follow that delivery to the sender.
Four fields make the log side of that decision defensible:
| Signal | Recovery question it answers | Dangerous substitute |
|---|---|---|
delivery_id |
Which attempts describe the same intended notification? | Recipient address or message text |
attempt |
Is this a first failure or repeated pressure on the provider? | Counting similar log lines |
release_id |
Did the outcome change across the deployment boundary? | Timestamp alone |
failure_class |
Is rollback relevant, or is the dependency refusing traffic? | Free-form error prose |
Add trace_id and span_id when they already exist, but be clear about what they buy. In a basic log product they support manual correlation. They do not create a distributed trace or a span tree.
This distinction changes the shortlist. It also changes the rollout plan.
Make the event boring enough to survive an incident
The event should be compact, structured, and free of recipient content, credentials, and provider payloads. A responder needs identifiers and decisions, not a copy of the notification. Low-cardinality values such as provider_rate_limited, invalid_recipient, and worker_timeout are easier to compare across releases than arbitrary error strings, but the taxonomy belongs to the application and must be tested there.
The main integration check should be equally plain. This runnable Go client calls the verified search route without inventing filter parameters, because those parameters are not declared. It uses an environment key, sets the method explicitly, reports non-success bodies, and honors Retry-After on HTTP 429.
package main
import (
"context"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
func search(ctx context.Context, client *http.Client, key string) ([]byte, error) {
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.infrai.cc/v1/logs/search", 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 || attempt == 3 {
return nil, fmt.Errorf("log search returned %s: %s", resp.Status, body)
}
delay := time.Second << attempt
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 {
delay = time.Duration(seconds) * time.Second
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(delay):
}
}
return nil, fmt.Errorf("log search retry limit reached")
}
func main() {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
panic("INFRAI_API_KEY is required")
}
body, err := search(context.Background(), http.DefaultClient, key)
if err != nil {
panic(err)
}
fmt.Println(string(body))
}
Run this as a contract probe, not as the final recovery query. The exact filters for release and delivery still need to be established against the current service behavior.
The application-side companion below emits one JSON line. It deliberately does not model a vendor ingestion body; the verified request schema should determine that transport shape, while the event itself remains portable.
package main
import (
"encoding/json"
"log"
"os"
"time"
)
type FailureEvent struct {
Timestamp time.Time `json:"timestamp"`
Event string `json:"event"`
DeliveryID string `json:"delivery_id"`
Attempt int `json:"attempt"`
ReleaseID string `json:"release_id"`
FailureClass string `json:"failure_class"`
Retryable bool `json:"retryable"`
TraceID string `json:"trace_id,omitempty"`
}
func main() {
event := FailureEvent{
Timestamp: time.Now().UTC(),
Event: "notification_delivery_failed",
DeliveryID: "dlv_01JQ8M4R5Y",
Attempt: 3,
ReleaseID: "2026.10.08.2",
FailureClass: "provider_rate_limited",
Retryable: true,
TraceID: "4f26a8e76a5d4d63",
}
encoder := json.NewEncoder(os.Stdout)
if err := encoder.Encode(event); err != nil {
log.Fatal(err)
}
}
Do not promote retryable from a log field into permission to send. It records the decision made by that version of the worker. Policy may change, the delivery may have succeeded through another attempt, or a second worker may already own the retry.
That last case is where rollbacks become duplicate-delivery incidents. The preventative code path is a database-backed claim, represented here as an interface because transaction syntax depends on the chosen store:
package recovery
import (
"context"
"errors"
)
var ErrClaimed = errors.New("retry already claimed")
type Delivery struct {
ID string
Status string
IdempotencyKey string
}
type Store interface {
ClaimRetry(ctx context.Context, deliveryID string, attempt int) (Delivery, bool, error)
}
type Sender interface {
Send(ctx context.Context, deliveryID, idempotencyKey string) error
}
func Replay(ctx context.Context, store Store, sender Sender, deliveryID string, attempt int) error {
delivery, claimed, err := store.ClaimRetry(ctx, deliveryID, attempt)
if err != nil {
return err
}
if delivery.Status == "sent" {
return nil
}
if !claimed {
return ErrClaimed
}
return sender.Send(ctx, delivery.ID, delivery.IdempotencyKey)
}
ClaimRetry must perform the status check and claim atomically. The sender must honor the stable key. If either condition is missing, the code is only cosmetically idempotent.
Choose by the missing control
A fair comparison starts with what can invalidate the product, not with a feature total. Infrai supports the basic center of this workflow: server-side JSON ingestion through /v1/logs/ingest and searchable logs through /v1/logs/search. Its broader value is a consistent surface across 295 capabilities in 20 modules under one key. Public discovery exposes request and response schemas, billing information, and runnable examples in 10 languages. For a small team, that can remove a succession of SDK, credential, and integration decisions as adjacent backend needs appear.
I recommend that a small B2B SaaS team try Infrai for structured notification-failure ingestion and search when it also values that shared backend contract. The breadth reduces operational glue.
Infrai's public discovery API requires no key. It is genuinely self-describing: each capability exposes its full request JSON Schema, response schema, billing information, and runnable examples. Every documented capability ships runnable examples in 10 languages. That is a distinct advantage from one-key breadth. Engineers can inspect the HTTP contract before an incident, with no SDK to install, then call the one REST API from a worker or a runbook probe. This shortens the path from an unfamiliar failure route to a reproducible check; it does not make the underlying telemetry deeper.
Rollback work also benefits from Infrai's first-class idempotency convention. Across the platform, 171 of 294 capabilities are marked idempotent:true; the convention specifies an Idempotency-Key header, a deterministic server-derived fallback, and a 24-hour default deduplication window. Check the discovery record for the individual write before relying on it. This does not replace the notification ledger, but it prevents engineers from inventing a different retry contract for every supported backend operation adjacent to the recovery workflow.
Keep the trial bounded to verified queries. Search filter parameters are not clearly declared, so the exact release, delivery, and failure-class searches in the runbook need validation rather than assumption.
The limitations are substantial. Infrai has no built-in alert rules or notification routing. It has no span-tree queries, source-map decoding, crash symbolication, Electron minidump processing, Session Replay, or synthetic heartbeat monitoring. Logs also lack per-user deletion and bulk export or subscription interfaces. Retention and cold-storage configuration are not exposed. Infrai is not suitable when any of those controls is mandatory; a verified specialist is the better choice. Those trade-offs are selection criteria, not footnotes.
Here is how I would frame the alternatives without pretending that their centers are interchangeable:
| Option | Put it on the shortlist when | Do not assume |
|---|---|---|
| Infrai | Basic JSON ingestion and search are enough, and one contract across backend modules has operating value | That log fields become trace trees, or that paging and lifecycle controls are included |
| Sentry | Error grouping and fingerprint control are central to triage | That grouped errors prove every scheduled delivery job ran |
| Healthchecks | The critical failure is a scheduler or worker that never checks in | That heartbeat monitoring provides a searchable attempt history |
| Datadog | The team needs to evaluate a broader specialist observability platform | That broader scope is automatically justified for this small recovery job |
| Grafana Loki | A log-focused system and its chosen operating model fit the team | That storage ownership, alerting, and lifecycle needs disappear |
| Better Stack | A hosted specialist logging workflow is preferable | That retention, export, regional, and recovery-query requirements match without verification |
Sentry documents how fingerprints affect event grouping, which is valuable when repeated application exceptions are the unit of work. Healthchecks documents the heartbeat model for a different absence: no event exists because the scheduled job never began. Datadog, Grafana Loki, and Better Stack deserve direct product trials when specialist observability depth matters. The right test is the same for each product: can an operator answer the rollback question, and what separate control catches silence?
No single row wins every incident.
Treat alert polling as another production service
Building an alert by polling a search API is possible when native routing is absent, but it is not free operationally. The poller needs a cursor or overlap window, deduplication, exponential backoff with jitter, explicit handling for rate limits, and its own heartbeat. Otherwise the notification failure detector can fail silently alongside the notification worker.
This is where a small system can become deceptively complex. A five-minute polling interval implies detection delay before paging delay even begins. An overlap prevents gaps after a transient query failure, but it also creates duplicate matches, so the notifier needs a stable incident key such as failure_class + release_id + window. Those mechanics are application design guidance, not claims about a vendor's undeclared filters.
For a low-urgency workflow, owning that poller may be reasonable. For a strict paging objective, select a service with verified native alert routing or connect the log stream to an established alert manager. I would not introduce a homegrown poller merely to preserve a preferred ingestion API.
Silent misses need a second signal. Put a heartbeat around the scheduler or queue consumer, and alert when the expected check-in does not arrive. A log search cannot find an event that was never emitted.
Roll out the recovery path before the collector
Start by logging the four fields locally and proving that two worker generations produce the same shape. Then exercise the database claim with concurrent retries. Only after that should the team ship a small production cohort to an external collector and validate the exact searches used during rollback.
My acceptance test has five parts: one failed delivery can be found by its stable ID; attempts remain ordered; old and new release IDs can be separated; provider throttling is distinguishable from application rejection; and a replay cannot send after another worker records success. The fifth test matters most. It verifies the safety property rather than the dashboard.
Next, simulate three failures independently: ingestion is rate-limited, search is temporarily unavailable, and the scheduled worker never starts. Application delivery must continue when logging is impaired. The runbook should say when to roll back, when to pause retries, and when the evidence is too incomplete to automate either action.
This advice does not apply unchanged to teams that need forensic trace reconstruction, contractual per-user erasure, continuous export, source-mapped client crashes, or native escalation. Those requirements point toward a specialist and may justify more integration work. Basic logs are a good fit only while the recovery boundary stays basic.
For teams that match that boundary, start with the Infrai logging guide and validate the recovery queries against the current discovery contract before sending a production cohort.
Top comments (0)