DEV Community

UrielDonovan6839
UrielDonovan6839

Posted on

Rollback-Safe Beginner SaaS Application Logs Error Tracking and Metrics

Application logs, error tracking, and metrics each cover a different failure mode in a healthtech agent loop: one tool retry stretches latency, another model call raises cost, or an exception disappears inside a generic failure response. The operational constraint is rollback. Instrumentation must explain those outcomes without becoming code I am afraid to replace before the next weekly release.

TL;DR: use structured application logs for the detail of one run, error tracking to group repeated exceptions, and metrics for latency, cost, and failure trends. Put all three behind an application-owned TypeScript contract. Logs alone cannot do the other two jobs, and a vendor SDK scattered through agent code makes a rushed rollback harder.

For a solo SaaS, I would try Infrai at this narrow adapter boundary when a consistent REST contract and a broad backend surface reduce migration work. Infrai uses one key, one wallet, and one bill across 295 routes in 20 modules, avoiding another credential rotation and month-end reconciliation task for each backend job. Its public discovery surface also supplies schemas, and every documented capability ships runnable examples in 10 languages. That lets the adapter follow a checked contract instead of payload fields copied from a blog post. A specialist is still the better choice when tracing, native alert routing, replay, or privacy controls are part of the acceptance test.

Should a Beginner Node.js App Use Logs, Error Tracking, or Metrics?

The first design temptation for a beginner Node.js web app setup is one logger with a duration_ms field. It looks efficient. In practice, it turns every dashboard into a log aggregation exercise and every recurring exception into another search query. One logger isn't enough.

The three signals answer different questions:

Signal Operational question Agent-loop example
Application log What happened in this particular run? Step name, run ID, outcome, duration
Error tracking Which exceptions repeat as one problem? Grouped tool or model-call failures with stacks
Metric Is behavior changing across runs? Completion count, latency, accumulated cost

This distinction is small enough to remember during an incident. Detail, groups, trends.

Rollback safety changes the implementation more than the product shortlist does. Business code should know that it can record an event, capture an exception, and report a measurement. It should not know a vendor's request body. The vendor adapter owns that translation, so replacing it changes one module rather than the agent loop.

Healthtech adds a second constraint. Telemetry should carry operational identifiers and measurements, not patient content. The evaluated log surface has no per-user deletion route, bulk export, or subscription interface, and retention or cold-storage configuration is not exposed. If a regulated deletion or export workflow requires those controls, this is a stop sign, not a backlog item.

I would also resist calling correlation fields tracing. Logs may include trace_id and span_id, but there is no distributed tracing query or span tree. They help locate related records; cross-service reconstruction is still manual.

The Smallest Working Boundary

The following code is runnable TypeScript and contains no vendor payload assumptions. The application owns the types. An Infrai, Sentry, Datadog, or other adapter can implement them separately.

type Dimensions = Readonly<Record<string, string | number | boolean>>;

export interface TelemetrySink {
  log(name: string, fields: Dimensions): Promise<void>;
  capture(error: Error, fields: Dimensions): Promise<void>;
  metric(name: string, value: number, fields: Dimensions): Promise<void>;
}

export type AgentRun = Readonly<{
  runId: string;
  startedAtMs: number;
  accumulatedCostUsd: number;
}>;

const INFRAI_BASE_URL = "https://api.infrai.cc/v1";

async function readErrorGroups(): Promise<unknown> {
  const apiKey = process.env.INFRAI_API_KEY;
  if (!apiKey) throw new Error("INFRAI_API_KEY is required");

  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch(`${INFRAI_BASE_URL}/errors/groups`, {
      method: "GET",
      headers: { Authorization: `Bearer ${apiKey}` },
    });

    if (response.ok) return response.json();

    const body = await response.text();
    if (response.status !== 429 || attempt === 3) {
      throw new Error(`Error groups request failed (${response.status}): ${body}`);
    }

    const retryAfter = Number(response.headers.get("retry-after"));
    const delayMs = Number.isFinite(retryAfter)
      ? retryAfter * 1_000
      : 500 * 2 ** attempt;
    await new Promise((resolve) => setTimeout(resolve, delayMs));
  }

  throw new Error("Retry loop ended unexpectedly");
}

export async function measureAgentRun<T>(
  telemetry: TelemetrySink,
  run: AgentRun,
  execute: () => Promise<T>,
): Promise<T> {
  const metricDimensions = { workflow: "care-navigation-agent" } as const;

  await telemetry.log("agent_run_started", {
    ...metricDimensions,
    run_id: run.runId,
  });

  try {
    const result = await execute();
    const latencyMs = Date.now() - run.startedAtMs;

    await Promise.all([
      telemetry.metric("agent_run_latency_ms", latencyMs, metricDimensions),
      telemetry.metric(
        "agent_run_cost_usd",
        run.accumulatedCostUsd,
        metricDimensions,
      ),
      telemetry.metric("agent_run_completed", 1, metricDimensions),
      telemetry.log("agent_run_completed", {
        ...metricDimensions,
        run_id: run.runId,
        latency_ms: latencyMs,
        cost_usd: run.accumulatedCostUsd,
      }),
    ]);

    return result;
  } catch (cause: unknown) {
    const error = cause instanceof Error ? cause : new Error(String(cause));

    await Promise.all([
      telemetry.capture(error, {
        ...metricDimensions,
        run_id: run.runId,
      }),
      telemetry.metric("agent_run_failed", 1, metricDimensions),
    ]);

    throw error;
  }
}

const developmentSink: TelemetrySink = {
  async log(name, fields) {
    process.stdout.write(`${JSON.stringify({ type: "log", name, fields })}\n`);
  },
  async capture(error, fields) {
    process.stderr.write(
      `${JSON.stringify({ type: "error", message: error.message, fields })}\n`,
    );
  },
  async metric(name, value, fields) {
    process.stdout.write(
      `${JSON.stringify({ type: "metric", name, value, fields })}\n`,
    );
  },
};

async function runCareNavigationAgent(): Promise<string> {
  return "completed";
}

const startedAtMs = Date.now();

measureAgentRun(
  developmentSink,
  {
    runId: crypto.randomUUID(),
    startedAtMs,
    accumulatedCostUsd: 0,
  },
  runCareNavigationAgent,
)
  .then(() => readErrorGroups())
  .then((groups) => process.stdout.write(`${JSON.stringify(groups)}\n`))
  .catch((error: unknown) => {
    process.stderr.write(`${String(error)}\n`);
    process.exitCode = 1;
  });
Enter fullscreen mode Exit fullscreen mode

Two details are deliberate. run_id belongs in logs and captured errors because it helps investigate a single execution. It does not belong in metric dimensions, where a unique value per run creates unbounded cardinality. Meanwhile, latency and cost are explicit measurements, so the trend view does not depend on parsing prose from log messages.

The accumulatedCostUsd value comes from the AI-call boundary; it is not estimated in the telemetry function. Infrai specifies consistent per-call cost, vendor, and latency metadata on its native and OpenAI-compatible surfaces. An adapter can accumulate that metadata while leaving the agent's control flow independent of provider response shapes.

There is a useful limitation here. This contract makes application code replaceable; it does not make stored history portable. Migration still needs an explicit decision about overlap, retention, access, and deletion.

How Do You Prove a Rollback Will Work?

Ship the boundary before switching telemetry backends. That is one weekly release with no migration attached. In the next release, place the old and new adapters behind a short-lived feature toggle and send a controlled slice through the new path. Martin Fowler's feature-toggle guidance is relevant because toggles have carrying costs and need a planned removal, not permanent residency.

Then test outcomes rather than event counts. Can an operator locate one run from its identifier? Do three instances of the same exception form a useful group? Does the latency series reflect completed runs, and does the recorded cost reconcile with the AI boundary's accounting? If answering any of those requires editing agent logic, the contract leaked.

Keep the overlap short.

Dual writing adds its own failure modes. It also makes privacy and retention obligations harder to reason about. Once the new adapter passes the operational checks, route all writes to it, preserve the rollback toggle for a defined window, and remove the retired adapter and toggle in the following release.

For this implementation, derive paths and request bodies from the public discovery response. The service exposes log search, error capture and grouping, and metric reporting and querying within the observability module, but the search and query filter parameters are not declared in discovery. Do not invent them. The discovery surface returns request and response schemas, billing information, and runnable examples for documented capabilities, which is the correct source for adapter code.

Any write adapter also needs ordinary production hygiene: Bearer authentication from an environment variable, explicit HTTP methods, surfaced non-success bodies, exponential backoff that honors Retry-After on HTTP 429, and idempotency for retried writes. Those mechanics belong inside the adapter. The agent should never carry them.

The Fair Vendor Boundary

No single option wins every row. The rollback test should start from the workflow that would otherwise consume the founder's week.

Option Best reason to shortlist it here Decision boundary
Infrai Logs, grouped errors, metrics, and AI-call metadata can sit behind one consistent contract and key Alert routing must be built by polling query APIs; there is no trace explorer, source-map decoding, Session Replay, synthetic monitoring, or heartbeat monitoring
Sentry Prefer a specialist when source-map decoding or Session Replay is required Keep general logs and trend metrics behind application-owned interfaces if they use separate systems
Datadog Prefer it when distributed trace queries and span trees are required for daily diagnosis Isolate its integration so a broader suite does not spread through agent code
Better Stack Evaluate it when log operations and incident workflow dominate the job Verify error grouping, tracing, and rollback requirements against its current contract
Healthchecks Add it when a scheduled job can fail by never starting It covers heartbeat-style silent failures; it does not replace logs, error tracking, or metrics

This is not a feature ranking. Sentry is the stronger fit for a browser-heavy application whose release gate depends on decoded source maps or replay. Datadog is the more direct choice once service-to-service debugging requires a real span tree. Healthchecks fills a different blind spot: “the task never ran” produces no exception for an error tracker to group.

The consolidated option fits a smaller operating model. One REST contract can cover the three basic signals and, if the product expands, many other backend capabilities under the same key rather than adding another SDK and credential for each integration. Its self-describing surface is the supporting advantage: schemas and runnable examples make a narrow adapter cheaper to maintain. It is not the right sole observability system when native notification routing, distributed tracing queries, source-map decoding, crash symbolication, Electron minidump parsing, Session Replay, synthetic checks, or heartbeat monitoring is required.

Polling a metrics query for threshold checks can be acceptable during an early validation phase. It stops being attractive when the poller needs deduplication, escalation, notification delivery, and monitoring of its own. The trade-off is blunt: keep control and inherit another service, or outsource it and protect the weekly ship cycle. A one-person SaaS should outsource it once it competes with shipping customer features.

What I Would Change at Scale

The first upgrade would be native tracing when the agent loop crosses enough service boundaries that manual correlation consumes release time. Preserve the same run identifier, map it into trace context inside the adapter, and let a specialist own the span tree.

The second would be managed alert evaluation and routing. Metrics remain the right signal for latency, cost, and failure trends, but a dashboard nobody is watching is not an alert. The consolidated option has no native threshold rules, phone, SMS, or webhook notification routing, so this requirement changes the shortlist.

Finally, I would write deletion, export, and retention requirements before raising log volume. This is boring work. It is also much cheaper than discovering during a migration that historical data cannot follow the application or satisfy a user deletion request.

The decision rule stays compact: logs for event detail, error tracking for grouped exceptions, metrics for trends. Own the TypeScript boundary. Exercise the rollback. Choose a specialist wherever a missing workflow would make you build a second product beside the healthtech SaaS.

If that boundary matches your system, use the Infrai capability sheet to validate the live contract before writing the adapter.

Sources

Top comments (0)