From 5e942fe61ae172257cf4a01d95decc93392810a0 Mon Sep 17 00:00:00 2001 From: Arnaud Hillen Date: Wed, 30 Sep 2026 00:06:09 +0200 Subject: [PATCH] fix(replay): stop session replay when over the mobile recordings quota The server reports the mobile session replay quota as `quotaLimited: ["mobile_recordings"]` in remote config and keeps `sessionRecording` set, because web shares that field. Treat the value the same as `sessionRecording: false`. `quotaLimited` moves to `PostHogRemoteConfigResponse` so `/config` responses parse it. Generated-By: PostHog Desktop Task-Id: f115d189-5362-410f-9778-f87e6f59f5d6 --- .changeset/stop-replay-on-mobile-quota.md | 6 ++++ posthog/api/posthog.api | 21 ++++++------ .../posthog/internal/PostHogFlagsResponse.kt | 2 -- .../posthog/internal/PostHogRemoteConfig.kt | 11 ++++++- .../internal/PostHogRemoteConfigResponse.kt | 2 ++ .../internal/PostHogRemoteConfigTest.kt | 33 +++++++++++++++++++ 6 files changed, 61 insertions(+), 14 deletions(-) create mode 100644 .changeset/stop-replay-on-mobile-quota.md diff --git a/.changeset/stop-replay-on-mobile-quota.md b/.changeset/stop-replay-on-mobile-quota.md new file mode 100644 index 000000000..a21808690 --- /dev/null +++ b/.changeset/stop-replay-on-mobile-quota.md @@ -0,0 +1,6 @@ +--- +'posthog': patch +'posthog-android': patch +--- + +Stop session replay when the remote config reports the mobile recordings quota as exceeded (`quotaLimited` contains `mobile_recordings`). Replay resumes once a later remote config no longer reports it. diff --git a/posthog/api/posthog.api b/posthog/api/posthog.api index eb293ee65..71d451629 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -903,18 +903,17 @@ public final class com/posthog/internal/PostHogFeatureFlagsInterface$DefaultImpl } public final class com/posthog/internal/PostHogFlagsResponse : com/posthog/internal/PostHogRemoteConfigResponse { - public fun (ZLjava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/List;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/Boolean;)V - public synthetic fun (ZLjava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/List;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/Boolean;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (ZLjava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/Boolean;)V + public synthetic fun (ZLjava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/Boolean;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Z public final fun component2 ()Ljava/util/Map; public final fun component3 ()Ljava/util/Map; public final fun component4 ()Ljava/util/Map; - public final fun component5 ()Ljava/util/List; - public final fun component6 ()Ljava/lang/String; - public final fun component7 ()Ljava/lang/Long; - public final fun component8 ()Ljava/lang/Boolean; - public final fun copy (ZLjava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/List;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/Boolean;)Lcom/posthog/internal/PostHogFlagsResponse; - public static synthetic fun copy$default (Lcom/posthog/internal/PostHogFlagsResponse;ZLjava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/List;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/Boolean;ILjava/lang/Object;)Lcom/posthog/internal/PostHogFlagsResponse; + public final fun component5 ()Ljava/lang/String; + public final fun component6 ()Ljava/lang/Long; + public final fun component7 ()Ljava/lang/Boolean; + public final fun copy (ZLjava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/Boolean;)Lcom/posthog/internal/PostHogFlagsResponse; + public static synthetic fun copy$default (Lcom/posthog/internal/PostHogFlagsResponse;ZLjava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/Boolean;ILjava/lang/Object;)Lcom/posthog/internal/PostHogFlagsResponse; public fun equals (Ljava/lang/Object;)Z public final fun getErrorsWhileComputingFlags ()Z public final fun getEvaluatedAt ()Ljava/lang/Long; @@ -922,7 +921,6 @@ public final class com/posthog/internal/PostHogFlagsResponse : com/posthog/inter public final fun getFeatureFlags ()Ljava/util/Map; public final fun getFlags ()Ljava/util/Map; public final fun getMinimalFlagCalledEvents ()Ljava/lang/Boolean; - public final fun getQuotaLimited ()Ljava/util/List; public final fun getRequestId ()Ljava/lang/String; public fun hashCode ()I public fun toString ()Ljava/lang/String; @@ -1099,12 +1097,13 @@ public final class com/posthog/internal/PostHogRemoteConfig$SessionReplayLinkedF public class com/posthog/internal/PostHogRemoteConfigResponse { public fun ()V - public fun (Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Boolean;Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)V - public synthetic fun (Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Boolean;Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Boolean;Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;Ljava/util/List;)V + public synthetic fun (Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Boolean;Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getCapturePerformance ()Ljava/lang/Object; public final fun getErrorTracking ()Ljava/lang/Object; public final fun getHasFeatureFlags ()Ljava/lang/Boolean; public final fun getPush ()Ljava/lang/Object; + public final fun getQuotaLimited ()Ljava/util/List; public final fun getSessionRecording ()Ljava/lang/Object; public final fun getSurveys ()Ljava/lang/Object; } diff --git a/posthog/src/main/java/com/posthog/internal/PostHogFlagsResponse.kt b/posthog/src/main/java/com/posthog/internal/PostHogFlagsResponse.kt index 02f8543f9..51b1ad22c 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogFlagsResponse.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogFlagsResponse.kt @@ -9,7 +9,6 @@ import org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement * @property featureFlags the feature flags * @property featureFlagPayloads the feature flag payloads * @property flags the feature flags. - * @property quotaLimited array of quota limited features * @property requestId the request id generated by the flags server on evaluation * @property evaluatedAt the evaluated at timestamp generated by the flags server on evaluation * @property minimalFlagCalledEvents whether the server enabled minimal `$feature_flag_called` @@ -23,7 +22,6 @@ public data class PostHogFlagsResponse( val featureFlags: Map?, val featureFlagPayloads: Map?, val flags: Map? = null, - val quotaLimited: List? = null, val requestId: String?, val evaluatedAt: Long?, val minimalFlagCalledEvents: Boolean? = null, diff --git a/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt b/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt index d0086ecd9..27ae6874d 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt @@ -319,7 +319,16 @@ public class PostHogRemoteConfig( response?.let { synchronized(remoteConfigLock) { - processSessionRecordingConfig(it.sessionRecording) + // The server keeps sessionRecording enabled here because web recording is still allowed. + if (it.quotaLimited?.contains("mobile_recordings") == true) { + config.logger.log( + """Session replay is quota limited, recordings are disabled. + Learn more about billing limits at https://posthog.com/docs/billing/limits-alerts""", + ) + processSessionRecordingConfig(false) + } else { + processSessionRecordingConfig(it.sessionRecording) + } processSurveys(it.surveys) processErrorTrackingConfig(it.errorTracking) processCapturePerformanceConfig(it.capturePerformance) diff --git a/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfigResponse.kt b/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfigResponse.kt index 9766dee74..7d847e3a2 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfigResponse.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfigResponse.kt @@ -21,4 +21,6 @@ public open class PostHogRemoteConfigResponse( // same as a project with none configured. See docs/internal/push-subscription-registration.md // in PostHog/posthog. public val push: Any? = null, + // quota limited products, e.g. "mobile_recordings" or "feature_flags" + public val quotaLimited: List? = null, ) diff --git a/posthog/src/test/java/com/posthog/internal/PostHogRemoteConfigTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogRemoteConfigTest.kt index 76480fe26..213c37211 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogRemoteConfigTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogRemoteConfigTest.kt @@ -807,6 +807,39 @@ internal class PostHogRemoteConfigTest { http.shutdown() } + @Test + fun `mobile_recordings quota limit disables replay until a later remote config drops it`() { + val enabled = File("src/test/resources/json/basic-remote-config-no-flags.json").readText() + val quotaLimited = + enabled.replace( + "\"hasFeatureFlags\": false,", + "\"hasFeatureFlags\": false, \"quotaLimited\": [\"mobile_recordings\"],", + ) + val http = mockHttp(response = MockResponse().setBody(quotaLimited)) + http.enqueue(MockResponse().setBody(enabled)) + val sut = getSut(host = http.url("/").toString()) + + val latch = CountDownLatch(1) + sut.loadRemoteConfig( + "my_identify", + anonymousId = "anonId", + emptyMap(), + onFeatureFlags = PostHogOnFeatureFlags { latch.countDown() }, + ) + assertTrue(latch.await(5, TimeUnit.SECONDS), "quota limited remote config load should complete") + + assertFalse(sut.isSessionReplayFlagActive()) + assertNull(preferences.getValue(SESSION_REPLAY)) + + sut.loadRemoteConfig("my_identify", anonymousId = "anonId", emptyMap()) + executor.shutdownAndAwaitTermination() + + assertTrue(sut.isSessionReplayFlagActive()) + + sut.clear() + http.shutdown() + } + @Test fun `explicit errorTracking false from remote config caches the disabled stance`() { // Stale cache from when the project had autocapture enabled.