Skip to content

Commit 1ba63c4

Browse files
salevineclaudewyattwalter
authored
fix: fully honor block-anonymous-tracking flag on client and server (#41973)
## Description Makes the `configure_block_event_tracking_for_anonymous_users` feature flag **completely** turn off anonymous-user event tracking. Two gaps let anonymous events through when the flag was on: ### Gap 1 — client license bypass (`app/client/src/ce/sagas/userSagas.tsx`) `shouldTrackUser` short-circuited on an active license: ``` isAnonymous && (licenseActive || (telemetryOn && !featureFlag)) ``` so licensed EE/cloud instances tracked anonymous users regardless of the flag. Removed the `licenseActive` bypass — the flag is now honored on **all** instances. ### Gap 2 — ungated direct `sendEvent` path (`AnalyticsServiceCEImpl`) The flag was only checked in `sendObjectEvent`. Direct callers of `sendEvent(event, userId, props)` with an anonymous userId bypassed it (and that path resolves the anonymous user to the client `x-anonymous-user-id`). Added a gate on the public `sendEvent` that blocks anonymous userIds **before** id resolution, and it fails closed if the flag state can't be resolved (drops the event, completes the chain). The existing `sendObjectEvent` check is kept (it governs the session-user/signup path, where `sendEvent` receives the new user's email rather than `"anonymousUser"`). `sendObjectEvent` now routes through the extracted private `sendEventInternal` to avoid a redundant (Redis-backed) flag check on the hot anonymous path (published-app page views / action executions). ### Scope - Behavior is **opt-in**: the flag defaults to `false`, so existing instances are unchanged until an admin enables it. - **Out of scope (intentional):** the usage-pulse channel (`/api/v1/usage-pulse`) still emits the anonymous ID — that feeds billing/seat metering, not Segment analytics, and is treated separately. ### Tests - **Client** — new `userSagas.test.ts` covering the `shouldTrackUser` matrix, including the regression guard that anonymous + telemetry-on + flag-on returns `false` on a licensed instance. - **Server** — new `AnalyticsServiceCEImplTest` cases: a direct anonymous `sendEvent` enqueues nothing when the flag is on, and fails closed (no enqueue, no error) when the flag check errors. Ticket: #15380 ## Automation /ok-to-test tags="@tag.All" ### 🔍 Cypress test results <!-- This is an auto-generated comment: Cypress test results --> > [!TIP] > 🟢 🟢 🟢 All cypress tests have passed! 🎉 🎉 🎉 > Workflow run: <https://github.com/appsmithorg/appsmith/actions/runs/30322009616> > Commit: 6b500e2 > <a href="https://internal.appsmith.com/app/cypress-dashboard/rundetails-65890b3c81d7400d08fa9ee5?branch=master&workflowId=30322009616&attempt=1" target="_blank">Cypress dashboard</a>. > Tags: `@tag.All` > Spec: > <hr>Tue, 28 Jul 2026 03:13:55 UTC <!-- end of auto-generated comment: Cypress test results --> ## Communication Should the DevRel and Marketing teams inform users about this change? - [ ] Yes - [ ] No <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Enhanced anonymous-user analytics controls using the `configure_block_event_tracking_for_anonymous_users` feature flag. * Usage pulse now uses a stable anonymous identifier with resilient local fallback behavior. * **Bug Fixes** * Anonymous event tracking is now correctly suppressed when the flag blocks tracking, including fail-closed behavior when the flag can’t be evaluated. * Improved anonymous detection using the shared anonymous username constant. * **Tests** * Added/extended tests covering anonymous tracking decisions, event suppression, and anonymous identifier fallback persistence. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Wyatt Walter <wyattwalter@gmail.com>
2 parents 3bf3f67 + 6b500e2 commit 1ba63c4

6 files changed

Lines changed: 313 additions & 19 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import type { User } from "constants/userConstants";
2+
import { ANONYMOUS_USERNAME } from "constants/userConstants";
3+
import { shouldTrackUser } from "ee/sagas/userSagas";
4+
5+
const makeUser = (overrides: Partial<User>): User =>
6+
({
7+
isAnonymous: false,
8+
username: "user@example.com",
9+
...overrides,
10+
}) as User;
11+
12+
describe("shouldTrackUser", () => {
13+
it("tracks a non-anonymous user regardless of the block flag", () => {
14+
const user = makeUser({ isAnonymous: false, username: "user@example.com" });
15+
16+
expect(shouldTrackUser(user, false)).toBe(true);
17+
expect(shouldTrackUser(user, true)).toBe(true);
18+
});
19+
20+
it("tracks an anonymous user when telemetry is on and the flag is off", () => {
21+
const user = makeUser({ isAnonymous: true, enableTelemetry: true });
22+
23+
expect(shouldTrackUser(user, false)).toBe(true);
24+
});
25+
26+
it("does not track an anonymous user when the block flag is on (even with telemetry on)", () => {
27+
// Regression guard: previously an active license bypassed the flag here.
28+
const user = makeUser({ isAnonymous: true, enableTelemetry: true });
29+
30+
expect(shouldTrackUser(user, true)).toBe(false);
31+
});
32+
33+
it("does not track an anonymous user when telemetry is off", () => {
34+
const user = makeUser({ isAnonymous: true, enableTelemetry: false });
35+
36+
expect(shouldTrackUser(user, false)).toBe(false);
37+
});
38+
39+
it("treats a user named anonymousUser as anonymous", () => {
40+
const user = makeUser({
41+
isAnonymous: false,
42+
username: ANONYMOUS_USERNAME,
43+
enableTelemetry: true,
44+
});
45+
46+
expect(shouldTrackUser(user, true)).toBe(false);
47+
});
48+
});

app/client/src/ce/sagas/userSagas.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import {
5050
import AnalyticsUtil from "ee/utils/AnalyticsUtil";
5151
import { INVITE_USERS_TO_WORKSPACE_FORM } from "ee/constants/forms";
5252
import type { User } from "constants/userConstants";
53+
import { ANONYMOUS_USERNAME } from "constants/userConstants";
5354
import {
5455
flushErrorsAndRedirect,
5556
safeCrashAppRequest,
@@ -89,7 +90,6 @@ import {
8990
segmentInitUncertain,
9091
} from "actions/analyticsActions";
9192
import { getSegmentState } from "selectors/analyticsSelectors";
92-
import { getOrganizationConfig } from "ee/selectors/organizationSelectors";
9393

9494
export function* getCurrentUserSaga(action?: {
9595
payload?: { userProfile?: ApiResponse };
@@ -152,22 +152,24 @@ function* getSessionRecordingConfig() {
152152
};
153153
}
154154

155-
function shouldTrackUser(
155+
export function shouldTrackUser(
156156
currentUser: User,
157-
licenseActive: boolean,
158157
featureFlag: boolean,
159158
): boolean {
160159
try {
161160
const isAnonymous =
162-
currentUser?.isAnonymous || currentUser?.username === "anonymousUser";
161+
currentUser?.isAnonymous || currentUser?.username === ANONYMOUS_USERNAME;
163162

164163
if (!isAnonymous) {
165164
return true;
166165
}
167166

168167
const telemetryOn = currentUser?.enableTelemetry ?? false;
169168

170-
return isAnonymous && (licenseActive || (telemetryOn && !featureFlag));
169+
// When the block-anonymous-tracking flag is on, never track anonymous
170+
// users — including on licensed instances. Otherwise, track only if
171+
// telemetry is enabled.
172+
return telemetryOn && !featureFlag;
171173
} catch (error) {
172174
return true;
173175
}
@@ -186,11 +188,9 @@ function* initTrackers(currentUser: User): SagaIterator {
186188
);
187189

188190
const featureFlags: FeatureFlags = yield select(selectFeatureFlags);
189-
const organizationConfig = yield select(getOrganizationConfig);
190191

191192
const shouldTrack = shouldTrackUser(
192193
currentUser,
193-
organizationConfig.license.active,
194194
featureFlags.configure_block_event_tracking_for_anonymous_users,
195195
);
196196

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { getUsagePulsePayload } from "./utils";
2+
import AnalyticsUtil from "ee/utils/AnalyticsUtil";
3+
import { FALLBACK_KEY } from "ee/constants/UsagePulse";
4+
import { APP_MODE } from "entities/App";
5+
6+
jest.mock("store", () => ({
7+
__esModule: true,
8+
default: { getState: jest.fn(() => ({})) },
9+
}));
10+
11+
jest.mock("ee/selectors/entitiesSelector", () => ({
12+
getAppMode: jest.fn(() => APP_MODE.EDIT),
13+
}));
14+
15+
jest.mock("ee/utils/AnalyticsUtil", () => ({
16+
__esModule: true,
17+
default: { getAnonymousId: jest.fn() },
18+
}));
19+
20+
const getAnonymousIdMock = AnalyticsUtil.getAnonymousId as jest.Mock;
21+
22+
describe("getUsagePulsePayload", () => {
23+
beforeEach(() => {
24+
localStorage.clear();
25+
getAnonymousIdMock.mockReset();
26+
});
27+
28+
it("does not attach an anonymousUserId for logged-in users", () => {
29+
getAnonymousIdMock.mockReturnValue("segment-id");
30+
31+
const payload = getUsagePulsePayload(true, false);
32+
33+
expect(payload).not.toHaveProperty("anonymousUserId");
34+
});
35+
36+
it("uses Segment's anonymous id when telemetry is enabled and it is available", () => {
37+
getAnonymousIdMock.mockReturnValue("segment-id");
38+
39+
const payload = getUsagePulsePayload(true, true);
40+
41+
expect(payload["anonymousUserId"]).toBe("segment-id");
42+
// Segment id should not be persisted as the local fallback.
43+
expect(localStorage.getItem(FALLBACK_KEY)).toBeNull();
44+
});
45+
46+
// "Unavailable" is defined as null or undefined — the two values
47+
// AnalyticsUtil.getAnonymousId() returns when no Segment user exists.
48+
it.each([undefined, null])(
49+
"falls back to a locally persisted id when telemetry is enabled but Segment's id is %s",
50+
(segmentId) => {
51+
getAnonymousIdMock.mockReturnValue(segmentId);
52+
53+
const payload = getUsagePulsePayload(true, true);
54+
55+
const fallback = localStorage.getItem(FALLBACK_KEY);
56+
57+
expect(fallback).toBeTruthy();
58+
expect(payload["anonymousUserId"]).toBe(fallback);
59+
},
60+
);
61+
62+
it("uses the local fallback id when telemetry is disabled", () => {
63+
getAnonymousIdMock.mockReturnValue("segment-id");
64+
65+
const payload = getUsagePulsePayload(false, true);
66+
67+
const fallback = localStorage.getItem(FALLBACK_KEY);
68+
69+
expect(fallback).toBeTruthy();
70+
expect(payload["anonymousUserId"]).toBe(fallback);
71+
// Segment's id must not be used on the telemetry-disabled path.
72+
expect(payload["anonymousUserId"]).not.toBe("segment-id");
73+
// getAnonymousId is Segment-coupled and should not be consulted here.
74+
expect(getAnonymousIdMock).not.toHaveBeenCalled();
75+
});
76+
77+
it("still returns an id when localStorage is unavailable", () => {
78+
getAnonymousIdMock.mockReturnValue(undefined);
79+
80+
const setItemSpy = jest
81+
.spyOn(Storage.prototype, "setItem")
82+
.mockImplementation(() => {
83+
throw new Error("QuotaExceededError");
84+
});
85+
86+
try {
87+
const payload = getUsagePulsePayload(true, true);
88+
89+
expect(payload["anonymousUserId"]).toBeTruthy();
90+
} finally {
91+
setItemSpy.mockRestore();
92+
}
93+
});
94+
95+
it("reuses the same fallback id across pulses", () => {
96+
getAnonymousIdMock.mockReturnValue(undefined);
97+
98+
const first = getUsagePulsePayload(true, true);
99+
const second = getUsagePulsePayload(false, true);
100+
101+
expect(first["anonymousUserId"]).toBe(second["anonymousUserId"]);
102+
});
103+
});

app/client/src/usagePulse/utils.ts

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,31 @@ export const fetchWithRetry = (config: {
4949
});
5050
};
5151

52+
/*
53+
* Returns usage-pulse's own anonymous id, independent of Segment analytics.
54+
* Persisted locally so the pulse keeps a stable id even when Segment is
55+
* unavailable or intentionally blocked for anonymous users.
56+
*/
57+
const getOrCreateFallbackAnonymousId = (): string => {
58+
try {
59+
let fallback = localStorage.getItem(FALLBACK_KEY);
60+
61+
if (!fallback) {
62+
fallback = nanoid();
63+
localStorage.setItem(FALLBACK_KEY, fallback);
64+
}
65+
66+
return fallback;
67+
} catch {
68+
/*
69+
* localStorage can throw when it is unavailable (private mode, quota
70+
* exceeded, or storage disabled). Return a non-persisted per-call id so
71+
* the pulse still carries an anonymousUserId instead of failing.
72+
*/
73+
return nanoid();
74+
}
75+
};
76+
5277
export const getUsagePulsePayload = (
5378
isTelemetryEnabled: boolean,
5479
isAnonymousUser: boolean,
@@ -67,16 +92,15 @@ export const getUsagePulsePayload = (
6792

6893
if (isAnonymousUser) {
6994
if (isTelemetryEnabled) {
70-
data["anonymousUserId"] = AnalyticsUtil.getAnonymousId();
95+
/*
96+
* Prefer Segment's anonymous id when available, but fall back to the
97+
* locally persisted id when it is unavailable (e.g. Segment blocked for
98+
* anonymous users) so the pulse always carries an anonymousUserId.
99+
*/
100+
data["anonymousUserId"] =
101+
AnalyticsUtil.getAnonymousId() ?? getOrCreateFallbackAnonymousId();
71102
} else {
72-
let fallback = localStorage.getItem(FALLBACK_KEY);
73-
74-
if (!fallback) {
75-
fallback = nanoid();
76-
localStorage.setItem(FALLBACK_KEY, fallback);
77-
}
78-
79-
data["anonymousUserId"] = fallback;
103+
data["anonymousUserId"] = getOrCreateFallbackAnonymousId();
80104
}
81105
}
82106

app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/AnalyticsServiceCEImpl.java

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,35 @@ public Mono<Void> sendEvent(String event, String userId, Map<String, ?> properti
206206
return Mono.empty();
207207
}
208208

209+
// If the event is for an anonymous user, respect the
210+
// configure_block_event_tracking_for_anonymous_users feature flag. sendObjectEvent applies the same
211+
// check upstream on the session user; gating here additionally covers direct sendEvent callers that
212+
// pass an anonymous userId.
213+
if (FieldName.ANONYMOUS_USER.equals(userId)) {
214+
return featureFlagService
215+
.check(FeatureFlagEnum.configure_block_event_tracking_for_anonymous_users)
216+
// Fail closed: if the flag state can't be resolved, drop the anonymous event rather than
217+
// erroring the caller's chain (analytics is fire-and-forget for direct callers).
218+
.onErrorResume(error -> {
219+
log.warn(
220+
"Could not resolve the block-anonymous-tracking flag; dropping anonymous event {}",
221+
event,
222+
error);
223+
return Mono.just(Boolean.TRUE);
224+
})
225+
.flatMap(isBlocked -> {
226+
if (isBlocked) {
227+
log.debug("Analytics event {} is not sent for anonymous user", event);
228+
return Mono.empty();
229+
}
230+
return sendEventInternal(event, userId, properties, hashUserId);
231+
});
232+
}
233+
234+
return sendEventInternal(event, userId, properties, hashUserId);
235+
}
236+
237+
private Mono<Void> sendEventInternal(String event, String userId, Map<String, ?> properties, boolean hashUserId) {
209238
// Can't update the properties directly as it's throwing ImmutableCollection error
210239
// java.lang.UnsupportedOperationException: null
211240
// at java.base/java.util.ImmutableCollections.uoe(ImmutableCollections.java)
@@ -332,8 +361,17 @@ public <T> Mono<T> sendObjectEvent(AnalyticsEvents event, T object, Map<String,
332361
if (user.isAnonymous()) {
333362
return featureFlagService
334363
.check(FeatureFlagEnum.configure_block_event_tracking_for_anonymous_users)
335-
.flatMap(isDisabled -> {
336-
if (isDisabled) {
364+
// Fail closed: if the flag state can't be resolved, drop the anonymous event rather
365+
// than erroring the business flow this analytics call is chained into.
366+
.onErrorResume(error -> {
367+
log.warn(
368+
"Could not resolve the block-anonymous-tracking flag; dropping anonymous event {}",
369+
eventTag,
370+
error);
371+
return Mono.just(Boolean.TRUE);
372+
})
373+
.flatMap(isBlocked -> {
374+
if (isBlocked) {
337375
log.debug("Analytics event {} is not sent for anonymous user", eventTag);
338376
return Mono.empty();
339377
} else {
@@ -388,7 +426,9 @@ public <T> Mono<T> sendObjectEvent(AnalyticsEvents event, T object, Map<String,
388426
analyticsProperties.remove(FieldName.CLOUD_HOSTED_EXTRA_PROPS);
389427
}
390428

391-
return sendEvent(eventTag, username, analyticsProperties);
429+
// The anonymous-user flag was already evaluated above for this session user, so route
430+
// straight to sendEventInternal to avoid re-checking the (Redis-backed) flag on this hot path.
431+
return sendEventInternal(eventTag, username, analyticsProperties, true);
392432
})
393433
// Return the original object after sending the event
394434
.then(Mono.just(object));

0 commit comments

Comments
 (0)