DEV Community

RhysFalconer159
RhysFalconer159

Posted on

Building App Logging Alerts with Custom Polling Tools Explained

When you compare app logging tools with alerts against polling, a pricing rollout has one unforgiving constraint: after a disputed order, the team must reconstruct the rule decision that the shopper received. An alert saying error count > 10 cannot do that.

TL;DR: Emit one structured decision event for each price calculation, preserve a stable correlation ID, and alert on both explicit failures and missing expected events. Compare a managed log-alert service with a polling worker by replaying the same incident questions. Choose the design that returns defensible evidence with the least operational glue, not the longest feature list or smallest advertised number.

This changes the usual logging-tools discussion. Datadog, Better Stack, and Grafana Cloud can sit in a candidate set, and a polling worker can be a fourth architecture. The useful comparison is whether an engineer can answer which rule version ran, which flag variant was evaluated, what shaped the outcome, and whether the evidence survived the alert path.

Should I compare app logging alert tools or build polling?

A pricing rule behind a flag can fail without throwing. A stale flag value may select the old rule. A worker may stop emitting decisions. A valid rule may reject an input and fall back to a base price. None of those states is explained by a page containing only an order ID and the word failed.

Treat the log event as an evidence record. It needs a timestamp, correlation ID, rule version, flag key and variant, outcome category, currency, and inputs that are safe to retain. It should record the fallback path too. The alert is downstream of that record; it is not the record itself.

Start there.

Be strict about the boundary. OWASP advises excluding or masking data such as access tokens, passwords, payment-card data, and sensitive personal information from logs. An order correlation ID is useful. A raw card number is indefensible. Data classification and retention rules decide what else belongs there.

Here is a fictional decision with deliberately plain fields:

{
  "event": "pricing.decision",
  "occurred_at": "2026-10-13T04:20:00.000Z",
  "correlation_id": "ord_demo_7f2",
  "rule_version": "regional-discount-v3",
  "flag_key": "regional-pricing",
  "flag_variant": "treatment",
  "currency": "USD",
  "base_minor": 12900,
  "final_minor": 11610,
  "outcome": "applied",
  "fallback": false
}
Enter fullscreen mode Exit fullscreen mode

That is enough to test retrieval and alert behavior. It is intentionally boring. Boring schemas are easier to query at 03:00.

The constraint that rules out naive polling

Polling looks like one query, one threshold, and one notification call. The hidden problem is state. A worker needs overlap for late arrivals, deduplication, retry behavior, and an independent signal proving that the worker is alive. Without those pieces, the monitor can silently miss the gap it was created to detect.

Consider a concrete sequence. The pricing service emits a treatment decision at 12:00:58, but ingestion delivers it after a poller has queried through 12:01:00. A worker that advances a cursor to the end of that query and never overlaps its next window can miss the late record. If the same worker retries a notification without an idempotency key, it can also page twice for one fallback. Then, if its scheduler dies, the absence detector dies with it. The repair is not another threshold: query overlapping time windows, deduplicate stable event identities, make notification delivery idempotent, and expose the worker's last successful run through an independent health signal. Each addition is reasonable. Together they turn a tiny script into an owned service.

That ownership is the real bill.

There are two alert classes. Negative evidence covers an explicit error or fallback event. Absence covers an expected stream that goes quiet. Do not collapse them into one count threshold. An empty result can mean healthy zero traffic, broken ingestion, a dead pricing path, or a broken poller. A traffic expectation must make silence actionable.

Use a window matching business cadence. A busy store may benefit from a short silence window; a low-volume catalog may get noise. There is no universal correct number, so derive it from observed order cadence and test planned quiet periods.

Cost comparisons are less tidy than a monthly headline. Ingestion, retention, indexed fields, query frequency, and engineering ownership move independently. The CloudWatch pricing page, for example, describes log ingestion charges by data volume. Volume is a dimension to measure. It is not the incident-reconstruction criterion.

The smallest working build

Start with the event contract. Keep it narrow enough that an SDK wrapper can validate it before emission. This TypeScript uses generic sinks, so transport and storage remain replaceable.

type Outcome = "applied" | "skipped" | "fallback" | "error";

type PricingDecision = {
  event: "pricing.decision";
  occurred_at: string;
  correlation_id: string;
  rule_version: string;
  flag_key: string;
  flag_variant: "control" | "treatment";
  currency: string;
  base_minor: number;
  final_minor: number;
  outcome: Outcome;
  fallback: boolean;
};

type LogSink = (event: PricingDecision) => Promise<void>;

export async function recordDecision(
  sink: LogSink,
  decision: Omit<PricingDecision, "event" | "occurred_at">,
): Promise<void> {
  await sink({
    event: "pricing.decision",
    occurred_at: new Date().toISOString(),
    ...decision,
  });
}
Enter fullscreen mode Exit fullscreen mode

The application must await or durably buffer this write according to its failure policy. Blocking protects evidence but adds availability coupling. Asynchronous delivery reduces request latency but needs durable buffering if process loss is unacceptable. I would expose that policy in the wrapper instead of burying it in configuration.

Now separate bad outcomes from silence:

type Alert = {
  kind: "bad-outcome" | "decision-silence";
  windowStart: string;
  count: number;
};

type CountDecisions = (input: {
  from: Date;
  outcomes?: Outcome[];
}) => Promise<number>;

export async function checkSignals(
  count: CountDecisions,
  send: (alert: Alert) => Promise<void>,
  now: Date,
  windowMs: number,
): Promise<void> {
  const from = new Date(now.getTime() - windowMs);
  const [all, bad] = await Promise.all([
    count({ from }),
    count({ from, outcomes: ["fallback", "error"] }),
  ]);

  if (bad > 0) {
    await send({ kind: "bad-outcome", windowStart: from.toISOString(), count: bad });
  }
  if (all === 0) {
    await send({ kind: "decision-silence", windowStart: from.toISOString(), count: 0 });
  }
}
Enter fullscreen mode Exit fullscreen mode

This is a kernel, not a production monitor. The loop still needs idempotent notification keys, bounded retries, timeouts, overlapping windows, and telemetry for its last successful run. It needs a traffic gate so a closed storefront does not page someone for legitimate silence. Config bloat arrives fast here. Put rule version and traffic expectations in one owned policy object, then reject unknown fields.

Test four cases before rollout: an applied treatment decision, an explicit fallback, a quiet interval during expected traffic, and a late event inside the overlap window. Then kill the poller. If nothing reports that death, the alert system is incomplete.

Kill it on purpose.

Comparing managed alerts and a polling worker

Use a timed incident drill, not a marketing matrix. Feed the same synthetic events into each candidate architecture and ask an engineer who did not build the test to reconstruct the order. Measure time to first correct answer, false pages, missed late events, and custom code required. Do not invent benchmark results in advance.

For Datadog, Better Stack, and Grafana Cloud, verify current ingestion, query, alert-window, retention, and export behavior in primary documentation before the drill. Their presence here is not an endorsement, and behavior can change. Apply the identical fixture and scoring rules to each service and the polling worker.

Incident question Evidence required Failure to reject
Which rule priced the order? Correlation ID plus rule version Only a deployment timestamp
Which cohort was active? Flag key and evaluated variant A current flag value
Was fallback intentional? Outcome and fallback fields A generic error count
Did evaluation stop? Traffic expectation plus event absence An empty query alone
Can the record be trusted? Defined access, masking, and retention Sensitive payload dumping

The managed path means less alert-engine code to own, while still requiring schema design, access control, and testing. The polling path offers control over query and notification semantics, but the team owns scheduler health, retries, and duplicate suppression. Those are architectural boundaries, not product scores.

Choose managed alerts when tested query and window semantics reconstruct the incident without adapters. Build polling when the required decision spans systems or business cadence that the tested alert model cannot express, and accept the worker as a production service with an owner. If both pass, prefer fewer moving parts in the on-call chain.

What I would change at scale

First, separate the durable decision stream from its searchable projection. Search indexes are useful operational views, but reconstruction should not depend on an accidentally short retention setting. The exact storage design depends on compliance and recovery requirements.

Second, version the event schema and validate producers in CI. A renamed rule_version field can disable a query without crashing the pricing request. Contract tests should replay old and new events through the alert query before deployment. A flag rollout also needs a rollback test; evidence must distinguish rollback from unexplained drift.

Third, benchmark cardinality before promoting fields into indexed dimensions. Correlation IDs are excellent lookup keys and potentially expensive grouping dimensions. Keep evidence searchable by exact ID, then aggregate on bounded fields such as rule version, outcome, and flag variant. Measure representative volume. Guessing is cheap and usually wrong.

More evidence improves reconstruction until it harms privacy, query behavior, or operator focus. More custom logic improves expressiveness until the monitor becomes another service needing monitoring. The right design is the smallest one that can replay a disputed pricing decision and prove when its evidence stream stopped.

Sources

Top comments (0)