fix(app): gate single-source message hover behind an experiment (#2761)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-14 15:37:37 +02:00
1 parent 69c42cff90
commit 1281045648
30 files changed
+2107 -360

No files matched your search

@@ -23,6 +23,10 @@ import {
ExperimentDeliveryConfigResponse,
ExperimentDeliveryConfigUpdateRequest,
} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
import {
MessageHoverTrackingConfigResponse,
MessageHoverTrackingConfigUpdateRequest,
} from '@fluxer/schema/src/domains/experiment/MessageHoverTrackingSchemas';
import {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
import {
InstanceCaptchaProviderSchema,
@@ -630,6 +634,7 @@ export const InstanceConfigResponse = z.object({
gateway_rollout: GatewayRolloutConfigResponse,
voice_noise_suppression: VoiceNoiseSuppressionConfigResponse,
experiment_delivery: ExperimentDeliveryConfigResponse,
message_hover_tracking: MessageHoverTrackingConfigResponse,
registration: InstanceRegistrationResponse,
self_hosted: z.boolean(),
app_public: AppPublicConfigResponse,
@@ -666,6 +671,7 @@ export const InstanceConfigUpdateRequest = z.object({
gateway_rollout: GatewayRolloutConfigUpdateRequest.nullish(),
voice_noise_suppression: VoiceNoiseSuppressionConfigUpdateRequest.nullish(),
experiment_delivery: ExperimentDeliveryConfigUpdateRequest.nullish(),
message_hover_tracking: MessageHoverTrackingConfigUpdateRequest.nullish(),
registration: z
.object({
mode: InstanceRegistrationModeSchema.optional(),
@@ -0,0 +1,59 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {EXPERIMENT_BUCKET_RESOLUTION, experimentBucket} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
import {z} from 'zod';
const CLIENT_BEHAVIOR_MAX_TARGETED_USERS = 1000;
const CLIENT_BEHAVIOR_ASSIGNMENT_SOURCES = ['user_rule', 'canary'] as const;
export function createClientBehaviorExperiment({defaultRolloutSalt}: {defaultRolloutSalt: string}) {
const targetIdSchema = z.string().regex(/^\d{1,20}$/u);
const targetedUserIdsSchema = z.array(targetIdSchema).max(CLIENT_BEHAVIOR_MAX_TARGETED_USERS);
const configFields = {
enabled: z.boolean(),
config_version: z.number().int().min(0),
rollout_basis_points: z.number().int().min(0).max(EXPERIMENT_BUCKET_RESOLUTION),
rollout_salt: z.string().trim().min(1).max(64),
included_user_ids: targetedUserIdsSchema,
excluded_user_ids: targetedUserIdsSchema,
};
const ConfigSchema = z.object({
enabled: configFields.enabled.default(false),
config_version: configFields.config_version.default(0),
rollout_basis_points: configFields.rollout_basis_points.default(0),
rollout_salt: configFields.rollout_salt.default(defaultRolloutSalt),
included_user_ids: configFields.included_user_ids.default([]),
excluded_user_ids: configFields.excluded_user_ids.default([]),
});
const ConfigUpdateRequest = z.object(configFields).omit({config_version: true}).partial();
const AssignmentResponse = z.object({
enabled: configFields.enabled,
config_version: z.number().int(),
user_targeted: z.boolean(),
source: z.enum(CLIENT_BEHAVIOR_ASSIGNMENT_SOURCES).nullable(),
});
type Config = z.infer<typeof ConfigSchema>;
type Assignment = z.infer<typeof AssignmentResponse>;
const DEFAULT_CONFIG: Config = ConfigSchema.parse({});
const INERT_ASSIGNMENT: Assignment = {
enabled: false,
config_version: 0,
user_targeted: false,
source: null,
};
const resolveAssignment = (config: Config, userId: string): Assignment => {
if (!config.enabled) {
return {...INERT_ASSIGNMENT, config_version: config.config_version};
}
const shared = {enabled: true, config_version: config.config_version};
if (config.excluded_user_ids.includes(userId)) {
return {...shared, user_targeted: false, source: null};
}
if (config.included_user_ids.includes(userId)) {
return {...shared, user_targeted: true, source: 'user_rule'};
}
const inCanary = experimentBucket(userId, config.rollout_salt) < config.rollout_basis_points;
return {...shared, user_targeted: inCanary, source: inCanary ? 'canary' : null};
};
return {ConfigSchema, ConfigUpdateRequest, AssignmentResponse, DEFAULT_CONFIG, INERT_ASSIGNMENT, resolveAssignment};
}
@@ -4,6 +4,10 @@ import {
INERT_VOICE_NOISE_SUPPRESSION_ASSIGNMENT,
VoiceNoiseSuppressionAssignmentResponse,
} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
import {
INERT_MESSAGE_HOVER_TRACKING_ASSIGNMENT,
MessageHoverTrackingAssignmentResponse,
} from '@fluxer/schema/src/domains/experiment/MessageHoverTrackingSchemas';
import {z} from 'zod';
export const EXPERIMENT_MIN_POLL_INTERVAL_SECONDS = 60;
@@ -42,6 +46,7 @@ export type ExperimentDeliveryConfigResponse = z.infer<typeof ExperimentDelivery
const ExperimentAssignmentsSchema = z.object({
voice_noise_suppression: VoiceNoiseSuppressionAssignmentResponse.optional(),
message_hover_tracking: MessageHoverTrackingAssignmentResponse.optional(),
});
export const ExperimentAssignmentsResponse = z.object({
@@ -63,3 +68,9 @@ export function readVoiceNoiseSuppressionAssignment(
): VoiceNoiseSuppressionAssignmentResponse {
return response.assignments.voice_noise_suppression ?? INERT_VOICE_NOISE_SUPPRESSION_ASSIGNMENT;
}
export function readMessageHoverTrackingAssignment(
response: ExperimentAssignmentsResponse,
): MessageHoverTrackingAssignmentResponse {
return response.assignments.message_hover_tracking ?? INERT_MESSAGE_HOVER_TRACKING_ASSIGNMENT;
}
@@ -0,0 +1,34 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createClientBehaviorExperiment} from '@fluxer/schema/src/domains/experiment/ClientBehaviorExperiment';
import type {z} from 'zod';
const messageHoverTracking = createClientBehaviorExperiment({defaultRolloutSalt: 'message-hover-tracking-v1'});
export const MessageHoverTrackingConfigSchema = messageHoverTracking.ConfigSchema;
export type MessageHoverTrackingConfig = z.infer<typeof MessageHoverTrackingConfigSchema>;
export const DEFAULT_MESSAGE_HOVER_TRACKING_CONFIG: MessageHoverTrackingConfig = messageHoverTracking.DEFAULT_CONFIG;
export const MessageHoverTrackingConfigUpdateRequest = messageHoverTracking.ConfigUpdateRequest;
export type MessageHoverTrackingConfigUpdateRequest = z.infer<typeof MessageHoverTrackingConfigUpdateRequest>;
export const MessageHoverTrackingConfigResponse = messageHoverTracking.ConfigSchema;
export type MessageHoverTrackingConfigResponse = MessageHoverTrackingConfig;
export const MessageHoverTrackingAssignmentResponse = messageHoverTracking.AssignmentResponse;
export type MessageHoverTrackingAssignmentResponse = z.infer<typeof MessageHoverTrackingAssignmentResponse>;
export const INERT_MESSAGE_HOVER_TRACKING_ASSIGNMENT: MessageHoverTrackingAssignmentResponse =
messageHoverTracking.INERT_ASSIGNMENT;
export function resolveMessageHoverTrackingAssignment(
config: MessageHoverTrackingConfig,
userId: string,
): MessageHoverTrackingAssignmentResponse {
return messageHoverTracking.resolveAssignment(config, userId);
}
@@ -0,0 +1,98 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {EXPERIMENT_BUCKET_RESOLUTION, experimentBucket} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
import {
DEFAULT_MESSAGE_HOVER_TRACKING_CONFIG,
MessageHoverTrackingConfigSchema,
resolveMessageHoverTrackingAssignment,
} from '@fluxer/schema/src/domains/experiment/MessageHoverTrackingSchemas';
import {describe, expect, it} from 'vitest';
const USER_ID = '1485064866382176262';
const OTHER_USER_ID = '1485064866382176263';
function config(overrides: Partial<ReturnType<typeof MessageHoverTrackingConfigSchema.parse>> = {}) {
return MessageHoverTrackingConfigSchema.parse({...overrides});
}
describe('message hover tracking configuration', () => {
it('defaults to a disabled rollout that targets nobody', () => {
expect(DEFAULT_MESSAGE_HOVER_TRACKING_CONFIG).toEqual({
enabled: false,
config_version: 0,
rollout_basis_points: 0,
rollout_salt: 'message-hover-tracking-v1',
included_user_ids: [],
excluded_user_ids: [],
});
});
it('refuses a rollout wider than the bucket resolution', () => {
expect(() => config({rollout_basis_points: EXPERIMENT_BUCKET_RESOLUTION + 1})).toThrow();
});
it('refuses a target that is not a snowflake', () => {
expect(() => config({included_user_ids: ['not-a-snowflake']})).toThrow();
});
});
describe('message hover tracking resolution', () => {
it('reports the stored revision while the rollout is disabled', () => {
const assignment = resolveMessageHoverTrackingAssignment(config({config_version: 3}), USER_ID);
expect(assignment).toEqual({enabled: false, config_version: 3, user_targeted: false, source: null});
});
it('targets an allowlisted account regardless of its bucket', () => {
const assignment = resolveMessageHoverTrackingAssignment(
config({enabled: true, included_user_ids: [USER_ID]}),
USER_ID,
);
expect(assignment).toEqual({enabled: true, config_version: 0, user_targeted: true, source: 'user_rule'});
});
it('keeps a blocklisted account out of a full rollout', () => {
const assignment = resolveMessageHoverTrackingAssignment(
config({
enabled: true,
rollout_basis_points: EXPERIMENT_BUCKET_RESOLUTION,
excluded_user_ids: [USER_ID],
}),
USER_ID,
);
expect(assignment).toEqual({enabled: true, config_version: 0, user_targeted: false, source: null});
});
it('prefers the blocklist over the allowlist', () => {
const assignment = resolveMessageHoverTrackingAssignment(
config({enabled: true, included_user_ids: [USER_ID], excluded_user_ids: [USER_ID]}),
USER_ID,
);
expect(assignment.user_targeted).toBe(false);
});
it('targets an account whose bucket falls inside the rollout', () => {
const salt = DEFAULT_MESSAGE_HOVER_TRACKING_CONFIG.rollout_salt;
const bucket = experimentBucket(USER_ID, salt);
const inside = resolveMessageHoverTrackingAssignment(
config({enabled: true, rollout_basis_points: bucket + 1}),
USER_ID,
);
const outside = resolveMessageHoverTrackingAssignment(
config({enabled: true, rollout_basis_points: bucket}),
USER_ID,
);
expect(inside).toEqual({enabled: true, config_version: 0, user_targeted: true, source: 'canary'});
expect(outside).toEqual({enabled: true, config_version: 0, user_targeted: false, source: null});
});
it('separates two accounts under the same salt', () => {
const salt = DEFAULT_MESSAGE_HOVER_TRACKING_CONFIG.rollout_salt;
expect(experimentBucket(USER_ID, salt)).not.toBe(experimentBucket(OTHER_USER_ID, salt));
});
it('moves an account when the salt changes', () => {
expect(experimentBucket(USER_ID, 'message-hover-tracking-v1')).not.toBe(
experimentBucket(USER_ID, 'message-hover-tracking-v2'),
);
});
});