@intranefr/superbackend 1.5.1 → 1.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +10 -0
- package/analysis-only.skill +0 -0
- package/package.json +2 -1
- package/src/controllers/admin.controller.js +68 -1
- package/src/controllers/adminExperiments.controller.js +200 -0
- package/src/controllers/adminScripts.controller.js +105 -74
- package/src/controllers/experiments.controller.js +85 -0
- package/src/controllers/internalExperiments.controller.js +17 -0
- package/src/helpers/mongooseHelper.js +258 -0
- package/src/helpers/scriptBase.js +230 -0
- package/src/helpers/scriptRunner.js +335 -0
- package/src/middleware.js +65 -11
- package/src/models/CacheEntry.js +1 -1
- package/src/models/ConsoleLog.js +1 -1
- package/src/models/Experiment.js +75 -0
- package/src/models/ExperimentAssignment.js +23 -0
- package/src/models/ExperimentEvent.js +26 -0
- package/src/models/ExperimentMetricBucket.js +30 -0
- package/src/models/GlobalSetting.js +1 -2
- package/src/models/RateLimitCounter.js +1 -1
- package/src/models/ScriptDefinition.js +1 -0
- package/src/models/Webhook.js +2 -0
- package/src/routes/admin.routes.js +2 -0
- package/src/routes/adminConsoleManager.routes.js +1 -1
- package/src/routes/adminExperiments.routes.js +29 -0
- package/src/routes/blogInternal.routes.js +2 -2
- package/src/routes/experiments.routes.js +30 -0
- package/src/routes/internalExperiments.routes.js +15 -0
- package/src/services/blogCronsBootstrap.service.js +7 -6
- package/src/services/consoleManager.service.js +56 -18
- package/src/services/consoleOverride.service.js +1 -0
- package/src/services/experiments.service.js +273 -0
- package/src/services/experimentsAggregation.service.js +308 -0
- package/src/services/experimentsCronsBootstrap.service.js +118 -0
- package/src/services/experimentsRetention.service.js +43 -0
- package/src/services/experimentsWs.service.js +134 -0
- package/src/services/globalSettings.service.js +15 -0
- package/src/services/jsonConfigs.service.js +2 -2
- package/src/services/scriptsRunner.service.js +214 -14
- package/src/utils/rbac/rightsRegistry.js +4 -0
- package/views/admin-dashboard.ejs +28 -8
- package/views/admin-experiments.ejs +91 -0
- package/views/admin-scripts.ejs +596 -2
- package/views/partials/dashboard/nav-items.ejs +1 -0
- package/views/partials/dashboard/palette.ejs +5 -3
- package/src/middleware/internalCronAuth.js +0 -29
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
const mongoose = require('mongoose');
|
|
2
|
+
|
|
3
|
+
const Experiment = require('../models/Experiment');
|
|
4
|
+
const ExperimentEvent = require('../models/ExperimentEvent');
|
|
5
|
+
const ExperimentMetricBucket = require('../models/ExperimentMetricBucket');
|
|
6
|
+
|
|
7
|
+
const experimentsService = require('./experiments.service');
|
|
8
|
+
const { broadcastWinnerChanged } = require('./experimentsWs.service');
|
|
9
|
+
const webhookService = require('./webhook.service');
|
|
10
|
+
|
|
11
|
+
function normalizeStr(v) {
|
|
12
|
+
return String(v || '').trim();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function floorToBucket(date, bucketMs) {
|
|
16
|
+
const t = new Date(date).getTime();
|
|
17
|
+
const ms = Number(bucketMs || 0) || 0;
|
|
18
|
+
if (!Number.isFinite(t) || ms <= 0) return null;
|
|
19
|
+
return new Date(Math.floor(t / ms) * ms);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function resolveMetricKeys(exp) {
|
|
23
|
+
const defs = [];
|
|
24
|
+
if (exp?.primaryMetric) defs.push(exp.primaryMetric);
|
|
25
|
+
for (const m of exp?.secondaryMetrics || []) defs.push(m);
|
|
26
|
+
|
|
27
|
+
const keys = new Set();
|
|
28
|
+
for (const d of defs) {
|
|
29
|
+
const kind = normalizeStr(d?.kind);
|
|
30
|
+
const key = normalizeStr(d?.key);
|
|
31
|
+
if (!key) continue;
|
|
32
|
+
|
|
33
|
+
if (kind === 'rate') {
|
|
34
|
+
const num = normalizeStr(d?.numeratorEventKey);
|
|
35
|
+
const den = normalizeStr(d?.denominatorEventKey);
|
|
36
|
+
if (num) keys.add(num);
|
|
37
|
+
if (den) keys.add(den);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
keys.add(key);
|
|
42
|
+
}
|
|
43
|
+
return Array.from(keys);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function aggregateExperiment({ experimentId, bucketMs, start, end }) {
|
|
47
|
+
const exp = await Experiment.findById(experimentId).lean();
|
|
48
|
+
if (!exp) {
|
|
49
|
+
const err = new Error('Experiment not found');
|
|
50
|
+
err.code = 'NOT_FOUND';
|
|
51
|
+
throw err;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const metricEventKeys = resolveMetricKeys(exp);
|
|
55
|
+
if (!metricEventKeys.length) {
|
|
56
|
+
return { experimentId: String(exp._id), aggregated: 0 };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const startAt = start ? new Date(start) : (exp.startedAt ? new Date(exp.startedAt) : new Date(Date.now() - 24 * 60 * 60 * 1000));
|
|
60
|
+
const endAt = end ? new Date(end) : new Date();
|
|
61
|
+
|
|
62
|
+
const bucket = Number(bucketMs || 0) || 3600000;
|
|
63
|
+
|
|
64
|
+
const pipeline = [
|
|
65
|
+
{
|
|
66
|
+
$match: {
|
|
67
|
+
experimentId: new mongoose.Types.ObjectId(String(exp._id)),
|
|
68
|
+
ts: { $gte: startAt, $lte: endAt },
|
|
69
|
+
eventKey: { $in: metricEventKeys },
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
$addFields: {
|
|
74
|
+
bucketStart: {
|
|
75
|
+
$toDate: {
|
|
76
|
+
$multiply: [
|
|
77
|
+
{ $floor: { $divide: [{ $toLong: '$ts' }, bucket] } },
|
|
78
|
+
bucket,
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
$group: {
|
|
86
|
+
_id: {
|
|
87
|
+
variantKey: '$variantKey',
|
|
88
|
+
metricKey: '$eventKey',
|
|
89
|
+
bucketStart: '$bucketStart',
|
|
90
|
+
},
|
|
91
|
+
count: { $sum: 1 },
|
|
92
|
+
sum: { $sum: '$value' },
|
|
93
|
+
sumSq: { $sum: { $multiply: ['$value', '$value'] } },
|
|
94
|
+
min: { $min: '$value' },
|
|
95
|
+
max: { $max: '$value' },
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
const rows = await ExperimentEvent.aggregate(pipeline);
|
|
101
|
+
|
|
102
|
+
let aggregated = 0;
|
|
103
|
+
for (const r of rows || []) {
|
|
104
|
+
if (!r || !r._id) continue;
|
|
105
|
+
|
|
106
|
+
await ExperimentMetricBucket.updateOne(
|
|
107
|
+
{
|
|
108
|
+
experimentId: exp._id,
|
|
109
|
+
organizationId: exp.organizationId || null,
|
|
110
|
+
variantKey: String(r._id.variantKey),
|
|
111
|
+
metricKey: String(r._id.metricKey),
|
|
112
|
+
bucketStart: new Date(r._id.bucketStart),
|
|
113
|
+
bucketMs: bucket,
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
$set: {
|
|
117
|
+
count: Number(r.count || 0) || 0,
|
|
118
|
+
sum: Number(r.sum || 0) || 0,
|
|
119
|
+
sumSq: Number(r.sumSq || 0) || 0,
|
|
120
|
+
min: r.min === undefined ? null : r.min,
|
|
121
|
+
max: r.max === undefined ? null : r.max,
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
{ upsert: true },
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
aggregated += 1;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return { experimentId: String(exp._id), aggregated };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function computeTotalsForMetric({ experimentId, variantKey, metricKey, startAt }) {
|
|
134
|
+
const q = {
|
|
135
|
+
experimentId: new mongoose.Types.ObjectId(String(experimentId)),
|
|
136
|
+
variantKey: String(variantKey),
|
|
137
|
+
metricKey: String(metricKey),
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
if (startAt) {
|
|
141
|
+
q.bucketStart = { $gte: new Date(startAt) };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const rows = await ExperimentMetricBucket.find(q).select('count sum').lean();
|
|
145
|
+
|
|
146
|
+
let count = 0;
|
|
147
|
+
let sum = 0;
|
|
148
|
+
for (const r of rows || []) {
|
|
149
|
+
count += Number(r.count || 0) || 0;
|
|
150
|
+
sum += Number(r.sum || 0) || 0;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return { count, sum };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function evaluateWinner({ experimentId }) {
|
|
157
|
+
const exp = await Experiment.findById(experimentId);
|
|
158
|
+
if (!exp) {
|
|
159
|
+
const err = new Error('Experiment not found');
|
|
160
|
+
err.code = 'NOT_FOUND';
|
|
161
|
+
throw err;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (exp.winnerPolicy?.mode !== 'automatic') {
|
|
165
|
+
return { decided: false, reason: 'manual_mode' };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (exp.winnerVariantKey && exp.winnerDecidedAt) {
|
|
169
|
+
return { decided: true, reason: 'already_decided', winnerVariantKey: exp.winnerVariantKey };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const pickAfterMs = Number(exp.winnerPolicy?.pickAfterMs || 0) || 0;
|
|
173
|
+
const startedAt = exp.startedAt ? new Date(exp.startedAt) : null;
|
|
174
|
+
if (!startedAt) {
|
|
175
|
+
return { decided: false, reason: 'missing_startedAt' };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (pickAfterMs > 0 && Date.now() - startedAt.getTime() < pickAfterMs) {
|
|
179
|
+
return { decided: false, reason: 'too_early' };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const primary = exp.primaryMetric || {};
|
|
183
|
+
const kind = normalizeStr(primary.kind) || 'count';
|
|
184
|
+
|
|
185
|
+
const variants = (exp.variants || []).map((v) => normalizeStr(v?.key)).filter(Boolean);
|
|
186
|
+
if (!variants.length) {
|
|
187
|
+
return { decided: false, reason: 'no_variants' };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const evalStart = startedAt;
|
|
191
|
+
|
|
192
|
+
const scores = [];
|
|
193
|
+
for (const variantKey of variants) {
|
|
194
|
+
if (kind === 'rate') {
|
|
195
|
+
const numeratorKey = normalizeStr(primary.numeratorEventKey);
|
|
196
|
+
const denominatorKey = normalizeStr(primary.denominatorEventKey);
|
|
197
|
+
|
|
198
|
+
if (!numeratorKey || !denominatorKey) {
|
|
199
|
+
return { decided: false, reason: 'invalid_primary_rate_metric' };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const num = await computeTotalsForMetric({ experimentId: exp._id, variantKey, metricKey: numeratorKey, startAt: evalStart });
|
|
203
|
+
const den = await computeTotalsForMetric({ experimentId: exp._id, variantKey, metricKey: denominatorKey, startAt: evalStart });
|
|
204
|
+
|
|
205
|
+
const conversions = num.sum;
|
|
206
|
+
const exposures = den.sum;
|
|
207
|
+
|
|
208
|
+
const score = exposures > 0 ? conversions / exposures : 0;
|
|
209
|
+
scores.push({ variantKey, score, conversions, exposures });
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const metricKey = normalizeStr(primary.key);
|
|
214
|
+
if (!metricKey) {
|
|
215
|
+
return { decided: false, reason: 'invalid_primary_metric' };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const totals = await computeTotalsForMetric({ experimentId: exp._id, variantKey, metricKey, startAt: evalStart });
|
|
219
|
+
|
|
220
|
+
let score = 0;
|
|
221
|
+
if (kind === 'count') score = totals.count;
|
|
222
|
+
else if (kind === 'sum') score = totals.sum;
|
|
223
|
+
else if (kind === 'avg') score = totals.count > 0 ? totals.sum / totals.count : 0;
|
|
224
|
+
else score = totals.sum;
|
|
225
|
+
|
|
226
|
+
scores.push({ variantKey, score, count: totals.count, sum: totals.sum });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const minAssignments = Number(exp.winnerPolicy?.minAssignments || 0) || 0;
|
|
230
|
+
const minExposures = Number(exp.winnerPolicy?.minExposures || 0) || 0;
|
|
231
|
+
const minConversions = Number(exp.winnerPolicy?.minConversions || 0) || 0;
|
|
232
|
+
|
|
233
|
+
if (kind === 'rate') {
|
|
234
|
+
const anyOk = scores.some((s) => (Number(s.exposures || 0) || 0) >= minExposures && (Number(s.conversions || 0) || 0) >= minConversions);
|
|
235
|
+
if (!anyOk) return { decided: false, reason: 'insufficient_data' };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (minAssignments > 0) {
|
|
239
|
+
// Placeholder: assignment counts could be derived from assignments collection.
|
|
240
|
+
// We enforce it later once we implement an efficient counter.
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const objective = normalizeStr(primary.objective) || 'maximize';
|
|
244
|
+
const sorted = [...scores].sort((a, b) => (objective === 'minimize' ? a.score - b.score : b.score - a.score));
|
|
245
|
+
const winner = sorted[0];
|
|
246
|
+
if (!winner) return { decided: false, reason: 'no_scores' };
|
|
247
|
+
|
|
248
|
+
const override = normalizeStr(exp.winnerPolicy?.overrideWinnerVariantKey);
|
|
249
|
+
const winnerKey = override || winner.variantKey;
|
|
250
|
+
|
|
251
|
+
exp.winnerVariantKey = winnerKey;
|
|
252
|
+
exp.winnerDecidedAt = new Date();
|
|
253
|
+
exp.winnerReason = override ? 'manual_override' : `auto:${kind}:${objective}`;
|
|
254
|
+
exp.status = 'completed';
|
|
255
|
+
|
|
256
|
+
await exp.save();
|
|
257
|
+
|
|
258
|
+
await experimentsService.clearExperimentCaches(exp._id);
|
|
259
|
+
|
|
260
|
+
broadcastWinnerChanged({
|
|
261
|
+
experimentId: String(exp._id),
|
|
262
|
+
experimentCode: exp.code,
|
|
263
|
+
organizationId: exp.organizationId ? String(exp.organizationId) : null,
|
|
264
|
+
winnerVariantKey: exp.winnerVariantKey,
|
|
265
|
+
decidedAt: exp.winnerDecidedAt,
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
if (exp.organizationId) {
|
|
269
|
+
webhookService.emit(
|
|
270
|
+
'experiment.winner_changed',
|
|
271
|
+
{
|
|
272
|
+
experimentId: String(exp._id),
|
|
273
|
+
code: exp.code,
|
|
274
|
+
winnerVariantKey: exp.winnerVariantKey,
|
|
275
|
+
decidedAt: exp.winnerDecidedAt,
|
|
276
|
+
},
|
|
277
|
+
String(exp.organizationId),
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return { decided: true, reason: exp.winnerReason, winnerVariantKey: exp.winnerVariantKey, scores };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function runAggregationAndWinner({ bucketMs, start, end } = {}) {
|
|
285
|
+
const now = new Date();
|
|
286
|
+
const bucket = Number(bucketMs || 0) || 3600000;
|
|
287
|
+
|
|
288
|
+
const startAt = start ? new Date(start) : new Date(now.getTime() - 6 * 60 * 60 * 1000);
|
|
289
|
+
const endAt = end ? new Date(end) : now;
|
|
290
|
+
|
|
291
|
+
const experiments = await Experiment.find({ status: { $in: ['running', 'completed'] } }).select('_id').lean();
|
|
292
|
+
|
|
293
|
+
const out = [];
|
|
294
|
+
for (const e of experiments || []) {
|
|
295
|
+
const res = await aggregateExperiment({ experimentId: e._id, bucketMs: bucket, start: startAt, end: endAt });
|
|
296
|
+
const win = await evaluateWinner({ experimentId: e._id }).catch((err) => ({ decided: false, reason: err.message }));
|
|
297
|
+
out.push({ experimentId: String(e._id), aggregated: res.aggregated, winner: win });
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return { range: { start: startAt.toISOString(), end: endAt.toISOString() }, bucketMs: bucket, items: out };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
module.exports = {
|
|
304
|
+
floorToBucket,
|
|
305
|
+
aggregateExperiment,
|
|
306
|
+
evaluateWinner,
|
|
307
|
+
runAggregationAndWinner,
|
|
308
|
+
};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
|
|
3
|
+
const GlobalSetting = require('../models/GlobalSetting');
|
|
4
|
+
const CronJob = require('../models/CronJob');
|
|
5
|
+
|
|
6
|
+
const globalSettingsService = require('./globalSettings.service');
|
|
7
|
+
|
|
8
|
+
const INTERNAL_CRON_TOKEN_SETTING_KEY = 'experiments.internalCronToken';
|
|
9
|
+
|
|
10
|
+
const CRON_NAME_AGGREGATE = 'Experiments: Aggregate + Evaluate Winner';
|
|
11
|
+
const CRON_NAME_RETENTION = 'Experiments: Retention Cleanup';
|
|
12
|
+
|
|
13
|
+
function getDefaultInternalCronToken() {
|
|
14
|
+
return crypto.randomBytes(24).toString('hex');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function ensureSettingExists({ key, type, description, defaultValue }) {
|
|
18
|
+
const existing = await GlobalSetting.findOne({ key }).lean();
|
|
19
|
+
if (existing) return existing;
|
|
20
|
+
|
|
21
|
+
const doc = await GlobalSetting.create({
|
|
22
|
+
key,
|
|
23
|
+
type,
|
|
24
|
+
description,
|
|
25
|
+
value: type === 'json' ? JSON.stringify(defaultValue) : String(defaultValue ?? ''),
|
|
26
|
+
templateVariables: [],
|
|
27
|
+
public: false,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
globalSettingsService.clearSettingsCache();
|
|
31
|
+
return doc.toObject();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function ensureInternalTokenExists() {
|
|
35
|
+
await ensureSettingExists({
|
|
36
|
+
key: INTERNAL_CRON_TOKEN_SETTING_KEY,
|
|
37
|
+
type: 'string',
|
|
38
|
+
description: 'Bearer token used by CronJobs to call internal experiments endpoints.',
|
|
39
|
+
defaultValue: getDefaultInternalCronToken(),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const raw = await globalSettingsService.getSettingValue(INTERNAL_CRON_TOKEN_SETTING_KEY, '');
|
|
43
|
+
return String(raw || '').trim();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function ensureCronJobs({ baseUrl }) {
|
|
47
|
+
const aggregateUrl = `${baseUrl}/api/internal/experiments/aggregate/run`;
|
|
48
|
+
const retentionUrl = `${baseUrl}/api/internal/experiments/retention/run`;
|
|
49
|
+
|
|
50
|
+
// Use the same Basic Auth credentials as the admin API.
|
|
51
|
+
// This keeps a single source of truth and avoids CronJobs drifting to other env vars.
|
|
52
|
+
const internalCronUsername = process.env.ADMIN_USERNAME || 'admin';
|
|
53
|
+
const internalCronPassword = process.env.ADMIN_PASSWORD || 'admin';
|
|
54
|
+
|
|
55
|
+
const aggDoc = {
|
|
56
|
+
name: CRON_NAME_AGGREGATE,
|
|
57
|
+
description: 'Aggregates experiment events into buckets and evaluates winners.',
|
|
58
|
+
cronExpression: '*/15 * * * *',
|
|
59
|
+
timezone: 'UTC',
|
|
60
|
+
enabled: true,
|
|
61
|
+
nextRunAt: null,
|
|
62
|
+
taskType: 'http',
|
|
63
|
+
httpMethod: 'POST',
|
|
64
|
+
httpUrl: aggregateUrl,
|
|
65
|
+
httpHeaders: [],
|
|
66
|
+
httpBody: JSON.stringify({}),
|
|
67
|
+
httpBodyType: 'json',
|
|
68
|
+
httpAuth: { type: 'basic', username: internalCronUsername, password: internalCronPassword },
|
|
69
|
+
timeoutMs: 5 * 60 * 1000,
|
|
70
|
+
createdBy: 'system',
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
await CronJob.updateOne(
|
|
74
|
+
{ name: CRON_NAME_AGGREGATE, taskType: 'http' },
|
|
75
|
+
{ $set: aggDoc, $setOnInsert: { createdAt: new Date() } },
|
|
76
|
+
{ upsert: true },
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const retentionDoc = {
|
|
80
|
+
name: CRON_NAME_RETENTION,
|
|
81
|
+
description: 'Deletes old experiment events and metric buckets based on retention settings.',
|
|
82
|
+
cronExpression: '0 3 * * *',
|
|
83
|
+
timezone: 'UTC',
|
|
84
|
+
enabled: true,
|
|
85
|
+
nextRunAt: null,
|
|
86
|
+
taskType: 'http',
|
|
87
|
+
httpMethod: 'POST',
|
|
88
|
+
httpUrl: retentionUrl,
|
|
89
|
+
httpHeaders: [],
|
|
90
|
+
httpBody: JSON.stringify({}),
|
|
91
|
+
httpBodyType: 'json',
|
|
92
|
+
httpAuth: { type: 'basic', username: internalCronUsername, password: internalCronPassword },
|
|
93
|
+
timeoutMs: 10 * 60 * 1000,
|
|
94
|
+
createdBy: 'system',
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
await CronJob.updateOne(
|
|
98
|
+
{ name: CRON_NAME_RETENTION, taskType: 'http' },
|
|
99
|
+
{ $set: retentionDoc, $setOnInsert: { createdAt: new Date() } },
|
|
100
|
+
{ upsert: true },
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function bootstrap() {
|
|
105
|
+
// CronScheduler HTTP jobs need an absolute base URL.
|
|
106
|
+
const baseUrl =
|
|
107
|
+
String(process.env.SUPERBACKEND_BASE_URL || process.env.PUBLIC_URL || '').trim() ||
|
|
108
|
+
'http://localhost:3000';
|
|
109
|
+
|
|
110
|
+
await ensureCronJobs({ baseUrl: baseUrl.replace(/\/+$/, '') });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = {
|
|
114
|
+
bootstrap,
|
|
115
|
+
INTERNAL_CRON_TOKEN_SETTING_KEY,
|
|
116
|
+
CRON_NAME_AGGREGATE,
|
|
117
|
+
CRON_NAME_RETENTION,
|
|
118
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
const globalSettingsService = require('./globalSettings.service');
|
|
2
|
+
|
|
3
|
+
const ExperimentEvent = require('../models/ExperimentEvent');
|
|
4
|
+
const ExperimentMetricBucket = require('../models/ExperimentMetricBucket');
|
|
5
|
+
|
|
6
|
+
function toInt(val, fallback) {
|
|
7
|
+
const n = parseInt(String(val), 10);
|
|
8
|
+
return Number.isFinite(n) ? n : fallback;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async function runRetentionCleanup() {
|
|
12
|
+
const eventsRetentionDays = toInt(
|
|
13
|
+
await globalSettingsService.getSettingValue('EXPERIMENT_EVENTS_RETENTION_DAYS', '30'),
|
|
14
|
+
30,
|
|
15
|
+
);
|
|
16
|
+
const metricsRetentionDays = toInt(
|
|
17
|
+
await globalSettingsService.getSettingValue('EXPERIMENT_METRICS_RETENTION_DAYS', '180'),
|
|
18
|
+
180,
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
const now = Date.now();
|
|
22
|
+
const eventsCutoff = new Date(now - eventsRetentionDays * 24 * 60 * 60 * 1000);
|
|
23
|
+
const metricsCutoff = new Date(now - metricsRetentionDays * 24 * 60 * 60 * 1000);
|
|
24
|
+
|
|
25
|
+
const [eventsRes, bucketsRes] = await Promise.all([
|
|
26
|
+
ExperimentEvent.deleteMany({ ts: { $lt: eventsCutoff } }),
|
|
27
|
+
ExperimentMetricBucket.deleteMany({ bucketStart: { $lt: metricsCutoff } }),
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
eventsRetentionDays,
|
|
32
|
+
metricsRetentionDays,
|
|
33
|
+
cutoffs: { eventsCutoff: eventsCutoff.toISOString(), metricsCutoff: metricsCutoff.toISOString() },
|
|
34
|
+
deleted: {
|
|
35
|
+
events: eventsRes?.deletedCount ?? 0,
|
|
36
|
+
metricBuckets: bucketsRes?.deletedCount ?? 0,
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = {
|
|
42
|
+
runRetentionCleanup,
|
|
43
|
+
};
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
const { WebSocketServer } = require('ws');
|
|
2
|
+
const url = require('url');
|
|
3
|
+
|
|
4
|
+
const subscribersByCode = new Map(); // experimentCode -> Set<ws>
|
|
5
|
+
|
|
6
|
+
function safeSend(ws, payload) {
|
|
7
|
+
try {
|
|
8
|
+
ws.send(JSON.stringify(payload));
|
|
9
|
+
} catch {
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function normalizeCode(v) {
|
|
14
|
+
return String(v || '').trim();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function broadcastWinnerChanged({ experimentCode, winnerVariantKey, decidedAt }) {
|
|
18
|
+
const code = normalizeCode(experimentCode);
|
|
19
|
+
if (!code) return;
|
|
20
|
+
|
|
21
|
+
const subs = subscribersByCode.get(code);
|
|
22
|
+
if (!subs || subs.size === 0) return;
|
|
23
|
+
|
|
24
|
+
const msg = {
|
|
25
|
+
type: 'winner',
|
|
26
|
+
experimentCode: code,
|
|
27
|
+
winnerVariantKey: winnerVariantKey || null,
|
|
28
|
+
decidedAt: decidedAt ? new Date(decidedAt).toISOString() : null,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
for (const ws of subs) {
|
|
32
|
+
if (ws.readyState !== ws.OPEN) continue;
|
|
33
|
+
safeSend(ws, msg);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function attachExperimentsWebsocketServer(server) {
|
|
38
|
+
const wsPath = '/api/experiments/ws';
|
|
39
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
40
|
+
|
|
41
|
+
server.on('upgrade', (req, socket, head) => {
|
|
42
|
+
const parsed = url.parse(req.url, true);
|
|
43
|
+
if (!parsed || parsed.pathname !== wsPath) return;
|
|
44
|
+
|
|
45
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
46
|
+
wss.emit('connection', ws, req, parsed);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
wss.on('connection', (ws, _req, parsed) => {
|
|
51
|
+
ws._sbExperimentSubs = new Set();
|
|
52
|
+
|
|
53
|
+
safeSend(ws, { type: 'hello' });
|
|
54
|
+
|
|
55
|
+
ws.on('message', (raw) => {
|
|
56
|
+
let msg;
|
|
57
|
+
try {
|
|
58
|
+
msg = JSON.parse(String(raw || ''));
|
|
59
|
+
} catch {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const type = normalizeCode(msg?.type);
|
|
64
|
+
const experimentCode = normalizeCode(msg?.experimentCode);
|
|
65
|
+
|
|
66
|
+
if (type === 'subscribe') {
|
|
67
|
+
if (!experimentCode) return;
|
|
68
|
+
|
|
69
|
+
let set = subscribersByCode.get(experimentCode);
|
|
70
|
+
if (!set) {
|
|
71
|
+
set = new Set();
|
|
72
|
+
subscribersByCode.set(experimentCode, set);
|
|
73
|
+
}
|
|
74
|
+
set.add(ws);
|
|
75
|
+
ws._sbExperimentSubs.add(experimentCode);
|
|
76
|
+
safeSend(ws, { type: 'subscribed', experimentCode });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (type === 'unsubscribe') {
|
|
81
|
+
if (!experimentCode) return;
|
|
82
|
+
|
|
83
|
+
const set = subscribersByCode.get(experimentCode);
|
|
84
|
+
if (set) {
|
|
85
|
+
set.delete(ws);
|
|
86
|
+
if (set.size === 0) subscribersByCode.delete(experimentCode);
|
|
87
|
+
}
|
|
88
|
+
ws._sbExperimentSubs.delete(experimentCode);
|
|
89
|
+
safeSend(ws, { type: 'unsubscribed', experimentCode });
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
ws.on('close', () => {
|
|
94
|
+
for (const code of ws._sbExperimentSubs || []) {
|
|
95
|
+
const set = subscribersByCode.get(code);
|
|
96
|
+
if (set) {
|
|
97
|
+
set.delete(ws);
|
|
98
|
+
if (set.size === 0) subscribersByCode.delete(code);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
ws.on('error', () => {
|
|
104
|
+
for (const code of ws._sbExperimentSubs || []) {
|
|
105
|
+
const set = subscribersByCode.get(code);
|
|
106
|
+
if (set) {
|
|
107
|
+
set.delete(ws);
|
|
108
|
+
if (set.size === 0) subscribersByCode.delete(code);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// If query provides experimentCode, auto-subscribe.
|
|
114
|
+
const q = parsed && parsed.query ? parsed.query : {};
|
|
115
|
+
const initial = normalizeCode(q.experimentCode);
|
|
116
|
+
if (initial) {
|
|
117
|
+
let set = subscribersByCode.get(initial);
|
|
118
|
+
if (!set) {
|
|
119
|
+
set = new Set();
|
|
120
|
+
subscribersByCode.set(initial, set);
|
|
121
|
+
}
|
|
122
|
+
set.add(ws);
|
|
123
|
+
ws._sbExperimentSubs.add(initial);
|
|
124
|
+
safeSend(ws, { type: 'subscribed', experimentCode: initial });
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
return { wss, wsPath };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
module.exports = {
|
|
132
|
+
attachExperimentsWebsocketServer,
|
|
133
|
+
broadcastWinnerChanged,
|
|
134
|
+
};
|
|
@@ -39,11 +39,26 @@ async function getSettingValue(key, defaultValue = null) {
|
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
async function deleteSetting(key) {
|
|
43
|
+
try {
|
|
44
|
+
const setting = await GlobalSetting.findOneAndDelete({ key });
|
|
45
|
+
|
|
46
|
+
// Clear cache for this key
|
|
47
|
+
settingsCache.delete(key);
|
|
48
|
+
|
|
49
|
+
return setting;
|
|
50
|
+
} catch (error) {
|
|
51
|
+
console.error(`Error deleting setting ${key}:`, error);
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
42
56
|
function clearSettingsCache() {
|
|
43
57
|
settingsCache.clear();
|
|
44
58
|
}
|
|
45
59
|
|
|
46
60
|
module.exports = {
|
|
47
61
|
getSettingValue,
|
|
62
|
+
deleteSetting,
|
|
48
63
|
clearSettingsCache,
|
|
49
64
|
};
|
|
@@ -162,10 +162,10 @@ async function createJsonConfig({ title, jsonRaw, publicEnabled = false, cacheTt
|
|
|
162
162
|
jsonHash: computeJsonHash(String(jsonRaw)),
|
|
163
163
|
};
|
|
164
164
|
|
|
165
|
-
console.log('Creating document with data:', createData);
|
|
165
|
+
//console.log('Creating document with data:', createData);
|
|
166
166
|
|
|
167
167
|
const doc = await JsonConfig.create(createData);
|
|
168
|
-
console.log('Created document:', doc.toObject());
|
|
168
|
+
//console.log('Created document:', doc.toObject());
|
|
169
169
|
|
|
170
170
|
clearJsonConfigCache(slug);
|
|
171
171
|
if (normalizedAlias) {
|