Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(feature-flags): support quota limiting for feature flags #308

Merged
merged 14 commits into from
Feb 26, 2025
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
## Next

## 3.20.0 - 2025-02-25

- feat: add support for quota-limited feature flags ([#308](https://github.com/PostHog/posthog-ios/pull/308))

## 3.19.7 - 2025-02-20

- fix: recordings not always properly masked during screen transitions ([#306](https://github.com/PostHog/posthog-ios/pull/306))
Expand Down
21 changes: 17 additions & 4 deletions PostHog/PostHogFeatureFlags.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,8 @@ class PostHogFeatureFlags {
var recordingActive = true

// check for boolean flags
if let linkedFlag = sessionRecording["linkedFlag"] as? String,
let value = featureFlags[linkedFlag] as? Bool
{
recordingActive = value
if let linkedFlag = sessionRecording["linkedFlag"] as? String {
recordingActive = featureFlags[linkedFlag] as? Bool ?? false
// check for specific flag variant
} else if let linkedFlag = sessionRecording["linkedFlag"] as? [String: Any],
let flag = linkedFlag["flag"] as? String,
Expand Down Expand Up @@ -94,6 +92,21 @@ class PostHogFeatureFlags {
groups: groups)
{ data, _ in
self.dispatchQueue.async {
// Check for quota limitation first
if let quotaLimited = data?["quotaLimited"] as? [String],
quotaLimited.contains("feature_flags")
{
hedgeLog("Warning: Feature flags quota limit reached - clearing all feature flags and payloads. See https://posthog.com/docs/billing/limits-alerts for more information.")
self.featureFlagsLock.withLock {
// Clear both feature flags and payloads
self.setCachedFeatureFlags([:])
self.setCachedFeatureFlagPayload([:])
}

self.notifyAndRelease()
return callback()
}

guard let featureFlags = data?["featureFlags"] as? [String: Any],
let featureFlagPayloads = data?["featureFlagPayloads"] as? [String: Any]
else {
Expand Down
63 changes: 63 additions & 0 deletions PostHogTests/PostHogFeatureFlagsTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,40 @@ class PostHogFeatureFlagsTest: QuickSpec {
expect(sut.isFeatureEnabled("bool-value")) == true
}

it("clears feature flags when quota limited") {
let sut = self.getSut()
let group = DispatchGroup()
group.enter()

// First load some feature flags normally
sut.loadFeatureFlags(distinctId: "distinctId", anonymousId: "anonymousId", groups: ["group": "value"], callback: {
group.leave()
})

group.wait()

// Verify flags are loaded
expect(sut.isFeatureEnabled("bool-value")) == true
expect(sut.getFeatureFlag("string-value") as? String) == "test"

// Now set the server to return quota limited response
server.quotaLimitFeatureFlags = true

let group2 = DispatchGroup()
group2.enter()

// Load flags again, this time with quota limiting
sut.loadFeatureFlags(distinctId: "distinctId", anonymousId: "anonymousId", groups: ["group": "value"], callback: {
group2.leave()
})

group2.wait()

// Verify flags are cleared
expect(sut.isFeatureEnabled("bool-value")) == false
expect(sut.getFeatureFlag("string-value")).to(beNil())
}

#if os(iOS)
it("returns isSessionReplayFlagActive true if there is a value") {
let storage = PostHogStorage(self.config)
Expand Down Expand Up @@ -318,6 +352,35 @@ class PostHogFeatureFlagsTest: QuickSpec {

storage.reset()
}

it("returns isSessionReplayFlagActive false if bool linked flag is missing") {
let storage = PostHogStorage(self.config)

let sut = self.getSut(storage: storage)

expect(sut.isSessionReplayFlagActive()) == false

let group = DispatchGroup()
group.enter()

server.returnReplay = true
server.returnReplayWithVariant = true
server.replayVariantName = "some-missing-flag"
server.flagsSkipReplayVariantName = true
// server.replayVariantValue = false

sut.loadFeatureFlags(distinctId: "distinctId", anonymousId: "anonymousId", groups: ["group": "value"], callback: {
group.leave()
})

group.wait()

expect(storage.getDictionary(forKey: .sessionReplay)) != nil
expect(self.config.snapshotEndpoint) == "/newS/"
expect(sut.isSessionReplayFlagActive()) == false

storage.reset()
}
#endif
}
}
17 changes: 15 additions & 2 deletions PostHogTests/TestUtils/MockPostHogServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,18 +40,31 @@ class MockPostHogServer {
public var returnReplayWithVariant = false
public var returnReplayWithMultiVariant = false
public var replayVariantName = "myBooleanRecordingFlag"
public var flagsSkipReplayVariantName = false
public var replayVariantValue: Any = true
public var quotaLimitFeatureFlags: Bool = false

init() {
stub(condition: pathEndsWith("/decide")) { _ in
if self.quotaLimitFeatureFlags {
return HTTPStubsResponse(
jsonObject: ["quotaLimited": ["feature_flags"]],
statusCode: 200,
headers: nil
)
}

var flags = [
"bool-value": true,
"string-value": "test",
"disabled-flag": false,
"number-value": true,
"recording-platform-check": "web",
self.replayVariantName: self.replayVariantValue,
"recording-platform-check": "web"
]

if !self.flagsSkipReplayVariantName {
flags[self.replayVariantName] = self.replayVariantValue
}

if self.errorsWhileComputingFlags {
flags["new-flag"] = true
Expand Down
Loading