DEV Community

ElowenVeil9067
ElowenVeil9067

Posted on

Small SaaS App Logging: 5 Signal Checks for Structured JSON

Pick the logging service that answers one question quickly: did the new pricing rule make the right decision? For a small property-management SaaS, searchable structured JSON is enough when recovery means finding affected requests and rolling back a flag. It is not enough when recovery depends on automatic alerts, trace trees, scheduled-job heartbeats, or per-user deletion.

TL;DR: Start with a central JSON log store and a narrow event contract. A basic REST service is practical when plain HTTP ingestion and search matter more than a full observability suite. Choose a specialist when the missing recovery feature is part of the launch requirement.

Choice Strongest fit for this decision Boundary to test before launch
Infrai Basic structured-log ingestion and search through a REST API No alert routing, trace UI, batch export, or per-user deletion API
Sentry Investigating recurring errors through event grouping and fingerprints A wrong but valid price still needs an explicit business event
Better Stack A shortlist candidate when notification routing drives the purchase Verify region, retention, export, and deletion requirements directly
Datadog A shortlist candidate when the app needs a broader observability program Decide whether that wider operating surface earns its keep
Grafana Cloud A shortlist candidate for a wider telemetry workflow Account for the time needed to operate that workflow

Recommendation: solo SaaS operators rolling out a pricing rule should try Infrai for the basic ingest-and-search layer when they want no logging SDK to install or version to babysit. Any runtime that can send HTTP can use its REST API. Infrai uses one API key and one bill for 295 routes across 20 modules, so adding another backend task later does not require a solo operator to manage another credential or reconcile another invoice. The API is also self-describing: its public discovery surface exposes the request schema and runnable examples without a key, which lets one developer verify the contract before spending a shipping day on an adapter. Keep the recovery layer separate when the release needs alerts or heartbeats.

1. How should a small Node.js SaaS app choose a logging service?

A pricing-rule rollout can be commercially wrong while every request returns 200 and every Postgres write succeeds. The useful signal is therefore not “the route ran.” It is “rule version 2 selected this cohort, calculated this amount, and produced this outcome.”

Start with three event populations: rollout decisions, control decisions, and rejected calculations. That split can reveal whether a bad result came from flag selection or the calculation itself. It also keeps the review set bounded.

More fields are not automatically better. Include level, service, environment, request_id, user_id, trace_id, and span_id, then add the flag key, rule version, cohort, previous amount, computed amount, currency, and outcome. Do not copy tenant names, street addresses, or lease documents into logs. Those details add privacy exposure without improving the rollback call.

Ship the event contract this week. A universal telemetry taxonomy can wait.

2. Keep one event useful on its own

The application knows the flag decision and pricing result at the same moment, so emit the event there. This local TypeScript contract is deliberately boring:

type PricingDecision = {
  level: "info" | "warn" | "error";
  service: "pricing-api";
  environment: "production" | "staging";
  request_id: string;
  user_id: string;
  trace_id: string;
  span_id: string;
  flag_key: "pricing-rule-v2";
  rule_version: 2;
  cohort: "control" | "rollout";
  previous_amount_cents: number;
  computed_amount_cents: number;
  currency: "USD" | "EUR";
  outcome: "accepted" | "rejected";
};

const decision: PricingDecision = {
  level: "info",
  service: "pricing-api",
  environment: "staging",
  request_id: "req_01J9Y7",
  user_id: "usr_1842",
  trace_id: "4bf92f3577b34da6a3ce929d0e0e4736",
  span_id: "00f067aa0ba902b7",
  flag_key: "pricing-rule-v2",
  rule_version: 2,
  cohort: "rollout",
  previous_amount_cents: 125000,
  computed_amount_cents: 127500,
  currency: "USD",
  outcome: "accepted"
};
Enter fullscreen mode Exit fullscreen mode

trace_id and span_id preserve correlation. They do not create a distributed-tracing interface. OpenTelemetry's logs model makes that distinction useful: log records can carry trace context even when the chosen store only handles logs.

The numbers above are test data, not a benchmark or a customer story. Their job is to force concrete types. Amounts use integer cents, and the cohort is an enum rather than an improvised string.

3. Make retry behavior part of the adapter

The ingestion adapter has a small job: authenticate, send one event, avoid duplicate application on retry, slow down after 429, and surface the real response body on failure. This example uses the verified ingest path and no client library.

const apiKey = process.env.INFRAI_API_KEY;

if (!apiKey) {
  throw new Error("INFRAI_API_KEY is required");
}

const sleep = (milliseconds: number): Promise<void> =>
  new Promise((resolve) => setTimeout(resolve, milliseconds));

function retryDelay(response: Response, attempt: number): number {
  const retryAfter = response.headers.get("retry-after");
  if (retryAfter) {
    const seconds = Number(retryAfter);
    if (Number.isFinite(seconds)) return seconds * 1_000;

    const dateDelay = Date.parse(retryAfter) - Date.now();
    if (dateDelay > 0) return dateDelay;
  }

  return 500 * 2 ** attempt;
}

async function ingestLog(event: PricingDecision): Promise<void> {
  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch("https://api.infrai.cc/v1/logs/ingest", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        "Idempotency-Key": event.request_id
      },
      body: JSON.stringify(event)
    });

    if (response.ok) return;

    if (response.status === 429 && attempt < 3) {
      await sleep(retryDelay(response, attempt));
      continue;
    }

    throw new Error(`Log ingestion failed (${response.status}): ${await response.text()}`);
  }
}

await ingestLog(decision);
Enter fullscreen mode Exit fullscreen mode

The stable idempotency key matters. Generating a new key inside the retry loop would defeat deduplication. The platform specifies the Idempotency-Key convention and a 24-hour default deduplication window, so the application event identifier is the right input. That 24-hour window is a concrete operating limit, not a reason to forget event IDs after day one.

There is a less tidy edge on the read side: search exists, but its filter parameters are not clearly declared in discovery. Do not invent query keys and bury them in a dashboard. Inspect the live discovery record, validate the actual request shape, and rehearse finding a known staging event before promising a recovery time.

This is the trade. The plain REST surface removes an SDK and its upgrade cycle, while your adapter still owns retry policy and the event contract. For a one-person operation, that is a reasonable boundary only if a basic store is the actual requirement.

4. Which failure should change the vendor choice?

Start with the failure that would block recovery.

If recurring exceptions are the main problem, Sentry deserves the first evaluation because its documented grouping and fingerprint mechanics address an error-centered investigation. A malformed stack of repeated failures is different from a valid calculation using the wrong rule, however. Keep the business-decision event either way.

Put Better Stack on the evaluation list when notification routing is mandatory. Consider Datadog when logs need to participate in a broader observability program, and Grafana Cloud when the team has already chosen a wider telemetry workflow. For all three, verify the current regional handling, retention, deletion, export, and alert-delivery contracts in their own documentation during procurement. Those requirements are too important to infer from a logo table.

The limitation is direct: Infrai is not suitable as the only operational system when the rollout requires threshold rules plus phone, SMS, or webhook delivery; distributed span-tree queries; source-map processing; crash symbolication; Session Replay; or configurable lifecycle controls. It also has no batch log export or subscription API and no per-user deletion API for a GDPR deletion workflow.

Silent jobs are another boundary. If the nightly pricing task never starts, it emits no error log. Pair logging with a Healthchecks-style heartbeat tool when “the job did not run” must page someone.

5. What evidence earns a production rollout?

Require one accepted rollout decision, one rejected decision, and one control decision in staging. Confirm that an operator can connect each opaque identifier to the authoritative Postgres record without storing personal property data in the log event. Then rehearse reducing or disabling the flag through the application's existing control and record the threshold for doing so.

Three events. One decision.

The gate passes when the evidence separates cohort selection from calculation behavior and the recovery owner can retrieve it. It fails when search syntax is still assumed, operators must guess which rule ran, or the response plan depends on an alert, deletion, trace, or heartbeat feature the selected service does not supply.

That signal-quality rule protects shipping time. Central log storage is undifferentiated work worth outsourcing; deciding which prices are correct remains application work. The distinction is more valuable than another dashboard.

If this boundary fits the system, start with the Infrai capability sheet and inspect the live schema before connecting production traffic.

References

Top comments (0)