Skip to content
Merged
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
103 changes: 103 additions & 0 deletions app/client/src/usagePulse/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { getUsagePulsePayload } from "./utils";
import AnalyticsUtil from "ee/utils/AnalyticsUtil";
import { FALLBACK_KEY } from "ee/constants/UsagePulse";
import { APP_MODE } from "entities/App";

jest.mock("store", () => ({
__esModule: true,
default: { getState: jest.fn(() => ({})) },
}));

jest.mock("ee/selectors/entitiesSelector", () => ({
getAppMode: jest.fn(() => APP_MODE.EDIT),
}));

jest.mock("ee/utils/AnalyticsUtil", () => ({
__esModule: true,
default: { getAnonymousId: jest.fn() },
}));

const getAnonymousIdMock = AnalyticsUtil.getAnonymousId as jest.Mock;

describe("getUsagePulsePayload", () => {
beforeEach(() => {
localStorage.clear();
getAnonymousIdMock.mockReset();
});

it("does not attach an anonymousUserId for logged-in users", () => {
getAnonymousIdMock.mockReturnValue("segment-id");

const payload = getUsagePulsePayload(true, false);

expect(payload).not.toHaveProperty("anonymousUserId");
});

it("uses Segment's anonymous id when telemetry is enabled and it is available", () => {
getAnonymousIdMock.mockReturnValue("segment-id");

const payload = getUsagePulsePayload(true, true);

expect(payload["anonymousUserId"]).toBe("segment-id");
// Segment id should not be persisted as the local fallback.
expect(localStorage.getItem(FALLBACK_KEY)).toBeNull();
});

// "Unavailable" is defined as null or undefined — the two values
// AnalyticsUtil.getAnonymousId() returns when no Segment user exists.
it.each([undefined, null])(
"falls back to a locally persisted id when telemetry is enabled but Segment's id is %s",
(segmentId) => {
getAnonymousIdMock.mockReturnValue(segmentId);

const payload = getUsagePulsePayload(true, true);

const fallback = localStorage.getItem(FALLBACK_KEY);

expect(fallback).toBeTruthy();
expect(payload["anonymousUserId"]).toBe(fallback);
},
);

it("uses the local fallback id when telemetry is disabled", () => {
getAnonymousIdMock.mockReturnValue("segment-id");

const payload = getUsagePulsePayload(false, true);

const fallback = localStorage.getItem(FALLBACK_KEY);

expect(fallback).toBeTruthy();
expect(payload["anonymousUserId"]).toBe(fallback);
// Segment's id must not be used on the telemetry-disabled path.
expect(payload["anonymousUserId"]).not.toBe("segment-id");
// getAnonymousId is Segment-coupled and should not be consulted here.
expect(getAnonymousIdMock).not.toHaveBeenCalled();
});

it("still returns an id when localStorage is unavailable", () => {
getAnonymousIdMock.mockReturnValue(undefined);

const setItemSpy = jest
.spyOn(Storage.prototype, "setItem")
.mockImplementation(() => {
throw new Error("QuotaExceededError");
});

try {
const payload = getUsagePulsePayload(true, true);

expect(payload["anonymousUserId"]).toBeTruthy();
} finally {
setItemSpy.mockRestore();
}
});

it("reuses the same fallback id across pulses", () => {
getAnonymousIdMock.mockReturnValue(undefined);

const first = getUsagePulsePayload(true, true);
const second = getUsagePulsePayload(false, true);

expect(first["anonymousUserId"]).toBe(second["anonymousUserId"]);
});
});
42 changes: 33 additions & 9 deletions app/client/src/usagePulse/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,31 @@ export const fetchWithRetry = (config: {
});
};

/*
* Returns usage-pulse's own anonymous id, independent of Segment analytics.
* Persisted locally so the pulse keeps a stable id even when Segment is
* unavailable or intentionally blocked for anonymous users.
*/
const getOrCreateFallbackAnonymousId = (): string => {
try {
let fallback = localStorage.getItem(FALLBACK_KEY);

if (!fallback) {
fallback = nanoid();
localStorage.setItem(FALLBACK_KEY, fallback);
}

return fallback;
} catch {
/*
* localStorage can throw when it is unavailable (private mode, quota
* exceeded, or storage disabled). Return a non-persisted per-call id so
* the pulse still carries an anonymousUserId instead of failing.
*/
return nanoid();
}
};

export const getUsagePulsePayload = (
isTelemetryEnabled: boolean,
isAnonymousUser: boolean,
Expand All @@ -67,16 +92,15 @@ export const getUsagePulsePayload = (

if (isAnonymousUser) {
if (isTelemetryEnabled) {
data["anonymousUserId"] = AnalyticsUtil.getAnonymousId();
/*
* Prefer Segment's anonymous id when available, but fall back to the
* locally persisted id when it is unavailable (e.g. Segment blocked for
* anonymous users) so the pulse always carries an anonymousUserId.
*/
data["anonymousUserId"] =
AnalyticsUtil.getAnonymousId() ?? getOrCreateFallbackAnonymousId();
} else {
let fallback = localStorage.getItem(FALLBACK_KEY);

if (!fallback) {
fallback = nanoid();
localStorage.setItem(FALLBACK_KEY, fallback);
}

data["anonymousUserId"] = fallback;
data["anonymousUserId"] = getOrCreateFallbackAnonymousId();
}
}

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,17 @@ 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) {
// Fail closed: if the flag state can't be resolved, drop the anonymous event rather
// than erroring the business flow this analytics call is chained into.
.onErrorResume(error -> {
log.warn(
"Could not resolve the block-anonymous-tracking flag; dropping anonymous event {}",
eventTag,
error);
return Mono.just(Boolean.TRUE);
})
.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 +426,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
Loading
Loading