mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
fix(app): gate single-source message hover behind an experiment (#2761)
This commit is contained in:
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'),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user