A small SaaS comparing a Prometheus alternative for custom metrics needs more than a Node.js dashboard that says revenue dipped; it must show which pricing rule was active. The operational constraint changes the design: preserve enough context to reconstruct a rollout before optimizing the collection mechanism.
TL;DR: record three decisions for every pricing evaluation: the rule version, the flag variant, and the outcome. Use pull collection when a long-running Node.js service can expose current counters reliably. Use a push API when short-lived jobs or network boundaries make scraping impractical, but treat delivery, retries, and event time as application concerns. The dashboard is the last step. The evidence model comes first.
This is deliberately a small system. It supports shipping a pricing change weekly without turning observability into a second product.
Evidence first.
What must survive the incident?
Imagine a new e-commerce rule behind a flag: customers in one market receive volume pricing when a cart crosses a threshold. Two hours after rollout, completed orders fall. A graph of checkout_total cannot answer the useful questions. Was the new variant evaluated? Which rule revision produced the quote? Did the quote become an order?
Those are separate decisions, and collapsing them into one success counter destroys the join points needed later. I would keep the telemetry vocabulary small:
| Signal | Required dimensions | Reconstruction question |
|---|---|---|
| Pricing evaluations |
rule_version, flag_variant, result
|
Which logic produced the displayed price? |
| Checkout outcomes |
flag_variant, outcome
|
Did exposed sessions complete or fail? |
| Rule errors |
rule_version, error_class
|
Did evaluation fail before checkout? |
Do not attach customer IDs, cart IDs, or raw URLs to metric labels. Metrics aggregate repeated measurements; an unbounded identifier creates an unbounded set of time series. Keep a correlation ID in logs or traces instead, where one event can be inspected without turning every customer into a dashboard dimension.
This separation also prevents a subtle mistake: treating absence as zero. No samples might mean no traffic, a dead process, a failed push, or a broken scrape. The collection path needs its own health signal, and an incident review must distinguish those cases.
The smallest Node.js implementation
The application code should not know whether a collector pulls or an adapter pushes. A tiny interface keeps the pricing path stable while the transport changes. It also makes the behavior testable without starting a monitoring stack.
type Labels = Record<string, string>;
interface CounterSink {
add(name: string, value: number, labels: Labels): void;
}
type PricingDecision = {
ruleVersion: string;
flagVariant: "control" | "volume_v2";
result: "standard" | "discounted" | "error";
};
export function recordPricingDecision(
sink: CounterSink,
decision: PricingDecision,
): void {
sink.add("pricing_evaluations_total", 1, {
rule_version: decision.ruleVersion,
flag_variant: decision.flagVariant,
result: decision.result,
});
}
That is enough instrumentation for the business decision. It is intentionally boring. The metric name describes a cumulative count, while labels carry a bounded set of states that can be grouped during an incident.
For a pull model, the in-process adapter accumulates these values and exposes a snapshot on an HTTP endpoint. The collector owns the schedule and records when a target could not be reached. A long-running Node.js web process fits this shape well because its current state is available between scrapes.
import { createServer } from "node:http";
const counts = new Map<string, number>();
const sink: CounterSink = {
add(name, value, labels) {
const ordered = Object.entries(labels).sort(([a], [b]) => a.localeCompare(b));
const key = JSON.stringify([name, ordered]);
counts.set(key, (counts.get(key) ?? 0) + value);
},
};
function snapshot(): string {
return [...counts.entries()]
.map(([key, value]) => `${key}\t${value}`)
.join("\n");
}
createServer((request, response) => {
if (request.url !== "/metrics") {
response.writeHead(404).end();
return;
}
response.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
response.end(snapshot());
}).listen(9464);
The serializer above demonstrates the boundary; it is not a claim of compatibility with a particular exposition format. In production, use a maintained metrics library or an OpenTelemetry SDK exporter rather than owning serialization. Outsource the undifferentiated parts. Spend engineering time on the pricing rule and the evidence it needs.
For push, the same sink can buffer points and hand batches to a transport adapter. That adapter must define event timestamps, a timeout, a bounded queue, and retry behavior. Never block checkout indefinitely to report checkout telemetry. If the queue fills, increment a local dropped-measurement counter and surface adapter health through a separate path.
Should a small SaaS push custom metrics or use a pull model?
The choice is less about dashboard taste than failure ownership.
With pull, the collector initiates collection. A failed scrape is visible from outside the application, and the scrape time supplies a consistent observation clock. Process-local counters disappear on restart unless the collector captured them first, so resets must be handled in queries. Brief jobs may start and finish between collection intervals. That limitation rules out a pure pull design for work that routinely begins and ends unseen.
With push, the application or an agent initiates delivery. That works across inbound network restrictions and for jobs whose lifetime is shorter than a scrape interval. It also moves work into the sending path: buffering, backoff, duplicate handling, authentication, and timestamp semantics now need explicit answers. A successful HTTP response only proves that one receiver accepted a request; it does not prove the future dashboard will preserve the point. The trade-off is more application-owned delivery state, so push is not a fit when the team cannot operate a queue or verify duplicates.
For the pricing rollout, I would use pull for the long-running checkout service and push only for a short-lived backfill that recalculates catalog prices. Mixing collection modes is acceptable when the metric schema stays consistent. Forcing every workload through one transport is tidy on a diagram and expensive during an incident.
The practical test is direct. On the Node.js 22 runtime used for the service, kill the collector, restart the process, and run the backfill twice. Then ask whether the resulting timeline lets you distinguish missed collection, a counter reset, duplicated delivery, and a genuine drop in evaluations. Repeat the exercise with one delayed batch and one rejected batch, because a clean happy-path replay hides the exact ambiguity an incident creates: the same empty interval can represent zero pricing evaluations, an unreachable target, discarded buffered points, or a query that filtered the new rule version. Record which layer exposes each failure and how an operator proves recovery. If the answer depends on remembering an undocumented retry interval, the design is not ready.
More panels will not help.
Build the dashboard backward from three queries
Start with the incident questions, not chart types. One view compares pricing evaluations by flag_variant and result. A second compares checkout outcomes by variant. A third shows rule errors by rule_version beside collection health.
Avoid calculating causality from those charts. They support correlation and triage. A variant can coincide with fewer purchases because traffic composition changed, inventory ran out, or payment processing degraded. The metrics narrow the search; logs and traces carry the per-request detail required to inspect a specific path.
OpenTelemetry describes a metrics data model with instruments and aggregations, and it can provide a stable instrumentation boundary even when storage changes. Prometheus uses a pull-oriented model for scraping HTTP metric endpoints. Grafana can visualize data from multiple sources. Hosted systems such as Datadog, New Relic, and Honeycomb differ in ingestion APIs, query models, retention controls, and how metrics relate to traces and logs. Those boundaries matter, but none repairs missing rule context at emission time.
Evaluate any backend with a replay exercise. Feed a fixed set of decisions, include one process restart, and delay one batch. Check whether the three incident questions produce understandable answers. Also inspect export, retention, cardinality controls, and delivery visibility before committing. A polished default dashboard ranks below recoverable evidence.
What I would change at scale
At higher traffic, I would move schema definitions into a shared package, enforce an allowlist for label values, and add automated tests that reject customer-derived dimensions. I would also sample detailed traces while retaining low-cardinality counters for the full rollout window. Metrics answer “how much”; traces and logs explain individual paths.
Multiple service instances introduce another trap. A process-local counter belongs to one process lifetime. Aggregate rates across instances with reset-aware queries rather than pretending each counter is a durable global total. For pushed points, include a stable producer identity only if the backend needs it for deduplication, and keep that identity bounded.
There is a revenue-per-hour line here. Building custom storage, query execution, alert delivery, and access control rarely advances an e-commerce pricing feature. A solo operator should own the event semantics and tests, then choose interchangeable collection and storage components that meet the reconstruction exercise. Ship weekly. Revisit the transport when workload lifetime, network topology, or operational burden actually changes.
The final decision rule is short: choose pull when external collection can observe a persistent service and scrape failures are valuable evidence; choose push when the producer cannot be scraped, after budgeting for delivery state. In both cases, preserve rule version, variant, outcome, and collection health. That is what turns a graph into an incident timeline.
Top comments (0)