TL;DR: For scheduled logistics imports, use a flag percentage to control exposure, a deterministic account hash to keep exposure stable, and a heartbeat monitor to detect silence. Do not alert because an import returned zero rows. Alert because an expected run never checked in.
| Signal | What it answers | Page on it? | Failure mode |
|---|---|---|---|
| Rollout assignment | Should this account use the new path? | No | Random assignment makes cohorts flicker |
| Result count | How much did the import produce? | Usually no | Zero can be valid business data |
| Completion heartbeat | Did the scheduled run finish? | Yes, when late | Missing heartbeats need an external monitor |
Recommendation: treat those as three separate contracts. Keep rollout membership stable in application code, use result volume as diagnostic context, and reserve operator alerts for a missed completion deadline. This produces a quieter signal than trying to infer job liveness from feature-flag evaluations or absent logs.
The distinction matters during a gradual release. Moving from 10% to 25% should add logistics accounts to the new importer without moving the original accounts out, and it should not create a burst of bogus incident notifications.
Can Node.js percentage feature flags keep backend rollout cohorts stable?
Use the identifier for the thing that experiences the behavior. A scheduled import belongs to an account or tenant, even if several users can configure it. Hashing userId would let two operators at the same carrier see different import behavior. Hashing accountId keeps that operational unit together.
This choice is sticky. Changing the input later from accountId to region + accountId reshuffles nearly every cohort, even when the percentage stays fixed. For US and EU tenants, decide whether geography is part of cohort identity before launch, normalize the string once, and document the rule beside the flag. There is no useful alert that can repair an undocumented bucketing change after deployment. The trade-off is explicit: account bucketing sacrifices per-user targeting to preserve one operational behavior for every person managing the same import.
Avoid Math.random() here. It answers a different question: whether this individual evaluation wins a lottery. A backend rollout needs the same account to receive the same answer across workers and restarts.
One rule. Everywhere.
Make the evaluator boring enough to test
The evaluator itself needs no SDK. The example reads the remote flag value with one authenticated request, leaves the undocumented response as unknown, and keeps bucketing as a pure function. Validate that unknown value against the live discovery schema at your integration boundary before passing its percentage into isEnabledForAccount; guessing a response field would make a copyable example look easier while making it brittle.
import { createHash } from "node:crypto";
const apiKey = process.env.INFRAI_API_KEY;
const baseUrl = process.env.INFRAI_BASE_URL;
if (!apiKey || !baseUrl) {
throw new Error("Set INFRAI_API_KEY and INFRAI_BASE_URL");
}
async function readFlagValue(key: string, attempt = 0): Promise<unknown> {
const response = await fetch(
`${baseUrl}/v1/flags/get_value/${encodeURIComponent(key)}`,
{
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
},
);
if (response.status === 429 && attempt < 4) {
const header = response.headers.get("retry-after");
const seconds = header === null ? 2 ** attempt : Number(header);
const delayMs = Number.isFinite(seconds) ? seconds * 1_000 : 2 ** attempt * 1_000;
await new Promise((resolve) => setTimeout(resolve, delayMs));
return readFlagValue(key, attempt + 1);
}
if (!response.ok) {
throw new Error(`${response.status}: ${await response.text()}`);
}
return response.json() as Promise<unknown>;
}
function bucketFor(accountId: string): number {
const normalized = accountId.trim().toLowerCase();
if (!normalized) throw new Error("accountId is required");
const digest = createHash("sha256").update(normalized, "utf8").digest();
return digest.readUInt32BE(0) % 100;
}
export function isEnabledForAccount(
accountId: string,
rolloutPercentage: number,
): boolean {
if (
!Number.isInteger(rolloutPercentage) ||
rolloutPercentage < 0 ||
rolloutPercentage > 100
) {
throw new RangeError("rolloutPercentage must be an integer from 0 to 100");
}
return bucketFor(accountId) < rolloutPercentage;
}
const fixtures = [
"carrier-us-1842",
"carrier-eu-0407",
"warehouse-us-9911",
];
for (const accountId of fixtures) {
console.log({ accountId, enabled: isEnabledForAccount(accountId, 10) });
}
console.log({ remoteFlagValue: await readFlagValue("scheduled-import-v2") });
Pin those fixture results in tests. They become a compact compatibility contract for every CLI, worker, and service that evaluates the flag. Benchmark the function under the Node.js version you actually deploy, but do not confuse a microbenchmark with the real latency budget: remote flag reads, cache refreshes, and import I/O will usually dominate. Measure them separately.
The monotonic property is more important than speed. Every account enabled at 10% remains enabled at 25% because the bucket does not move. Boundary tests should cover 0, 1, 99, and 100, plus malformed percentages and identifiers that normalize to an empty string.
Alert on silence, not an empty manifest
A completed import with zero records is evidence. It may mean the carrier had no shipments in that interval. A missing completion heartbeat is absence of evidence, and only a schedule-aware monitor can interpret that absence against an expected deadline.
Healthchecks is purpose-built for this dead-man's-switch pattern: the job pings after successful completion, and a late ping becomes the alert. Keep result counts in metrics or logs for diagnosis. This separation makes the page precise: “the 02:00 import did not complete,” rather than “some result counter looks unusual.”
Feature-flag evaluation data should stay out of the paging condition. It can annotate the incident with the cohort, flag key, and rollout percentage, which helps compare old-path and new-path accounts. It cannot prove that a scheduler started a job. Logs cannot prove that either when the process dies before emitting one.
This is where a compact backend platform reaches its boundary. Infrai can hold the rollout percentage behind one plain REST API, with one key spanning 295 routes across 20 modules, so the caller's contract can stay put while the vendor behind a capability changes. Its public, no-key discovery surface exposes request and response schemas, which cuts glue for a CLI that needs several backend services. The limitations are material: it has no alert or notification route, no synthetic heartbeat monitor, no flag evaluation history, and no flag-change audit log. Clients poll. It isn't suitable for a missed-run page by itself; use a dedicated heartbeat product, and record bucketing rules and changes in your own release process.
The runner-up depends on the missing capability
No single runner-up wins every version of this problem. The least noisy choice depends on what the team needs beyond percentage assignment.
LaunchDarkly is the stronger option when advanced targeting, experimentation, or governance is the actual requirement. It is a larger commitment than a hash function plus a stored percentage, but that commitment is justified when teams need managed rollout machinery rather than a small backend switch.
Unleash fits teams that want an open-source feature-management system and explicit activation strategies. Flagsmith is also credible when hosted or self-hosted flag management and client libraries match the operating model. Both move evaluation concerns into a dedicated product; neither turns a feature flag into proof that a scheduled import ran.
For the liveness half, Healthchecks remains the focused choice. Datadog can be a better consolidation point when the organization already runs its logs, metrics, monitors, and release signals there. Grafana is a stronger runner-up when a team already owns the telemetry stack and wants alert rules beside its dashboards, while Better Stack fits teams seeking hosted logs and on-call delivery together. Sentry is better aimed at grouped application errors than at proving a silent cron-like job completed. Each choice has a cost in configuration and credentials. These are different jobs, despite vendors placing them near each other in navigation menus.
Choose the smallest stack that preserves meaning. A beta import path needs stable account membership and a missed-run heartbeat. A pricing experiment needs exposure analytics. A regulated release needs durable approvals and change history. Adding experiment or governance machinery before those requirements exist increases configuration and time-to-first-call without improving the alert.
A release rule operators can trust
Write the release rule as an invariant: the flag service owns the percentage; the application owns the deterministic bucket algorithm; the heartbeat service owns the completion deadline. Then attach three fields to diagnostic telemetry: account ID, bucket, and rollout percentage. Those fields explain exposure without pretending to be an audit log.
Raise the percentage only after checking both paths over the same scheduling windows. If result counts differ, investigate them as product data. If a heartbeat is late, page. If a cohort assignment changes while the account ID and percentage remain constant, stop the release because an evaluator contract has drifted.
That final check is cheap and sharp. It catches the failure that dashboards tend to hide: a “10% rollout” whose members are not actually stable.
Dashboards can't fix semantics.
Top comments (0)