add campaign jitter

Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
Ronni Skansing
2025-11-17 22:21:12 +01:00
parent 64479c4669
commit b072eb7b1b
5 changed files with 276 additions and 16 deletions
+37
View File
@@ -31,6 +31,10 @@ type Campaign struct {
ConstraintStartTime nullable.Nullable[vo.CampaignTimeConstraint] `json:"constraintStartTime"`
ConstraintEndTime nullable.Nullable[vo.CampaignTimeConstraint] `json:"constraintEndTime"`
// jitter is used only during scheduling, not persisted to database
JitterMin nullable.Nullable[int] `json:"jitterMin,omitempty"`
JitterMax nullable.Nullable[int] `json:"jitterMax,omitempty"`
Name nullable.Nullable[vo.String64] `json:"name"`
SaveSubmittedData nullable.Nullable[bool] `json:"saveSubmittedData"`
@@ -166,6 +170,39 @@ func (c *Campaign) Validate() error {
}
}
}
// validate jitter values if specified
// negative values are allowed for asymmetric jitter (e.g., -10 to 20 means send 10 min early to 20 min late)
if c.JitterMin.IsSpecified() && !c.JitterMin.IsNull() && c.JitterMax.IsSpecified() && !c.JitterMax.IsNull() {
jitterMin := c.JitterMin.MustGet()
jitterMax := c.JitterMax.MustGet()
if jitterMax < jitterMin {
return validate.WrapErrorWithField(errors.New("jitter max must be greater than or equal to jitter min"), "jitterMax")
}
// validate jitter doesn't exceed campaign duration
if c.SendStartAt.IsSpecified() && !c.SendStartAt.IsNull() && c.SendEndAt.IsSpecified() && !c.SendEndAt.IsNull() {
startAt := c.SendStartAt.MustGet()
endAt := c.SendEndAt.MustGet()
campaignDuration := endAt.Sub(startAt)
// check if max jitter (positive) could push beyond end time
maxJitterDuration := time.Duration(jitterMax) * time.Minute
if maxJitterDuration > campaignDuration {
return validate.WrapErrorWithField(
errors.New("jitter max cannot exceed campaign duration"),
"jitterMax",
)
}
// check if min jitter (negative) could push before start time
minJitterDuration := time.Duration(jitterMin) * time.Minute
if minJitterDuration < -campaignDuration {
return validate.WrapErrorWithField(
errors.New("jitter min cannot exceed campaign duration"),
"jitterMin",
)
}
}
}
// must not be set from api consumers
if c.NotableEventID.IsSpecified() && !c.NotableEventID.IsNull() {
c.NotableEventID.SetNull()
+70
View File
@@ -167,6 +167,9 @@ func (c *Campaign) Create(
c.Logger.Errorw("failed to get campaign by id", "error", err)
return nil, errs.Wrap(err)
}
// preserve jitter values from original campaign (not persisted to db)
createdCampaign.JitterMin = campaign.JitterMin
createdCampaign.JitterMax = campaign.JitterMax
err = c.schedule(ctx, session, createdCampaign)
if err != nil {
c.Logger.Errorw("failed to schedule campaign", "error", err)
@@ -180,6 +183,49 @@ func (c *Campaign) Create(
// schedule campaign schedules the campaign
// this is a service method that does not perform auth, use with consideration
// applyJitter applies random jitter to a time based on jitter min/max in minutes
// if min is negative of max (symmetric), randomly applies as positive or negative
// e.g., min=-10, max=10 means randomly offset by 0-10 minutes, then randomly early or late
// clamps the result to stay within startBound and endBound
func applyJitter(baseTime time.Time, jitterMin, jitterMax int, startBound, endBound time.Time) time.Time {
if jitterMin == 0 && jitterMax == 0 {
return baseTime
}
var randomJitter int
// check if symmetric jitter (min = -max)
if jitterMin == -jitterMax {
// symmetric: pick random magnitude, then randomly apply as positive or negative
magnitude := rand.Intn(jitterMax + 1)
if rand.Intn(2) == 0 {
randomJitter = -magnitude
} else {
randomJitter = magnitude
}
} else {
// asymmetric: generate random jitter between min and max
jitterRange := jitterMax - jitterMin
if jitterRange > 0 {
randomJitter = jitterMin + rand.Intn(jitterRange+1)
} else {
randomJitter = jitterMin
}
}
jitteredTime := baseTime.Add(time.Duration(randomJitter) * time.Minute)
// clamp to campaign bounds
if jitteredTime.Before(startBound) {
return startBound
}
if jitteredTime.After(endBound) {
return endBound
}
return jitteredTime
}
func (c *Campaign) schedule(
ctx context.Context,
session *model.Session,
@@ -329,10 +375,27 @@ func (c *Campaign) schedule(
return fmt.Errorf("no recipients to schedule for '%s'", campaign.Name.MustGet())
}
scheduledEvent := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_SCHEDULED]
// get jitter values if specified
jitterMin := 0
jitterMax := 0
if campaign.JitterMin.IsSpecified() && !campaign.JitterMin.IsNull() {
jitterMin = campaign.JitterMin.MustGet()
}
if campaign.JitterMax.IsSpecified() && !campaign.JitterMax.IsNull() {
jitterMax = campaign.JitterMax.MustGet()
}
c.Logger.Debugw("scheduling campaign with jitter",
"campaignName", campaign.Name.MustGet(),
"jitterMin", jitterMin,
"jitterMax", jitterMax,
)
// handle single recipient
if recipientsCount == 1 {
recpID := nullable.NewNullableWithValue(recipients[0].ID.MustGet())
campaignID := nullable.NewNullableWithValue(campaign.ID.MustGet())
startAt = applyJitter(startAt, jitterMin, jitterMax, startAt, endAt)
campaignRecipient := &model.CampaignRecipient{
RecipientID: recpID,
CampaignID: campaignID,
@@ -406,6 +469,8 @@ func (c *Campaign) schedule(
// get the next recipient
recipient := recipients[0]
recipients = recipients[1:]
// apply jitter to send time
currentDayStart = applyJitter(currentDayStart, jitterMin, jitterMax, startAt, endAt)
// save
campaignRecipient := &model.CampaignRecipient{
RecipientID: recipient.ID,
@@ -451,6 +516,8 @@ func (c *Campaign) schedule(
sa := campaignRecipients[i-1].SendAt.MustGet().Add(interval * time.Duration(1))
sentAt = sa
}
// apply jitter to send time
sentAt = applyJitter(sentAt, jitterMin, jitterMax, startAt, endAt)
// todo perhaps this array is unnecesssary
//recpID := recipient.ID.MustGet()
//campaignID := campaign.ID.MustGet()
@@ -1411,6 +1478,9 @@ func (c *Campaign) UpdateByID(
c.Logger.Errorw("failed to add recipient groups", "error", err)
return errs.Wrap(err)
}
// preserve jitter values from incoming campaign (not persisted to db)
current.JitterMin = incoming.JitterMin
current.JitterMax = incoming.JitterMax
err = c.schedule(ctx, session, current)
if err != nil {
c.Logger.Errorw("failed to re-schedule campaign", "error", err)
+16 -4
View File
@@ -516,6 +516,8 @@ export class API {
* @param {Array} [campaign.constraintWeekDays]
* @param {string} [campaign.constraintStartTime]
* @param {string} [campaign.constraintEndTime]
* @param {number} [campaign.jitterMin]
* @param {number} [campaign.jitterMax]
* @returns {Promise<ApiResponse>}
*/
create: async ({
@@ -541,7 +543,9 @@ export class API {
webhookIncludeData,
constraintWeekDays,
constraintStartTime,
constraintEndTime
constraintEndTime,
jitterMin,
jitterMax
}) => {
return await postJSON(this.getPath('/campaign'), {
companyID,
@@ -566,7 +570,9 @@ export class API {
webhookIncludeData,
constraintWeekDays,
constraintStartTime,
constraintEndTime
constraintEndTime,
jitterMin,
jitterMax
});
},
@@ -595,6 +601,8 @@ export class API {
* @param {Array} [campaign.constraintWeekDays]
* @param {string} [campaign.constraintStartTime]
* @param {string} [campaign.constraintEndTime]
* @param {number} [campaign.jitterMin]
* @param {number} [campaign.jitterMax]
* @returns {Promise<ApiResponse>}
*/
update: async ({
@@ -620,7 +628,9 @@ export class API {
webhookIncludeData,
constraintWeekDays,
constraintStartTime,
constraintEndTime
constraintEndTime,
jitterMin,
jitterMax
}) => {
return await postJSON(this.getPath(`/campaign/${id}`), {
templateID,
@@ -644,7 +654,9 @@ export class API {
webhookIncludeData,
constraintWeekDays,
constraintStartTime,
constraintEndTime
constraintEndTime,
jitterMin,
jitterMax
});
},
@@ -0,0 +1,57 @@
<script>
export let id = 'jitter-slider';
export let valueMin = 0;
export let valueMax = 0;
// jitter options: symmetric jitter (applied randomly as positive or negative)
const jitterOptions = [
{ value: 0, label: 'No jitter' },
{ value: 1, label: '±1 min' },
{ value: 2, label: '±2 min' },
{ value: 5, label: '±5 min' },
{ value: 10, label: '±10 min' },
{ value: 15, label: '±15 min' },
{ value: 20, label: '±20 min' },
{ value: 30, label: '±30 min' },
{ value: 45, label: '±45 min' },
{ value: 60, label: '±60 min' },
{ value: 90, label: '±90 min' },
{ value: 120, label: '±120 min' }
];
let selectedIndex = 0; // default to "no jitter"
// update values when index changes - symmetric jitter
$: {
const jitter = jitterOptions[selectedIndex].value;
valueMin = -jitter;
valueMax = jitter;
}
function handleInput(event) {
selectedIndex = parseInt(event.currentTarget.value);
}
</script>
<div class="pt-4 pb-6">
<div class="flex flex-col gap-2">
<p class="font-semibold text-slate-600 dark:text-gray-400 py-1 transition-colors duration-200">
<slot>Jitter</slot>
<span class="italic font-normal">
({jitterOptions[selectedIndex].label})
</span>
</p>
<div class="flex items-center">
<input
{id}
type="range"
min="0"
max="11"
bind:value={selectedIndex}
on:input={handleInput}
class="w-96 h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-blue-600 [&::-webkit-slider-thumb]:cursor-pointer hover:[&::-webkit-slider-thumb]:bg-blue-700 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-blue-600 [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:cursor-pointer hover:[&::-moz-range-thumb]:bg-blue-700 transition-colors duration-200"
/>
</div>
</div>
</div>
+96 -12
View File
@@ -48,6 +48,7 @@
import BigButton from '$lib/components/BigButton.svelte';
import ToIcon from '$lib/components/ToIcon.svelte';
import Datetime from '$lib/components/Datetime.svelte';
import JitterSlider from '$lib/components/JitterSlider.svelte';
import RelativeTime from '$lib/components/RelativeTime.svelte';
import AutoRefresh from '$lib/components/AutoRefresh.svelte';
import CheckboxField from '$lib/components/CheckboxField.svelte';
@@ -203,16 +204,30 @@
const SPREAD_MANUAL = 'manual';
const SPREAD_IMMEDIATE = 'immediate';
const SPREAD_1MIN = '1min';
const SPREAD_2MIN = '2min';
const SPREAD_5MIN = '5min';
const SPREAD_10MIN = '10min';
const SPREAD_20MIN = '20min';
const SPREAD_30MIN = '30min';
const SPREAD_1HOUR = '1hour';
const SPREAD_2HOUR = '2hour';
const SPREAD_5HOUR = '5hour';
const SPREAD_12HOUR = '12hour';
const SPREAD_24HOUR = '24hour';
const spreadOptionMap = new BiMap({
Manual: SPREAD_MANUAL,
'1 minute': SPREAD_1MIN,
'2 minutes': SPREAD_2MIN,
'5 minutes': SPREAD_5MIN,
'10 minutes': SPREAD_10MIN,
'20 minutes': SPREAD_20MIN,
'1 hour': SPREAD_1HOUR
'30 minutes': SPREAD_30MIN,
'1 hour': SPREAD_1HOUR,
'2 hours': SPREAD_2HOUR,
'5 hours': SPREAD_5HOUR,
'12 hours': SPREAD_12HOUR,
'24 hours': SPREAD_24HOUR
});
let spreadOption = SPREAD_MANUAL;
@@ -223,12 +238,26 @@
return 0;
case SPREAD_1MIN:
return 60000;
case SPREAD_2MIN:
return 120000;
case SPREAD_5MIN:
return 300000;
case SPREAD_10MIN:
return 600000;
case SPREAD_20MIN:
return 1200000;
case SPREAD_30MIN:
return 1800000;
case SPREAD_1HOUR:
return 3600000;
case SPREAD_2HOUR:
return 7200000;
case SPREAD_5HOUR:
return 18000000;
case SPREAD_12HOUR:
return 43200000;
case SPREAD_24HOUR:
return 86400000;
default:
return null;
}
@@ -260,7 +289,9 @@
obfuscate: false,
selectedCount: 0,
webhookValue: null,
webhookIncludeData: false
webhookIncludeData: false,
jitterMin: 0,
jitterMax: 0
};
let modalError = '';
@@ -640,7 +671,9 @@
constraintStartTime: contraintStartTimeUTC,
constraintEndTime: contraintEndTimeUTC,
webhookID: webhookMap.byValueOrNull(formValues.webhookValue),
webhookIncludeData: formValues.webhookIncludeData
webhookIncludeData: formValues.webhookIncludeData,
jitterMin: formValues.jitterMin !== 0 ? formValues.jitterMin : null,
jitterMax: formValues.jitterMax !== 0 ? formValues.jitterMax : null
});
if (!res.success) {
@@ -704,7 +737,9 @@
denyPageID: denyPageMap.byValueOrNull(formValues.denyPageValue),
evasionPageID: denyPageMap.byValueOrNull(formValues.evasionPageValue),
webhookID: webhookMap.byValueOrNull(formValues.webhookValue),
webhookIncludeData: formValues.webhookIncludeData
webhookIncludeData: formValues.webhookIncludeData,
jitterMin: formValues.jitterMin !== 0 ? formValues.jitterMin : null,
jitterMax: formValues.jitterMax !== 0 ? formValues.jitterMax : null
});
if (!res.success) {
@@ -830,7 +865,9 @@
obfuscate: false,
selectedCount: 0,
webhookValue: null,
webhookIncludeData: false
webhookIncludeData: false,
jitterMin: 0,
jitterMax: 0
};
scheduleType = 'basic';
allowDenyType = 'none';
@@ -1313,10 +1350,10 @@
</div>
{#if formValues.sendStartAt}
<div class="pl-36 pt-4 pb-6">
<div class="pt-4 pb-6">
<div class="flex flex-col gap-2">
<p
class="text-sm font-semibold text-slate-600 dark:text-gray-300 transition-colors duration-200"
class="font-semibold text-slate-600 dark:text-gray-400 py-1 transition-colors duration-200"
>
Distribution Speed
@@ -1325,32 +1362,53 @@
{#if spreadOption === SPREAD_MANUAL}
Manual timing
{:else if spreadOption === SPREAD_1MIN}
1 minutes apart
1 minute apart
{:else if spreadOption === SPREAD_2MIN}
2 minutes apart
{:else if spreadOption === SPREAD_5MIN}
5 minutes apart
{:else if spreadOption === SPREAD_10MIN}
10 minutes apart
{:else if spreadOption === SPREAD_20MIN}
20 minutes apart
{:else if spreadOption === SPREAD_30MIN}
30 minutes apart
{:else if spreadOption === SPREAD_1HOUR}
1 hour apart
{:else if spreadOption === SPREAD_2HOUR}
2 hours apart
{:else if spreadOption === SPREAD_5HOUR}
5 hours apart
{:else if spreadOption === SPREAD_12HOUR}
12 hours apart
{:else if spreadOption === SPREAD_24HOUR}
24 hours apart
{/if}
)
</span>
</p>
<div class="flex items-center gap-4">
<div class="flex items-center">
<input
type="range"
min="0"
max="4"
max="11"
bind:value={speedIndex}
class="w-48 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-blue-600 [&::-webkit-slider-thumb]:cursor-pointer hover:[&::-webkit-slider-thumb]:bg-blue-700"
class="w-96 h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-blue-600 [&::-webkit-slider-thumb]:cursor-pointer hover:[&::-webkit-slider-thumb]:bg-blue-700 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-blue-600 [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:cursor-pointer hover:[&::-moz-range-thumb]:bg-blue-700 transition-colors duration-200"
on:input={(event) => {
const index = parseInt(event.currentTarget.value);
const speeds = [
SPREAD_MANUAL,
SPREAD_1MIN,
SPREAD_2MIN,
SPREAD_5MIN,
SPREAD_10MIN,
SPREAD_20MIN,
SPREAD_1HOUR
SPREAD_30MIN,
SPREAD_1HOUR,
SPREAD_2HOUR,
SPREAD_5HOUR,
SPREAD_12HOUR,
SPREAD_24HOUR
];
spreadOption = speeds[index];
const milliseconds = getSpreadMilliseconds(spreadOption);
@@ -1520,6 +1578,14 @@
options={Array.from(sortOrder.keys())}>Delivery order</TextFieldSelect
>
<JitterSlider
id="jitter-slider"
bind:valueMin={formValues.jitterMin}
bind:valueMax={formValues.jitterMax}
>
Jitter
</JitterSlider>
<DateTimeField
bind:value={formValues.closeAt}
min={formValues.sendEndAt
@@ -1894,6 +1960,15 @@
{spreadOptionMap.byValue(spreadOption)}
</span>
{/if}
{#if formValues.jitterMin !== 0 || formValues.jitterMax !== 0}
<span class="text-grayblue-dark font-medium">Jitter:</span>
<span
class="text-pc-darkblue dark:text-gray-100 transition-colors duration-200"
>
{formValues.jitterMin} to {formValues.jitterMax} minutes
</span>
{/if}
{:else if scheduleType === 'schedule'}
<span class="text-grayblue-dark font-medium">Active days:</span>
<span
@@ -1909,6 +1984,15 @@
{formValues.contraintStartTime} - {formValues.contraintEndTime}
</span>
{/if}
{#if formValues.jitterMin !== 0 || formValues.jitterMax !== 0}
<span class="text-grayblue-dark font-medium">Jitter:</span>
<span
class="text-pc-darkblue dark:text-gray-100 transition-colors duration-200"
>
{formValues.jitterMin} to {formValues.jitterMax} minutes
</span>
{/if}
{/if}
{#if formValues.closeAt}