Context Enrichment Strategies for Targeting

This guide is part of the Backend Evaluation & Server-Side SDKs series. A server-side SDK evaluates flags against an evaluation context — a key-value map describing the current request. A thin or incorrectly assembled context silently breaks targeting rules; an over-populated context leaks PII to vendor logs and audit records. This guide covers how to build, enrich, sanitize, and pass that context correctly every time.

Problem Framing: What Context Enrichment Is and Is Not

A bare context might contain only a targetingKey. That is enough to route traffic by user ID but useless for rules that check tenantTier, region, or betaOptIn. Enrichment is the act of populating those additional attributes before the evaluation call. The pipeline has three stages: assemble (collect attributes from the request and your data layer), sanitize (normalize types, enforce schema, strip or hash PII), and evaluate (pass the clean context to OpenFeature).

This guide covers the enrichment pipeline and the OpenFeature evaluation context schema. It does not cover distributed caching for flag rule sets, or how to tune the rule engine that processes the context once it arrives.

Context-assembly pipeline A request passes through four stages — raw request, enrichment, sanitization, evaluation — before a flag variant is returned. Request userId · headers Enrich tier · region · flags Sanitize hash PII · validate Evaluate OpenFeature SDK → variant
Each request passes through enrichment (add attributes) and sanitization (normalize + hash PII) before the OpenFeature SDK runs the evaluation.

Prerequisites

Core Concept & Architecture

OpenFeature defines the evaluation context as a flat or nested map with one reserved key: targetingKey. Every other attribute is application-defined. The shape your rules expect must match what the enrichment pipeline delivers — if a rule checks tenantTier == "enterprise" but the context key is subscriptionTier, the rule evaluates to the default variant silently.

The canonical attribute taxonomy for server-side targeting:

Category Example keys Source
Identity targetingKey, userId, tenantId JWT / session
Entitlement tenantTier, betaOptIn, featureAccess Entitlement service
Request region, environment, correlationId Request headers / middleware
Derived isInternalUser, accountAgedays Computed at enrichment time

Correlation IDs deserve special treatment: carry a request-scoped correlationId through the context so every flag evaluation in a trace can be joined back to the originating request. This is the cheapest way to answer “which variant did this request see?” during an incident.

Step-by-Step Implementation

Step 1 — Assemble the base context from the request

Extract the minimum required attributes from the authenticated request before touching downstream services. This keeps the fast path cheap and delays enrichment I/O until it is confirmed necessary.

import { EvaluationContext } from '@openfeature/server-sdk';

function buildBaseContext(req: AuthenticatedRequest): EvaluationContext {
  return {
    targetingKey: req.userId,          // required by OpenFeature
    tenantId:    req.tenantId,
    correlationId: req.headers['x-correlation-id'] ?? crypto.randomUUID(),
    environment: process.env.APP_ENV,  // e.g. "prod"
    region:      req.headers['x-forwarded-region'] ?? 'us-east-1',
  };
}

Pitfall: do not fall back to a random UUID as the targetingKey — bucketing becomes non-deterministic and percentage-based rollouts with sticky bucketing break. Use a stable identifier; fall back to an anonymous session ID if the user is unauthenticated, not a fresh random.

Step 2 — Enrich with downstream attributes

Call enrichment sources in parallel so their latencies overlap rather than stack. Cap each call with a per-source timeout and fall back to safe defaults on failure — a partial context is better than a blocked request.

import { OpenFeature, EvaluationContext } from '@openfeature/server-sdk';

async function enrichContext(
  base: EvaluationContext,
  entitlementSvc: EntitlementService,
): Promise<EvaluationContext> {
  const [entitlement] = await Promise.allSettled([
    entitlementSvc.get(base.targetingKey as string, { timeout: 30 }),
  ]);

  return {
    ...base,
    tenantTier:    entitlement.status === 'fulfilled' ? entitlement.value.tier  : 'free',
    betaOptIn:     entitlement.status === 'fulfilled' ? entitlement.value.beta  : false,
    isInternalUser: (base.targetingKey as string).endsWith('@internal.example.com'),
  };
}

Pitfall: Promise.all throws on the first rejection and drops the other results. Use Promise.allSettled so a single failed enrichment source does not discard attributes from sources that succeeded.

Step 3 — Sanitize: normalize types and apply PII boundaries

Before the context reaches the OpenFeature SDK, pass it through a sanitizer that enforces the schema, converts types, and hashes or removes sensitive identifiers. This is the redaction boundary between raw user data and the evaluation engine.

import { createHash } from 'crypto';

function sanitizeContext(ctx: EvaluationContext): EvaluationContext {
  const result: EvaluationContext = { ...ctx };

  // Hash the targetingKey so the raw user ID never reaches vendor telemetry
  result.targetingKey = createHash('sha256')
    .update(String(ctx.targetingKey))
    .digest('hex')
    .slice(0, 16);

  // Strip any raw email or phone that may have leaked in from upstream
  delete (result as Record<string, unknown>)['email'];
  delete (result as Record<string, unknown>)['phone'];

  // Normalize tier to the expected enum
  const validTiers = new Set(['free', 'pro', 'enterprise']);
  if (!validTiers.has(result.tenantTier as string)) {
    result.tenantTier = 'free';
  }

  return result;
}

Pitfall: the sanitizer runs before every evaluation call. Keep it synchronous and allocation-light — cloning the object with { ...ctx } is fine; deep-copying a nested graph on each call is not.

Step 4 — Pass the context to OpenFeature and evaluate

With a clean, enriched context, the evaluation call is a single method. Keep the call-site thin; all context logic belongs in the pipeline, not scattered across call sites.

const client = OpenFeature.getClient('checkout');

async function resolveFlag(req: AuthenticatedRequest): Promise<boolean> {
  const base    = buildBaseContext(req);
  const rich    = await enrichContext(base, entitlementSvc);
  const context = sanitizeContext(rich);

  return client.getBooleanValue(
    'checkout.payments.express-pay',
    false,           // safe default
    context,
  );
}

Step 5 — Emit telemetry without leaking context

Attach the correlationId and the resolved variant to your trace span. Never emit the full context object to a log line or a telemetry backend — the sanitized targetingKey hash is safe; the raw userId is not.

span.setAttributes({
  'flag.key':         'checkout.payments.express-pay',
  'flag.variant':     variant,
  'flag.correlation': context.correlationId as string,
  // Do NOT log context.targetingKey or any unsanitized attribute
});

Verification & Testing

Validate the pipeline end-to-end with a synthetic context that exercises each enrichment source in isolation.

# Dry-run against flagd: confirm the enriched context matches targeting rules
curl -sf -X POST http://localhost:8013/schema.v1.Service/ResolveBoolean \
  -H 'Content-Type: application/json' \
  -d '{
    "flagKey": "checkout.payments.express-pay",
    "context": {
      "targetingKey": "a1b2c3d4e5f60001",
      "tenantTier":   "enterprise",
      "region":       "us-east-1",
      "environment":  "prod"
    }
  }' | jq -e '.reason == "TARGETING_MATCH"'

For unit testing, seed the context builder with known inputs and assert that the sanitized output has no raw PII keys and that defaults fill correctly when an enrichment source returns an error.

Troubleshooting & FAQ

Why does a rule target tenantTier but always fall back to the default variant?

The most common cause is a key mismatch: the rule checks tenantTier but the context carries subscriptionTier or tier. Enable debug logging on the SDK and inspect the raw context object that reaches the provider. Compare the exact key names against what the rule definition expects.

How do I avoid blocking the request path with enrichment I/O?

Make enrichment calls parallel (Promise.allSettled) and set short per-source timeouts (30–50 ms). Fall back to defaults on timeout so the evaluation still runs. If attributes for a specific flag only matter when a rule needs them, consider lazy enrichment — resolve those attributes only when the flag’s rule tree actually references them.

Can I reuse the same context object across multiple flag evaluations in one request?

Yes — build and sanitize once per request, then pass the same context to every flag call. Avoid re-enriching on each getBooleanValue call; that turns a per-request overhead into a per-flag overhead.

What should I do if the correlationId is missing from upstream requests?

Generate one at the entry point of your service and inject it into the context. Propagate it downstream via the x-correlation-id header. Never leave this absent — without it, you cannot join flag decisions to traces during incident investigation.

Performance & Scale Considerations

Context enrichment adds to the request critical path only if you serialize I/O. Parallel fetches with timeouts keep the overhead bounded. Measure enrichment latency as a distinct span in your traces and budget it explicitly — for most services, 10–30 ms is acceptable; above 50 ms, evaluate whether the attributes can be pre-populated by the server-side SDK integration at session-init time rather than per-request.

If a flag’s rules only need attributes already present in the JWT or session token, the enrichment call for that flag is zero cost. Segment flags by which enrichment sources they require and skip calls that a given evaluation does not need.