Country, plan tier and an employee allowlist: build the rules engine?

Targeting logic is a 40-line pure function you should own. The store, the audit trail and the decision telemetry are the parts worth buying — here's the split.

Build the evaluation, buy the storage. Deciding whether a request is country ∈ {DE, AT} and tier ≥ pro, or on the employee allowlist regardless, is a pure function of about forty lines — it’s your business logic, it changes when your pricing changes, and no vendor’s rule builder will express it more clearly than the code. What you shouldn’t build is the durable part around it: a store with an audit trail, propagation to every process, a kill switch a non-engineer can hit, and telemetry for when a gate decides wrong. Infrai carries flag state on the same account and key as everything else, and this page uses its errors routes for the last of those four.

The trap in this question is the phrase “targeting rules engine”. It sounds like one product. It’s really four responsibilities with four different build-versus-buy answers, and teams get burned by treating it as all-or-nothing.

Four responsibilities, four verdicts

ResponsibilityBuild it?Why
Evaluating the rule for one requestBuildIt’s business logic, it’s cheap, and it’s testable in memory
Storing flag state and propagating itBuyConsistency, caching and staleness are where the bodies are buried
Audit: who flipped what, whenBuyYou’ll want it exactly once, during an incident, and it must already exist
Non-engineer edits and a kill switchDependsIf a PM must turn things off at 2am without a deploy, buy a UI
Seeing what the gate actually decidedBuild the hooks, buy the storeNobody sells “your gate denied the wrong 3%” — you have to emit it

LaunchDarkly and Unleash both sell the middle rows properly, with targeting UIs, streaming SDKs and change history. If a product manager owns the rollout, that’s the money well spent. If the rules live in your code and change with your deploys, you’re paying for a UI nobody opens.

What’s actually on the account

Don’t take a blog post’s word for which capabilities you already have — including this one. Ask the API:

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | python3 -c "
import json, sys
caps = json.load(sys.stdin)['capabilities']
for c in sorted((c for c in caps if c['namespace'] == 'flags'), key=lambda c: c['path']):
    print(c['method'].ljust(7), c['path'].ljust(34), 'free' if c['billing']['free'] else 'billable')
"

That prints the flags surface with its billing class attached — nine routes on the account we checked, all free — and it stays correct after we’ve stopped editing this page. Read the same output for the errors namespace by changing one string.

One caveat worth the sentence: a flag record carries a rules array, but there’s no documented request schema for writing targeting rules into it, so don’t design your country-and-tier logic around a field you’d be guessing at. Keep the decision in your process, where you can unit-test it.

The evaluator

Pure function, no I/O, no network. Everything it needs arrives as arguments:

const TIER_ORDER = ["free", "starter", "pro", "enterprise"];

/**
 * @param {{country: string, tier: string, email: string}} subject
 * @param {{countries?: string[], minTier?: string, allowlist?: string[], rollout?: number}} rule
 * @param {string} flagKey used for sticky bucketing
 */
export function decide(subject, rule, flagKey) {
  if (rule.allowlist?.includes(subject.email.toLowerCase())) {
    return { on: true, reason: "allowlist" };
  }
  if (rule.countries && !rule.countries.includes(subject.country)) {
    return { on: false, reason: "country" };
  }
  if (rule.minTier) {
    const have = TIER_ORDER.indexOf(subject.tier);
    const need = TIER_ORDER.indexOf(rule.minTier);
    if (have < 0 || need < 0) return { on: false, reason: "unknown-tier" };
    if (have < need) return { on: false, reason: "tier" };
  }
  if (typeof rule.rollout === "number" && rule.rollout < 100) {
    return bucket(subject.email, flagKey) < rule.rollout
      ? { on: true, reason: "rollout-in" }
      : { on: false, reason: "rollout-out" };
  }
  return { on: true, reason: "default" };
}

/** Deterministic 0-99 bucket: the same user keeps the same answer across processes. */
function bucket(identifier, flagKey) {
  let hash = 2166136261;
  for (const char of `${flagKey}:${identifier}`) {
    hash ^= char.charCodeAt(0);
    hash = Math.imul(hash, 16777619) >>> 0;
  }
  return hash % 100;
}

const rule = { countries: ["DE", "AT"], minTier: "pro", allowlist: ["ops@example.com"], rollout: 50 };
console.log(decide({ country: "DE", tier: "pro", email: "a@example.com" }, rule, "checkout-v2"));
console.log(decide({ country: "FR", tier: "enterprise", email: "ops@example.com" }, rule, "checkout-v2"));
console.log(decide({ country: "DE", tier: "free", email: "b@example.com" }, rule, "checkout-v2"));

Two design notes hiding in there. The allowlist is checked first, because “internal staff see it everywhere” is the point of an allowlist and a country check would otherwise veto your own QA. And bucketing hashes the flag key together with the user, so a user who lands outside one 50% rollout isn’t systematically excluded from every other one — a mistake that quietly turns “50% of users” into “the same unlucky half, forever”.

Telemetry for the decisions

The evaluator is easy to test and hard to observe. In production the interesting cases are the ones your tests don’t have: the flag store timed out, the tier came back null because the billing lookup failed, a country code arrived as de instead of DE. Each of those silently takes the default path, and the default path is invisible.

Give them a stable fingerprint and they become one group each:

curl -sS -X POST "https://api.infrai.cc/v1/errors/capture" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "message": "feature gate fell back to default: flag store unreachable for checkout-v2 (country=DE, tier=pro, allowlisted=false)",
    "exception": "FlagStoreUnavailable",
    "fingerprint": "gating:checkout-v2:store-unreachable",
    "environment": "production",
    "release": "api-2026.07.11"
  }'
{
  "ok": true,
  "data": {
    "event_id": "evt_err_n9MiHSYVWwajMip3EWHmjMca",
    "fingerprint": "621318a07ec3b71e2bcf79e524e74c11968dab80a93208bcb3f97db4f0ce24a0",
    "error_group_id": "errgrp_AtSjj1SSIoXUfUmDrkCV5hBN",
    "is_new_group": true,
    "dashboard_url": "https://infrai.cc/projects/proj_local/errors/evt_err_n9MiHSYVWwajMip3EWHmjMca"
  }
}

is_new_group: true on a release you shipped an hour ago is the signal that a rollout has started denying people it shouldn’t. Wire it into the fallback path, not the happy path:

import { decide } from "./decide.js";

const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is not set");

const FAIL_CLOSED = new Set(["checkout-v2", "new-pricing"]);

export async function gate(flagKey, subject, loadRule) {
  try {
    const rule = await loadRule(flagKey);
    return decide(subject, rule, flagKey);
  } catch (err) {
    const on = !FAIL_CLOSED.has(flagKey);
    await fetch("https://api.infrai.cc/v1/errors/capture", {
      method: "POST",
      headers: { authorization: `Bearer ${apiKey}`, "content-type": "application/json" },
      body: JSON.stringify({
        message: `feature gate fell back to ${on ? "on" : "off"} for ${flagKey}: ${err.message}`,
        exception: err.name || "FlagLookupError",
        fingerprint: `gating:${flagKey}:lookup-failed`,
        environment: process.env.APP_ENV ?? "production",
        release: process.env.RELEASE ?? "api-2026.07.11",
      }),
    }).catch(() => {});
    return { on, reason: "fallback" };
  }
}

Note the swallowed rejection on the capture call. Error reporting that can break the request it’s reporting on is worse than no error reporting.

Then read the group back when you’re deciding whether the rollout is safe to widen:

curl -sS "https://api.infrai.cc/v1/errors/group_detail/errgrp_AtSjj1SSIoXUfUmDrkCV5hBN" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

The group carries count, user_count, first_seen_at, last_seen_at and the releases it appeared in — enough to answer “is this one flapping instance or everybody in Germany” without opening a dashboard.

Where each option lands

Buy a flag platform if a non-engineer owns rollouts, you need audit history for compliance, or you’re running dozens of concurrent experiments — LaunchDarkly’s targeting model and Unleash’s activation strategies are mature and neither is expensive next to an engineer’s week. Keep it in-house if the rules change with your deploys anyway, which describes most teams under twenty engineers.

Either way, the decision telemetry is on you. Sentry can correlate a flag change with an error spike and Datadog will chart the aftermath, but neither knows your gate returned false for a customer who’d paid for the feature — that’s an event only your code can emit. Capture bills $0.00005 per event, verified 2026-07-26, and every read route in the errors namespace is free, so instrumenting the fallback path is close to free until something goes wrong at volume. Check the current figures rather than trusting this line, since rates drift down:

curl -sS "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | python3 -c "import json,sys; caps=json.load(sys.stdin)['capabilities']; print([c['billing'] for c in caps if c['id']=='errors.capture'])"

The limitation to be clear about: nothing in the errors namespace evaluates a targeting rule for you. It’s where the gate’s failures go, not where the gate lives.

References

Browse more errors developer guides