Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions app/client/src/ce/sagas/userSagas.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import type { User } from "constants/userConstants";
import { ANONYMOUS_USERNAME } from "constants/userConstants";
import { shouldTrackUser } from "ee/sagas/userSagas";

const makeUser = (overrides: Partial<User>): User =>
({
isAnonymous: false,
username: "user@example.com",
...overrides,
}) as User;

describe("shouldTrackUser", () => {
it("tracks a non-anonymous user regardless of the block flag", () => {
const user = makeUser({ isAnonymous: false, username: "user@example.com" });

expect(shouldTrackUser(user, false)).toBe(true);
expect(shouldTrackUser(user, true)).toBe(true);
});

it("tracks an anonymous user when telemetry is on and the flag is off", () => {
const user = makeUser({ isAnonymous: true, enableTelemetry: true });

expect(shouldTrackUser(user, false)).toBe(true);
});

it("does not track an anonymous user when the block flag is on (even with telemetry on)", () => {
// Regression guard: previously an active license bypassed the flag here.
const user = makeUser({ isAnonymous: true, enableTelemetry: true });

expect(shouldTrackUser(user, true)).toBe(false);
});

it("does not track an anonymous user when telemetry is off", () => {
const user = makeUser({ isAnonymous: true, enableTelemetry: false });

expect(shouldTrackUser(user, false)).toBe(false);
});

it("treats a user named anonymousUser as anonymous", () => {
const user = makeUser({
isAnonymous: false,
username: ANONYMOUS_USERNAME,
enableTelemetry: true,
});

expect(shouldTrackUser(user, true)).toBe(false);
});
});
14 changes: 7 additions & 7 deletions app/client/src/ce/sagas/userSagas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
import AnalyticsUtil from "ee/utils/AnalyticsUtil";
import { INVITE_USERS_TO_WORKSPACE_FORM } from "ee/constants/forms";
import type { User } from "constants/userConstants";
import { ANONYMOUS_USERNAME } from "constants/userConstants";
import {
flushErrorsAndRedirect,
safeCrashAppRequest,
Expand Down Expand Up @@ -89,7 +90,6 @@ import {
segmentInitUncertain,
} from "actions/analyticsActions";
import { getSegmentState } from "selectors/analyticsSelectors";
import { getOrganizationConfig } from "ee/selectors/organizationSelectors";

export function* getCurrentUserSaga(action?: {
payload?: { userProfile?: ApiResponse };
Expand Down Expand Up @@ -152,22 +152,24 @@ function* getSessionRecordingConfig() {
};
}

function shouldTrackUser(
export function shouldTrackUser(
currentUser: User,
licenseActive: boolean,
featureFlag: boolean,
): boolean {
try {
const isAnonymous =
currentUser?.isAnonymous || currentUser?.username === "anonymousUser";
currentUser?.isAnonymous || currentUser?.username === ANONYMOUS_USERNAME;

if (!isAnonymous) {
return true;
}

const telemetryOn = currentUser?.enableTelemetry ?? false;

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

const featureFlags: FeatureFlags = yield select(selectFeatureFlags);
const organizationConfig = yield select(getOrganizationConfig);

const shouldTrack = shouldTrackUser(
currentUser,
organizationConfig.license.active,
featureFlags.configure_block_event_tracking_for_anonymous_users,
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,35 @@ public Mono<Void> sendEvent(String event, String userId, Map<String, ?> properti
return Mono.empty();
}

// If the event is for an anonymous user, respect the
// configure_block_event_tracking_for_anonymous_users feature flag. sendObjectEvent applies the same
// check upstream on the session user; gating here additionally covers direct sendEvent callers that
// pass an anonymous userId.
if (FieldName.ANONYMOUS_USER.equals(userId)) {
return featureFlagService
.check(FeatureFlagEnum.configure_block_event_tracking_for_anonymous_users)
// Fail closed: if the flag state can't be resolved, drop the anonymous event rather than
// erroring the caller's chain (analytics is fire-and-forget for direct callers).
.onErrorResume(error -> {
log.warn(
"Could not resolve the block-anonymous-tracking flag; dropping anonymous event {}",
event,
error);
return Mono.just(Boolean.TRUE);
})
.flatMap(isBlocked -> {
if (isBlocked) {
log.debug("Analytics event {} is not sent for anonymous user", event);
return Mono.empty();
}
return sendEventInternal(event, userId, properties, hashUserId);
});
}

return sendEventInternal(event, userId, properties, hashUserId);
}

private Mono<Void> sendEventInternal(String event, String userId, Map<String, ?> properties, boolean hashUserId) {
// Can't update the properties directly as it's throwing ImmutableCollection error
// java.lang.UnsupportedOperationException: null
// at java.base/java.util.ImmutableCollections.uoe(ImmutableCollections.java)
Expand Down Expand Up @@ -332,8 +361,8 @@ public <T> Mono<T> sendObjectEvent(AnalyticsEvents event, T object, Map<String,
if (user.isAnonymous()) {
return featureFlagService
.check(FeatureFlagEnum.configure_block_event_tracking_for_anonymous_users)
.flatMap(isDisabled -> {
if (isDisabled) {
.flatMap(isBlocked -> {
if (isBlocked) {
log.debug("Analytics event {} is not sent for anonymous user", eventTag);
return Mono.empty();
} else {
Expand Down Expand Up @@ -388,7 +417,9 @@ public <T> Mono<T> sendObjectEvent(AnalyticsEvents event, T object, Map<String,
analyticsProperties.remove(FieldName.CLOUD_HOSTED_EXTRA_PROPS);
}

return sendEvent(eventTag, username, analyticsProperties);
// The anonymous-user flag was already evaluated above for this session user, so route
// straight to sendEventInternal to avoid re-checking the (Redis-backed) flag on this hot path.
return sendEventInternal(eventTag, username, analyticsProperties, true);
})
// Return the original object after sending the event
.then(Mono.just(object));
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
package com.appsmith.server.services.ce;

import com.appsmith.external.enums.FeatureFlagEnum;
import com.appsmith.server.services.FeatureFlagService;
import com.segment.analytics.Analytics;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;

import java.util.Map;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

public class AnalyticsServiceCEImplTest {
@Test
Expand All @@ -25,4 +37,42 @@ void shouldHashUserId_anonymousUserIdCloud_shouldReturnFalse() {
AnalyticsServiceCEImpl.shouldHashUserId("execute_ACTION_TRIGGERED", "anonymousUser", true, true);
assertEquals(false, shouldHash);
}

// Gap 2 regression guard: a direct sendEvent call for an anonymous user must be blocked when
// configure_block_event_tracking_for_anonymous_users is on, so no event is enqueued to Segment.
@Test
void sendEvent_anonymousUserWithBlockFlagOn_doesNotEnqueueEvent() {
Analytics analytics = mock(Analytics.class);
FeatureFlagService featureFlagService = mock(FeatureFlagService.class);
when(featureFlagService.check(FeatureFlagEnum.configure_block_event_tracking_for_anonymous_users))
.thenReturn(Mono.just(true));

// Only `analytics` (for isActive()) and `featureFlagService` (for the gate) are exercised on this
// path; the remaining collaborators are unused because the flag short-circuits before they are read.
AnalyticsServiceCEImpl analyticsService =
new AnalyticsServiceCEImpl(analytics, null, null, null, null, null, null, null, featureFlagService);

StepVerifier.create(analyticsService.sendEvent("execute_ACTION_TRIGGERED", "anonymousUser", Map.of("id", "x")))
.verifyComplete();

verify(analytics, never()).enqueue(any());
}

// If the flag state can't be resolved, the gate must fail closed: complete without error (so
// fire-and-forget callers' chains don't break) and drop the anonymous event.
@Test
void sendEvent_anonymousUserWhenFlagCheckErrors_completesWithoutEnqueueing() {
Analytics analytics = mock(Analytics.class);
FeatureFlagService featureFlagService = mock(FeatureFlagService.class);
when(featureFlagService.check(FeatureFlagEnum.configure_block_event_tracking_for_anonymous_users))
.thenReturn(Mono.error(new RuntimeException("flag service unavailable")));

AnalyticsServiceCEImpl analyticsService =
new AnalyticsServiceCEImpl(analytics, null, null, null, null, null, null, null, featureFlagService);

StepVerifier.create(analyticsService.sendEvent("execute_ACTION_TRIGGERED", "anonymousUser", Map.of("id", "x")))
.verifyComplete();

verify(analytics, never()).enqueue(any());
}
}
Loading