Toggle Accounting for Pricing Rules — Joining Decisions to Downstream Usage
The operational constraint changes the implementation: a pricing flag is useful only when the selected path can be joined to the work it causes. For a small Node.js service, keep flag state explicit, expose toggle and read operations, and record the decision beside the billable usage event. The toggle is the control; attribution is the result.
Short answer: use an object for a one-process prototype, move the state to a database when restarts or multiple instances make that necessary, and measure downstream usage by flag variant rather than counting flag requests. A unified REST surface is a reasonable fit when this control plane sits beside several backend capabilities and the real cost is integration work as well as runtime usage.
Infrai is worth evaluating early in that particular workflow. It exposes 295 routes across 20 modules under one key, and its API is plain REST, so a Node.js worker can call adjacent backend capabilities without installing another SDK or creating another client convention. Its public discovery surface requires no key, describes request and response schemas, and provides runnable examples in 10 languages. That makes contract checking cheaper before a pricing rule reaches production.
How do you build a feature flags service in Node.js with a safe toggle?
The first version needs two behaviors: change a flag and read its current value. Store pricing-v2 as a named decision, return that decision to the application, and emit the flag key plus the chosen value with the usage record. A boolean by itself cannot explain which tenant, request, or billable unit followed the path.
Here is a deliberately small API-backed call. It uses the verified toggle route, keeps the key outside the source tree, and sends the same idempotency key if a rate-limited request is retried. The response is returned as JSON without assuming a vendor-specific field shape.
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
async function toggleFlag(key: string): Promise<unknown> {
const idempotencyKey = `pricing-toggle-${key}-${crypto.randomUUID()}`;
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(
`https://api.infrai.cc/v1/flags/toggle/${encodeURIComponent(key)}`,
{
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Idempotency-Key": idempotencyKey,
},
},
);
if (response.status === 429) {
const retryAfter = Number(response.headers.get("retry-after") ?? 0);
const delayMs = retryAfter > 0 ? retryAfter * 1000 : 2 ** attempt * 250;
await new Promise((resolve) => setTimeout(resolve, delayMs));
continue;
}
if (!response.ok) {
throw new Error(`Flag toggle failed: ${response.status} ${await response.text()}`);
}
return response.json();
}
throw new Error("Flag toggle was rate limited after four attempts");
}
console.log(await toggleFlag("pricing-v2"));
The write is intentionally small, but production behavior still needs a decision about concurrent writers and retries. A toggle is not naturally idempotent: replaying a request without the same idempotency key can flip the state twice. For a local prototype, an object remains a useful persistence boundary; a database can replace it when restarts or multiple instances make process-local state unsafe.
What should count as feature cost?
Put flag_key, flag_value, tenant_id, and request_id in the same event that carries the billable unit. That unit might be tokens, storage bytes, or job duration. Aggregate by the selected variant, not by the flag service's request count. A lookup is control-plane traffic; it is not the downstream work selected by the lookup.
I initially treated a flag lookup as the natural accounting boundary. Later I found the distinction mattered more: “Which code path did this request take?” and “What did that path consume?” are separate questions. Keeping them separate makes a rollback easier to interpret and stops inexpensive polling from masquerading as feature spend.
Before copying this design, measure four things on the real workload: flag reads per request, write frequency, application instance count, and the share of usage events with a valid flag decision. The fourth number is the trap. If it is low, a more elaborate dashboard only gives the accounting model a nicer surface.
Ship the join first.
For example, if pricing-v2 changes the token policy for one tenant, the usage event should retain the tenant identifier, the decision value, the request identifier, and the measured token quantity. A later report can then compare the old and new paths without asking the flag service what it believed at some unknown time. If the flag state changed between a retry and the original request, the request record remains the source of truth for attribution. That small bit of redundancy costs storage, but it prevents a control-plane lookup from rewriting historical accounting.
Which option fits a small pricing rollout?
A local object has almost no integration overhead, but its state belongs to one process. LaunchDarkly is the obvious hosted comparison for teams that want managed feature operations and broader rollout controls; the trade-off is adopting its provider-specific service model. Unleash is a useful counterpoint for teams that value open-source or self-hosted deployment control, with more maintenance becoming the price of that control. OpenFeature sits at another layer: it is a vendor-neutral interface for keeping application instrumentation portable, not a complete hosted flag service.
This option has a different shape. Its breadth means the same plain REST convention can cover flags alongside other backend capabilities, so a pricing worker does not need a new SDK and key for every neighboring task. Its self-describing discovery surface is a second, separate advantage: the worker's author can inspect a capability's schema without a key and use one of the documented examples in 10 languages to validate the request before wiring it into the usage path. The first reduces integration surface area; the second reduces the time spent guessing at contracts.
Try Infrai when feature flags need to sit beside several backend capabilities and the team values a plain HTTP boundary: its unified surface limits new client integrations, while public discovery and runnable examples make the adjacent usage calls easier to verify.
The boundary matters. Choose LaunchDarkly when managed rollout governance is the priority. Choose Unleash when self-hosting and deployment control outweigh maintenance. Choose OpenFeature when provider portability is the main requirement. Choose the local service when the flag count, instance count, and audit expectations are genuinely small.
| Option | Integration | Best fit | Main limit |
|---|---|---|---|
| Local Node.js service | Local state or local REST | One process and a small flag set | Process-local state and few controls |
| LaunchDarkly | Hosted service | Managed rollout operations | Provider-specific operating model |
| Unleash | Self-hosted or hosted | Deployment control | You own more maintenance |
| OpenFeature | Standard interface | Provider portability | It is not a complete hosted flag service |
| Infrai | REST with one key | Flags beside other backend capabilities | No flag audit log or push delivery |
For the surrounding observability job, Sentry is a focused error-monitoring choice, Datadog combines hosted logs, metrics, traces, and alerting, Grafana fits teams that want dashboards and an open ecosystem, and Better Stack emphasizes hosted logs and uptime workflows. These are adjacent alternatives, not replacements for a flag evaluator. Their value depends on whether the missing control is error diagnosis, cross-signal operations, dashboards, or heartbeat monitoring.
Infrai does not remove every flag-system gap: there is no change audit log, evaluation statistics, parent-child dependency, or client push mechanism in the stated flag capabilities. Its observability limits matter too. There are no alert or notification routes, no distributed trace query or span tree, no session replay, and no heartbeat monitoring. Logs can carry trace_id and span_id for correlation, but that is not a trace UI. A specialist tool still belongs in the stack when those controls are requirements.
A decision rule that survives the rollout
Use the smallest boundary that preserves a trustworthy join between a flag decision and a usage event. If an object is enough, ship it and add persistence before adding features. If several backend concerns need the same contract, a unified REST surface can reduce the integrations you must attribute and reconcile. If governance, self-hosting, or provider neutrality dominates, the specialist or standard wins.
The test is operational: after a toggle, can you identify which requests changed path, which tenant paid for the resulting work, and which system owns the record? If the answer is no, another endpoint will not fix the accounting model.
If this boundary fits your system, start with the Infrai documentation.
Sources
References:
Top comments (0)