Skip to content

Commit

Permalink
feat(feature-flags): support quota limiting for feature flags (#308)
Browse files Browse the repository at this point in the history
* here we go

* linting

* update changelog

* Update CHANGELOG.md

Co-authored-by: Manoel Aranda Neto <[email protected]>

* the test that I forgot to commit

* fixing a mock

* formatting

* link to docs for warning log

* fix: replay disabled when linked flag is missing

* fix: lint

* fix: remove commented out code

* fix: isRecordingActive logic

* Update CHANGELOG.md

Co-authored-by: Manoel Aranda Neto <[email protected]>

---------

Co-authored-by: Manoel Aranda Neto <[email protected]>
Co-authored-by: Ioannis J <[email protected]>
  • Loading branch information
3 people authored Feb 26, 2025
1 parent db97741 commit 39c17e2
Show file tree
Hide file tree
Showing 4 changed files with 120 additions and 11 deletions.
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-26

- 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
50 changes: 40 additions & 10 deletions PostHog/PostHogFeatureFlags.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,17 +55,31 @@ 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 {
let value = featureFlags[linkedFlag]

if let boolValue = value as? Bool {
// boolean flag with value
recordingActive = boolValue
} else if value is String {
// its a multi-variant flag linked to "any"
recordingActive = true
} else {
// disable recording if the flag does not exist/quota limited
recordingActive = false
}
// check for specific flag variant
} else if let linkedFlag = sessionRecording["linkedFlag"] as? [String: Any],
let flag = linkedFlag["flag"] as? String,
let variant = linkedFlag["variant"] as? String,
let value = featureFlags[flag] as? String
{
recordingActive = value == variant
} else if let linkedFlag = sessionRecording["linkedFlag"] as? [String: Any] {
let flag = linkedFlag["flag"] as? String
let variant = linkedFlag["variant"] as? String

if let flag, let variant {
let value = featureFlags[flag] as? String
recordingActive = value == variant
} else {
// disable recording if the flag does not exist/quota limited
recordingActive = false
}
}
// check for multi flag variant (any)
// if let linkedFlag = sessionRecording["linkedFlag"] as? String,
Expand Down Expand Up @@ -94,6 +108,22 @@ 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")
{
// swiftlint:disable:next line_length
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
62 changes: 62 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,34 @@ 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

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
}
}
15 changes: 14 additions & 1 deletion PostHogTests/TestUtils/MockPostHogServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,19 +40,32 @@ 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,
]

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

if self.errorsWhileComputingFlags {
flags["new-flag"] = true
flags.removeValue(forKey: "bool-value")
Expand Down

0 comments on commit 39c17e2

Please sign in to comment.