Files
LCEssentials/Documentation/FeatureControl.md
Daniel Arantes Loverde c6e9803c75 [lcfeaturecontrol-review-fixes] Fix JWT log leak, drop macOS/tvOS, coalesce requests
- FeatureControlManager/NotificationsClient: pass debug: false at every
  api.request(...) call site — the API default (debug: true) was printing
  Authorization: Bearer <jwt> on every request, release builds included.
- Package.swift: drop macOS/tvOS from platforms — LCEssentials.API (used by
  LCFeatureControl) is iOS/watchOS-only and the vendored xcframework has no
  macOS/tvOS slice.
- FeatureControlManager: coalesce concurrent evaluate() calls on a cold cache
  into a single in-flight request per key instead of firing one per caller.
- FeatureControlManager/NotificationsClient: treat a 200 response carrying
  {"error": true} as a failure instead of caching/returning it as success.
- FeatureControlNotificationsClient: thread a cursor param through list() so
  the already-decoded nextCursor can actually be used to page.
- Tests: 9 new tests (coalescing, envelope-error-on-200, cursor param, a real
  object payload decoded through a full evaluate response, date-decode
  failures) and removed the 7 remaining force-unwraps in test scaffolding.
- Documentation/FeatureControl.md: new guide for the LCFeatureControl product,
  cross-linked from README.md and Extensions.md.
2026-09-16 13:21:10 -03:00

5.8 KiB

LCEssentials — Feature Control

A typed Swift client for Atomenta's Feature Control API: flag evaluation with a TTL cache and safe-degrade fallback, an in-app notifications inbox, and batched exposure telemetry. Lives in its own SPM product, LCFeatureControl, not in LCEssentials itself — an optional sub-package that target-depends on LCEssentials (same shape as LCECryptoKit, see its section there for the sub-SPM rationale).

Every section is a collapsible block — click a heading to expand it.

Contents


Setup

Add the product explicitly alongside LCEssentials to a consumer's Package.swift:

.product(name: "LCEssentials", package: "LCEssentials"),
.product(name: "LCFeatureControl", package: "LCEssentials"),
import LCFeatureControl
FeatureControlConfiguration — wiring for one module deployment
let configuration = FeatureControlConfiguration(
    baseURL: "https://api.example.com",
    environment: "production",
    auth: FeatureControlBearerAuth { await session.currentJWT() }
)
Property Default Notes
cacheTTL 45 seconds In-memory client-side cache window
requestTimeout 3 seconds Per-request timeout budget
evaluatePath / notificationsPath / telemetryPath /api/feature-control/... Override only for a non-default BFF mount
Auth strategiesFeatureControlAuthorizing

No Atomenta-Token (module token) type exists in this package on purpose — that token must never be embedded in a customer-facing app. Pick one:

// Internal/admin app hitting Atomenta directly with a panel-role JWT.
let auth = FeatureControlBearerAuth { await session.currentJWT() }

// Customer app calling its own BFF, which enforces its own auth —
// adds exactly the given headers, never synthesizes Authorization.
let auth = FeatureControlHeaderAuth(headers: ["X-BFF-Session": sessionToken])

Evaluating flags

FeatureControlManager — cache, safe-degrade, invalidation
let manager = FeatureControlManager(
    configuration: configuration,
    defaults: ["fc.checkout_v2": FeatureControlFlag(enabled: false, variant: nil,
                                                     payload: nil, reason: "default")]
)

let context = FeatureControlContext(subjectType: .customer, subjectId: user.id,
                                    storeId: store.id, platform: "ios",
                                    appVersion: appVersion)

let snapshot = await manager.evaluate(keys: ["fc.checkout_v2"], context: context)
snapshot.flags["fc.checkout_v2"]?.enabled   // Bool

evaluate(keys:context:) never throws — fresh fetch → stale cache → the defaults passed at init, in that order. Use it everywhere a flag gates real product behaviour. evaluateOrThrow(keys:context:) surfaces the real failure instead, for admin/debug tooling only.

Concurrent calls for the same key on a cold cache are coalesced onto a single in-flight request — three callers evaluating the same key at once produce one network call, not three.

await manager.invalidateCache()   // call on user/store change

The cache key is environment + subjectId + sortedKeys + platform + appVersion — it does not include storeId or attributes. If a subject's flags can legitimately differ by store, call invalidateCache() on store switch.

Notifications inbox

FeatureControlNotificationsClient — JWT-only

The OpenAPI fragment declares security: [bearerAuth] for every notifications route, no module-token alternative — configure with FeatureControlBearerAuth.

let client = FeatureControlNotificationsClient(configuration: configuration)

let (items, nextCursor) = try await client.list(status: .unread, limit: 20)
let (more, _) = try await client.list(status: .unread, limit: 20, cursor: nextCursor)

try await client.markRead(id: notification.id)
try await client.markAllRead()

Exposure telemetry

recordExposure(_:) / flushExposures() — best-effort, batched
await manager.recordExposure(FeatureControlExposureEvent(
    featureKey: "fc.checkout_v2", variant: "on",
    subjectType: .customer, storeId: store.id
))
// ... later, e.g. on app background or a periodic timer:
await manager.flushExposures()

Buffers locally — nothing is sent until flushExposures() is called. Splits into batches of ≤100 (server maxItems: 100). Not a critical-path operation: a failed batch is dropped, never retried indefinitely.

Errors

FeatureControlError — from evaluateOrThrow and the notifications client
public enum FeatureControlError: Error, Sendable, Equatable {
    case invalidContext(code: String)   // 400
    case unauthorized(code: String)     // 401
    case forbidden(code: String)        // 403
    case rateLimited(code: String)      // 429
    case server(code: String, status: Int)
    case transport(message: String)     // decode failure / non-HTTP error
}

The safe evaluate(...) path never surfaces this — it degrades to cache/defaults instead. evaluateOrThrow also throws .server(code:status: 200) if the server responds 200 with an {"error": true} envelope (a degraded-but-200 response).


See Extensions.md for the rest of LCEssentials' Foundation helpers, including the sibling LCECryptoKit sub-product.