posthog-flag-toolkit 0.1.0
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/LICENSE +21 -0
- package/README.md +184 -0
- package/dist/adapters/inngest.cjs +20 -0
- package/dist/adapters/inngest.cjs.map +1 -0
- package/dist/adapters/inngest.d.cts +29 -0
- package/dist/adapters/inngest.d.ts +29 -0
- package/dist/adapters/inngest.js +18 -0
- package/dist/adapters/inngest.js.map +1 -0
- package/dist/adapters/slack.cjs +75 -0
- package/dist/adapters/slack.cjs.map +1 -0
- package/dist/adapters/slack.d.cts +27 -0
- package/dist/adapters/slack.d.ts +27 -0
- package/dist/adapters/slack.js +73 -0
- package/dist/adapters/slack.js.map +1 -0
- package/dist/index.cjs +674 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +371 -0
- package/dist/index.d.ts +371 -0
- package/dist/index.js +647 -0
- package/dist/index.js.map +1 -0
- package/package.json +114 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
// src/guardian/decision.ts
|
|
2
|
+
function fmtPct(n) {
|
|
3
|
+
if (n == null) return "\u2014";
|
|
4
|
+
return `${(n * 100).toFixed(2)}%`;
|
|
5
|
+
}
|
|
6
|
+
function detectRegression(metrics, thresholds) {
|
|
7
|
+
const { treatment, control } = metrics;
|
|
8
|
+
if (treatment.errorRate != null && control.errorRate != null && control.errorRate > 0 && treatment.errorRate / control.errorRate >= thresholds.errorRateRatioThreshold) {
|
|
9
|
+
const ratio = treatment.errorRate / control.errorRate;
|
|
10
|
+
return {
|
|
11
|
+
kind: "regression_detected",
|
|
12
|
+
reason: `error rate ratio ${ratio.toFixed(2)}x (${fmtPct(treatment.errorRate)} vs ${fmtPct(control.errorRate)})`
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
if (treatment.errorRate != null && treatment.errorRate > 0.01 && (control.errorRate == null || control.errorRate === 0)) {
|
|
16
|
+
return {
|
|
17
|
+
kind: "regression_detected",
|
|
18
|
+
reason: `treatment error rate ${fmtPct(treatment.errorRate)} vs control 0%`
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
if (treatment.publishSuccessRate != null && control.publishSuccessRate != null && control.publishSuccessRate - treatment.publishSuccessRate >= thresholds.publishSuccessDropThreshold) {
|
|
22
|
+
const drop = control.publishSuccessRate - treatment.publishSuccessRate;
|
|
23
|
+
return {
|
|
24
|
+
kind: "regression_detected",
|
|
25
|
+
reason: `publish success drop ${(drop * 100).toFixed(1)}pp (${fmtPct(treatment.publishSuccessRate)} vs ${fmtPct(control.publishSuccessRate)})`
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
return { kind: "no_regression", reason: "all metrics within thresholds" };
|
|
29
|
+
}
|
|
30
|
+
function meetsSampleFloor(metrics, thresholds) {
|
|
31
|
+
return metrics.treatment.eventCount >= thresholds.minSampleSize && metrics.treatment.uniqueUsers >= thresholds.minUniqueUsers && metrics.control.eventCount >= thresholds.minSampleSize && metrics.control.uniqueUsers >= thresholds.minUniqueUsers;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// src/logger.ts
|
|
35
|
+
var consoleLogger = {
|
|
36
|
+
info: (msg, data) => console.log(`[posthog-flag-toolkit] ${msg}`, data ?? ""),
|
|
37
|
+
warn: (msg, data) => console.warn(`[posthog-flag-toolkit] ${msg}`, data ?? ""),
|
|
38
|
+
error: (msg, data) => console.error(`[posthog-flag-toolkit] ${msg}`, data ?? "")
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// src/posthog/api.ts
|
|
42
|
+
var DEFAULT_BASE_URL = "https://us.posthog.com";
|
|
43
|
+
var MAX_RETRIES = 3;
|
|
44
|
+
function baseUrl(config) {
|
|
45
|
+
return config.baseUrl ?? DEFAULT_BASE_URL;
|
|
46
|
+
}
|
|
47
|
+
function headers(config) {
|
|
48
|
+
return { Authorization: `Bearer ${config.apiKey}` };
|
|
49
|
+
}
|
|
50
|
+
function jsonHeaders(config) {
|
|
51
|
+
return {
|
|
52
|
+
...headers(config),
|
|
53
|
+
"Content-Type": "application/json"
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
async function fetchWithRetry(url, init, retries = MAX_RETRIES) {
|
|
57
|
+
let attempt = 0;
|
|
58
|
+
while (true) {
|
|
59
|
+
const res = await fetch(url, init);
|
|
60
|
+
if (res.status !== 429 || attempt >= retries) return res;
|
|
61
|
+
attempt++;
|
|
62
|
+
const retryAfter = res.headers.get("Retry-After");
|
|
63
|
+
const waitMs = retryAfter ? Math.min(Number(retryAfter) * 1e3, 3e4) : Math.min(1e3 * 2 ** attempt, 3e4);
|
|
64
|
+
await new Promise((r) => setTimeout(r, waitMs));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
async function fetchAllFlags(config) {
|
|
68
|
+
const flags = [];
|
|
69
|
+
let url = `${baseUrl(config)}/api/projects/${config.projectId}/feature_flags/?limit=200`;
|
|
70
|
+
while (url) {
|
|
71
|
+
const res = await fetchWithRetry(url, { headers: headers(config) });
|
|
72
|
+
if (!res.ok) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`PostHog flag fetch failed: ${res.status} ${await res.text().catch(() => "")}`
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
const data = await res.json();
|
|
78
|
+
flags.push(...data.results);
|
|
79
|
+
url = data.next;
|
|
80
|
+
}
|
|
81
|
+
return flags;
|
|
82
|
+
}
|
|
83
|
+
async function fetchFlagsByTag(config, tag) {
|
|
84
|
+
const res = await fetchWithRetry(
|
|
85
|
+
`${baseUrl(config)}/api/projects/${config.projectId}/feature_flags/?tags=${encodeURIComponent(tag)}&limit=200`,
|
|
86
|
+
{ headers: headers(config) }
|
|
87
|
+
);
|
|
88
|
+
if (!res.ok) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`PostHog flag fetch by tag failed: ${res.status} ${await res.text().catch(() => "")}`
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
const data = await res.json();
|
|
94
|
+
return data.results;
|
|
95
|
+
}
|
|
96
|
+
async function fetchAllExperiments(config) {
|
|
97
|
+
const res = await fetchWithRetry(
|
|
98
|
+
`${baseUrl(config)}/api/projects/${config.projectId}/experiments/?limit=200`,
|
|
99
|
+
{ headers: headers(config) }
|
|
100
|
+
);
|
|
101
|
+
if (!res.ok) return [];
|
|
102
|
+
const data = await res.json();
|
|
103
|
+
return data.results;
|
|
104
|
+
}
|
|
105
|
+
async function findTagAddedAt(config, flagId, tag) {
|
|
106
|
+
const res = await fetchWithRetry(
|
|
107
|
+
`${baseUrl(config)}/api/projects/${config.projectId}/feature_flags/${flagId}/activity/?limit=100`,
|
|
108
|
+
{ headers: headers(config) }
|
|
109
|
+
);
|
|
110
|
+
if (!res.ok) return null;
|
|
111
|
+
const data = await res.json();
|
|
112
|
+
for (const entry of data.results) {
|
|
113
|
+
const changes = entry.detail?.changes ?? [];
|
|
114
|
+
for (const change of changes) {
|
|
115
|
+
if (change.field !== "tags") continue;
|
|
116
|
+
const before = Array.isArray(change.before) ? change.before : [];
|
|
117
|
+
const after = Array.isArray(change.after) ? change.after : [];
|
|
118
|
+
if (!before.includes(tag) && after.includes(tag)) {
|
|
119
|
+
return new Date(entry.created_at);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
async function patchFlag(config, flagId, patch, options) {
|
|
126
|
+
if (options?.dryRun) return;
|
|
127
|
+
const res = await fetchWithRetry(
|
|
128
|
+
`${baseUrl(config)}/api/projects/${config.projectId}/feature_flags/${flagId}/`,
|
|
129
|
+
{
|
|
130
|
+
method: "PATCH",
|
|
131
|
+
headers: jsonHeaders(config),
|
|
132
|
+
body: JSON.stringify(patch)
|
|
133
|
+
}
|
|
134
|
+
);
|
|
135
|
+
if (!res.ok) {
|
|
136
|
+
throw new Error(`PostHog flag PATCH failed: ${res.status} ${await res.text().catch(() => "")}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
async function patchFlagTags(config, flagId, tags, options) {
|
|
140
|
+
return patchFlag(config, flagId, { tags }, options);
|
|
141
|
+
}
|
|
142
|
+
async function createFlag(config, body, options) {
|
|
143
|
+
if (options?.dryRun) return null;
|
|
144
|
+
const payload = {
|
|
145
|
+
key: body.key,
|
|
146
|
+
name: body.name,
|
|
147
|
+
tags: body.tags,
|
|
148
|
+
active: body.active ?? false,
|
|
149
|
+
filters: body.filters ?? {
|
|
150
|
+
groups: [{ properties: [], rollout_percentage: 0 }]
|
|
151
|
+
},
|
|
152
|
+
creation_context: "feature_flags"
|
|
153
|
+
};
|
|
154
|
+
const res = await fetchWithRetry(
|
|
155
|
+
`${baseUrl(config)}/api/projects/${config.projectId}/feature_flags/`,
|
|
156
|
+
{
|
|
157
|
+
method: "POST",
|
|
158
|
+
headers: jsonHeaders(config),
|
|
159
|
+
body: JSON.stringify(payload)
|
|
160
|
+
}
|
|
161
|
+
);
|
|
162
|
+
if (!res.ok) {
|
|
163
|
+
throw new Error(
|
|
164
|
+
`PostHog flag CREATE failed: ${res.status} ${await res.text().catch(() => "")}`
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
return await res.json();
|
|
168
|
+
}
|
|
169
|
+
function isFullyReleased(flag) {
|
|
170
|
+
if (!flag.active || flag.deleted) return false;
|
|
171
|
+
const groups = flag.filters?.groups ?? [];
|
|
172
|
+
if (groups.length === 0) return false;
|
|
173
|
+
return groups.every((g) => (g.rollout_percentage ?? 100) === 100);
|
|
174
|
+
}
|
|
175
|
+
function hasTag(flag, tag) {
|
|
176
|
+
return (flag.tags ?? []).includes(tag);
|
|
177
|
+
}
|
|
178
|
+
function withTagAdded(flag, tag) {
|
|
179
|
+
const existing = flag.tags ?? [];
|
|
180
|
+
if (existing.includes(tag)) return existing;
|
|
181
|
+
return [...existing, tag];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// src/posthog/hogql.ts
|
|
185
|
+
var FLAG_KEY_REGEX = /^[a-z0-9_-]+$/;
|
|
186
|
+
function normalizeVariant(raw) {
|
|
187
|
+
if (raw == null) return "unknown";
|
|
188
|
+
const s = String(raw).toLowerCase();
|
|
189
|
+
if (s === "false" || s === "0") return "control";
|
|
190
|
+
if (s === "true" || s === "1") return "treatment";
|
|
191
|
+
return "unknown";
|
|
192
|
+
}
|
|
193
|
+
async function queryCohortMetrics(params) {
|
|
194
|
+
const { config, flagKey, windowStart, windowEnd } = params;
|
|
195
|
+
const publishEvent = params.publishEventName ?? "post/publish.completed";
|
|
196
|
+
const publishProp = params.publishSuccessProp ?? "properties.success";
|
|
197
|
+
if (!FLAG_KEY_REGEX.test(flagKey)) {
|
|
198
|
+
throw new Error(
|
|
199
|
+
`Invalid flag key for HogQL interpolation: "${flagKey}". Must match ${FLAG_KEY_REGEX}`
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
const baseUrl2 = config.baseUrl ?? "https://us.posthog.com";
|
|
203
|
+
const variantProp = `properties.$feature/${flagKey}`;
|
|
204
|
+
const hogql = `
|
|
205
|
+
SELECT
|
|
206
|
+
toString(${variantProp}) AS variant,
|
|
207
|
+
count() AS total_events,
|
|
208
|
+
count(DISTINCT distinct_id) AS unique_users,
|
|
209
|
+
countIf(event = '$exception') AS error_events,
|
|
210
|
+
countIf(event = '${publishEvent}' AND ${publishProp} = true) AS publish_successes,
|
|
211
|
+
countIf(event = '${publishEvent}') AS publish_total
|
|
212
|
+
FROM events
|
|
213
|
+
WHERE timestamp >= toDateTime('${windowStart.toISOString()}')
|
|
214
|
+
AND timestamp < toDateTime('${windowEnd.toISOString()}')
|
|
215
|
+
AND ${variantProp} IS NOT NULL
|
|
216
|
+
GROUP BY variant
|
|
217
|
+
`;
|
|
218
|
+
const res = await fetchWithRetry(`${baseUrl2}/api/projects/${config.projectId}/query/`, {
|
|
219
|
+
method: "POST",
|
|
220
|
+
headers: {
|
|
221
|
+
Authorization: `Bearer ${config.apiKey}`,
|
|
222
|
+
"Content-Type": "application/json"
|
|
223
|
+
},
|
|
224
|
+
body: JSON.stringify({
|
|
225
|
+
query: { kind: "HogQLQuery", query: hogql }
|
|
226
|
+
})
|
|
227
|
+
});
|
|
228
|
+
if (!res.ok) {
|
|
229
|
+
throw new Error(
|
|
230
|
+
`PostHog HogQL query failed for ${flagKey}: ${res.status} ${await res.text().catch(() => "")}`
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
const data = await res.json();
|
|
234
|
+
const zero = {
|
|
235
|
+
eventCount: 0,
|
|
236
|
+
uniqueUsers: 0,
|
|
237
|
+
errorRate: null,
|
|
238
|
+
publishSuccessRate: null
|
|
239
|
+
};
|
|
240
|
+
const metrics = {
|
|
241
|
+
treatment: { ...zero },
|
|
242
|
+
control: { ...zero }
|
|
243
|
+
};
|
|
244
|
+
for (const row of data.results ?? []) {
|
|
245
|
+
const [variantRaw, totalEvents, uniqueUsers, errorEvents, publishSuccesses, publishTotal] = row;
|
|
246
|
+
const cohort = {
|
|
247
|
+
eventCount: Number(totalEvents ?? 0),
|
|
248
|
+
uniqueUsers: Number(uniqueUsers ?? 0),
|
|
249
|
+
errorRate: totalEvents > 0 ? Number(errorEvents ?? 0) / Number(totalEvents) : null,
|
|
250
|
+
publishSuccessRate: publishTotal > 0 ? Number(publishSuccesses ?? 0) / Number(publishTotal) : null
|
|
251
|
+
};
|
|
252
|
+
const variant = normalizeVariant(variantRaw);
|
|
253
|
+
if (variant === "treatment") metrics.treatment = cohort;
|
|
254
|
+
else if (variant === "control") metrics.control = cohort;
|
|
255
|
+
}
|
|
256
|
+
return metrics;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// src/step-runner.ts
|
|
260
|
+
var SimpleStepRunner = class {
|
|
261
|
+
async run(_id, fn) {
|
|
262
|
+
return fn();
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
// src/guardian/thresholds.ts
|
|
267
|
+
var DEFAULT_THRESHOLDS = {
|
|
268
|
+
windowMinutes: 20,
|
|
269
|
+
minSampleSize: 100,
|
|
270
|
+
minUniqueUsers: 50,
|
|
271
|
+
errorRateRatioThreshold: 2,
|
|
272
|
+
publishSuccessDropThreshold: 0.15,
|
|
273
|
+
cooldownMinutes: 30
|
|
274
|
+
};
|
|
275
|
+
function mergeThresholds(overrides) {
|
|
276
|
+
return { ...DEFAULT_THRESHOLDS, ...overrides };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// src/guardian/run-flag-guardian.ts
|
|
280
|
+
var GUARDIAN_TAG = "guardian";
|
|
281
|
+
var GUARDIAN_ENFORCE_TAG = "guardian-enforce";
|
|
282
|
+
function isInCooldown(flag, cooldownMinutes) {
|
|
283
|
+
if (!flag.updated_at) return false;
|
|
284
|
+
const updated = new Date(flag.updated_at).getTime();
|
|
285
|
+
return Date.now() - updated < cooldownMinutes * 60 * 1e3;
|
|
286
|
+
}
|
|
287
|
+
function posthogFlagUrl(projectId, flagId, baseUrl2 = "https://us.posthog.com") {
|
|
288
|
+
return `${baseUrl2}/project/${projectId}/feature_flags/${flagId}`;
|
|
289
|
+
}
|
|
290
|
+
async function runFlagGuardian(options) {
|
|
291
|
+
const {
|
|
292
|
+
posthog,
|
|
293
|
+
step = new SimpleStepRunner(),
|
|
294
|
+
dryRun = false,
|
|
295
|
+
callbacks = {},
|
|
296
|
+
logger = consoleLogger
|
|
297
|
+
} = options;
|
|
298
|
+
const thresholds = mergeThresholds(options.thresholds);
|
|
299
|
+
logger.info("Flag guardian started");
|
|
300
|
+
const monitoredFlags = await step.run("fetch-guardian-flags", async () => {
|
|
301
|
+
const all = await fetchFlagsByTag(posthog, GUARDIAN_TAG);
|
|
302
|
+
return all.filter((f) => f.active && !f.deleted && hasTag(f, GUARDIAN_TAG));
|
|
303
|
+
});
|
|
304
|
+
const windowEnd = /* @__PURE__ */ new Date();
|
|
305
|
+
const windowStart = new Date(windowEnd.getTime() - thresholds.windowMinutes * 60 * 1e3);
|
|
306
|
+
const regressions = [];
|
|
307
|
+
const perFlagOutcomes = [];
|
|
308
|
+
for (const flag of monitoredFlags) {
|
|
309
|
+
const enforceEnabled = hasTag(flag, GUARDIAN_ENFORCE_TAG);
|
|
310
|
+
if (isInCooldown(flag, thresholds.cooldownMinutes)) {
|
|
311
|
+
perFlagOutcomes.push({
|
|
312
|
+
flag_key: flag.key,
|
|
313
|
+
decision: "insufficient_data",
|
|
314
|
+
reason: "cooldown"
|
|
315
|
+
});
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
const metrics = await step.run(`evaluate-${flag.id}`, async () => {
|
|
319
|
+
return queryCohortMetrics({
|
|
320
|
+
config: posthog,
|
|
321
|
+
flagKey: flag.key,
|
|
322
|
+
windowStart,
|
|
323
|
+
windowEnd,
|
|
324
|
+
publishEventName: options.publishEventName,
|
|
325
|
+
publishSuccessProp: options.publishSuccessProp
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
if (!meetsSampleFloor(metrics, thresholds)) {
|
|
329
|
+
perFlagOutcomes.push({
|
|
330
|
+
flag_key: flag.key,
|
|
331
|
+
decision: "insufficient_data",
|
|
332
|
+
reason: "sample_size"
|
|
333
|
+
});
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
const decision = detectRegression(metrics, thresholds);
|
|
337
|
+
if (decision.kind === "no_regression") {
|
|
338
|
+
const evaluation2 = {
|
|
339
|
+
flag,
|
|
340
|
+
metrics,
|
|
341
|
+
decision: "no_regression",
|
|
342
|
+
reason: decision.reason,
|
|
343
|
+
enforced: false
|
|
344
|
+
};
|
|
345
|
+
await callbacks.onEvaluated?.(evaluation2);
|
|
346
|
+
perFlagOutcomes.push({
|
|
347
|
+
flag_key: flag.key,
|
|
348
|
+
decision: "no_regression",
|
|
349
|
+
reason: decision.reason
|
|
350
|
+
});
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
let enforced = false;
|
|
354
|
+
if (enforceEnabled && !dryRun) {
|
|
355
|
+
await step.run(`enforce-${flag.id}`, async () => {
|
|
356
|
+
await patchFlag(posthog, flag.id, { active: false }, { dryRun });
|
|
357
|
+
});
|
|
358
|
+
enforced = true;
|
|
359
|
+
}
|
|
360
|
+
const finalDecision = enforced ? "auto_disabled" : "regression_detected";
|
|
361
|
+
const evaluation = {
|
|
362
|
+
flag,
|
|
363
|
+
metrics,
|
|
364
|
+
decision: finalDecision,
|
|
365
|
+
reason: decision.reason,
|
|
366
|
+
enforced
|
|
367
|
+
};
|
|
368
|
+
await callbacks.onEvaluated?.(evaluation);
|
|
369
|
+
if (decision.kind === "regression_detected") {
|
|
370
|
+
await callbacks.onRegression?.(evaluation);
|
|
371
|
+
}
|
|
372
|
+
if (enforced) {
|
|
373
|
+
await callbacks.onEnforced?.(flag, evaluation);
|
|
374
|
+
}
|
|
375
|
+
regressions.push(evaluation);
|
|
376
|
+
perFlagOutcomes.push({
|
|
377
|
+
flag_key: flag.key,
|
|
378
|
+
decision: finalDecision,
|
|
379
|
+
reason: decision.reason
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
const result = {
|
|
383
|
+
flags_evaluated: monitoredFlags.length,
|
|
384
|
+
regressions_detected: regressions.length,
|
|
385
|
+
auto_disabled: regressions.filter((r) => r.enforced).length,
|
|
386
|
+
outcomes: perFlagOutcomes
|
|
387
|
+
};
|
|
388
|
+
logger.info("Flag guardian completed", {
|
|
389
|
+
flags_evaluated: result.flags_evaluated,
|
|
390
|
+
regressions_detected: result.regressions_detected,
|
|
391
|
+
auto_disabled: result.auto_disabled
|
|
392
|
+
});
|
|
393
|
+
return result;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// src/registry/naming.ts
|
|
397
|
+
function buildNamingRegex(areas) {
|
|
398
|
+
return new RegExp(`^(release|experiment|ops|tier)_(${areas.join("|")})_[a-z0-9_]+$`);
|
|
399
|
+
}
|
|
400
|
+
function getLifecycle(key) {
|
|
401
|
+
return key.split("_")[0];
|
|
402
|
+
}
|
|
403
|
+
function getArea(key) {
|
|
404
|
+
return key.split("_")[1];
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// src/registry/define.ts
|
|
408
|
+
function createRegistry(config) {
|
|
409
|
+
const NAMING_REGEX = buildNamingRegex(config.areas);
|
|
410
|
+
function flag(name, registry) {
|
|
411
|
+
return registry[name].key;
|
|
412
|
+
}
|
|
413
|
+
return {
|
|
414
|
+
NAMING_REGEX,
|
|
415
|
+
flag,
|
|
416
|
+
getLifecycle,
|
|
417
|
+
getArea: (key) => getArea(key)
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// src/release-tracker/run-release-tracker.ts
|
|
422
|
+
var RELEASED_TAG = "released-detected-v1";
|
|
423
|
+
var STALE_THRESHOLD_DAYS = 30;
|
|
424
|
+
function staleTagForNow() {
|
|
425
|
+
const now = /* @__PURE__ */ new Date();
|
|
426
|
+
const yyyy = now.getUTCFullYear();
|
|
427
|
+
const mm = String(now.getUTCMonth() + 1).padStart(2, "0");
|
|
428
|
+
return `stale-notified-${yyyy}-${mm}`;
|
|
429
|
+
}
|
|
430
|
+
async function runReleaseTracker(options) {
|
|
431
|
+
const {
|
|
432
|
+
posthog,
|
|
433
|
+
registry,
|
|
434
|
+
namingRegex,
|
|
435
|
+
namingExemptTag = "naming-exempt",
|
|
436
|
+
staleThresholdDays = STALE_THRESHOLD_DAYS,
|
|
437
|
+
step = new SimpleStepRunner(),
|
|
438
|
+
dryRun = false,
|
|
439
|
+
callbacks = {},
|
|
440
|
+
logger = consoleLogger
|
|
441
|
+
} = options;
|
|
442
|
+
logger.info("Feature release tracker started");
|
|
443
|
+
const flags = await step.run("fetch-flags", () => fetchAllFlags(posthog));
|
|
444
|
+
const registryKeys = new Set(Object.values(registry).map((d) => d.key));
|
|
445
|
+
const orphans = flags.filter((f) => !f.deleted && !registryKeys.has(f.key)).map((f) => ({ key: f.key }));
|
|
446
|
+
const namingViolations = flags.filter((f) => !f.deleted && !hasTag(f, namingExemptTag) && !namingRegex.test(f.key)).map((f) => ({ key: f.key }));
|
|
447
|
+
for (const o of orphans) {
|
|
448
|
+
const posthogFlag = flags.find((f) => f.key === o.key);
|
|
449
|
+
if (posthogFlag) await callbacks.onOrphan?.(posthogFlag);
|
|
450
|
+
}
|
|
451
|
+
for (const v of namingViolations) {
|
|
452
|
+
const posthogFlag = flags.find((f) => f.key === v.key);
|
|
453
|
+
if (posthogFlag) await callbacks.onNamingViolation?.(posthogFlag);
|
|
454
|
+
}
|
|
455
|
+
const newlyReleased = flags.filter((f) => isFullyReleased(f) && !hasTag(f, RELEASED_TAG));
|
|
456
|
+
const newFlagReleases = [];
|
|
457
|
+
for (const flag of newlyReleased) {
|
|
458
|
+
await step.run(`tag-released-${flag.id}`, async () => {
|
|
459
|
+
await patchFlagTags(posthog, flag.id, withTagAdded(flag, RELEASED_TAG), {
|
|
460
|
+
dryRun
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
await callbacks.onNewRelease?.(flag, "flag");
|
|
464
|
+
newFlagReleases.push({
|
|
465
|
+
key: flag.key,
|
|
466
|
+
name: flag.name ?? flag.key,
|
|
467
|
+
type: "flag"
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
const experiments = await step.run(
|
|
471
|
+
"fetch-experiments",
|
|
472
|
+
() => fetchAllExperiments(posthog)
|
|
473
|
+
);
|
|
474
|
+
const newExperimentReleases = [];
|
|
475
|
+
const flagsByKey = new Map(flags.map((f) => [f.key, f]));
|
|
476
|
+
const now = Date.now();
|
|
477
|
+
const completedExperiments = experiments.filter((e) => {
|
|
478
|
+
if (e.archived) return false;
|
|
479
|
+
if (!e.end_date) return false;
|
|
480
|
+
return new Date(e.end_date).getTime() <= now;
|
|
481
|
+
});
|
|
482
|
+
for (const exp of completedExperiments) {
|
|
483
|
+
const linkedFlag = flagsByKey.get(exp.feature_flag_key);
|
|
484
|
+
if (!linkedFlag) continue;
|
|
485
|
+
if (hasTag(linkedFlag, RELEASED_TAG)) continue;
|
|
486
|
+
await step.run(`tag-experiment-released-${exp.id}`, async () => {
|
|
487
|
+
await patchFlagTags(posthog, linkedFlag.id, withTagAdded(linkedFlag, RELEASED_TAG), {
|
|
488
|
+
dryRun
|
|
489
|
+
});
|
|
490
|
+
});
|
|
491
|
+
await callbacks.onNewRelease?.(linkedFlag, "experiment");
|
|
492
|
+
newExperimentReleases.push({
|
|
493
|
+
key: exp.feature_flag_key,
|
|
494
|
+
name: exp.name,
|
|
495
|
+
type: "experiment"
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
const STALE_ELIGIBLE_LIFECYCLES = /* @__PURE__ */ new Set(["release", "experiment"]);
|
|
499
|
+
const staleTag = staleTagForNow();
|
|
500
|
+
const staleCandidates = flags.filter(
|
|
501
|
+
(f) => hasTag(f, RELEASED_TAG) && !hasTag(f, staleTag) && STALE_ELIGIBLE_LIFECYCLES.has(getLifecycle(f.key))
|
|
502
|
+
);
|
|
503
|
+
const staleFlags = [];
|
|
504
|
+
for (const flag of staleCandidates) {
|
|
505
|
+
const releasedAt = await step.run(`find-release-time-${flag.id}`, async () => {
|
|
506
|
+
const t = await findTagAddedAt(posthog, flag.id, RELEASED_TAG);
|
|
507
|
+
return t ? t.toISOString() : null;
|
|
508
|
+
});
|
|
509
|
+
if (!releasedAt) continue;
|
|
510
|
+
const days = Math.floor((now - new Date(releasedAt).getTime()) / (24 * 60 * 60 * 1e3));
|
|
511
|
+
if (days < staleThresholdDays) continue;
|
|
512
|
+
await step.run(`tag-stale-${flag.id}`, async () => {
|
|
513
|
+
await patchFlagTags(posthog, flag.id, withTagAdded(flag, staleTag), {
|
|
514
|
+
dryRun
|
|
515
|
+
});
|
|
516
|
+
});
|
|
517
|
+
await callbacks.onStale?.(flag, days);
|
|
518
|
+
staleFlags.push({
|
|
519
|
+
key: flag.key,
|
|
520
|
+
name: flag.name ?? flag.key,
|
|
521
|
+
daysSinceRelease: days
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
const anyNews = newFlagReleases.length + newExperimentReleases.length + staleFlags.length + orphans.length + namingViolations.length > 0;
|
|
525
|
+
if (anyNews) {
|
|
526
|
+
await callbacks.onDigestReady?.({
|
|
527
|
+
newFlagReleases,
|
|
528
|
+
newExperimentReleases,
|
|
529
|
+
staleFlags,
|
|
530
|
+
orphans,
|
|
531
|
+
namingViolations
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
const result = {
|
|
535
|
+
flags_checked: flags.length,
|
|
536
|
+
experiments_checked: experiments.length,
|
|
537
|
+
new_flag_releases: newFlagReleases.length,
|
|
538
|
+
new_experiment_releases: newExperimentReleases.length,
|
|
539
|
+
stale_flags_notified: staleFlags.length,
|
|
540
|
+
orphans: orphans.length,
|
|
541
|
+
naming_violations: namingViolations.length
|
|
542
|
+
};
|
|
543
|
+
logger.info("Feature release tracker completed", result);
|
|
544
|
+
return result;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// src/sync/run-flag-sync.ts
|
|
548
|
+
function unionTags(current, wanted) {
|
|
549
|
+
const currentSet = new Set(current ?? []);
|
|
550
|
+
const merged = new Set(currentSet);
|
|
551
|
+
for (const t of wanted) merged.add(t);
|
|
552
|
+
if (merged.size === currentSet.size) return null;
|
|
553
|
+
return [...merged];
|
|
554
|
+
}
|
|
555
|
+
function initialTagsFor(def) {
|
|
556
|
+
const tags = new Set(def.tags ?? []);
|
|
557
|
+
if (def.guardian) tags.add("guardian");
|
|
558
|
+
return [...tags];
|
|
559
|
+
}
|
|
560
|
+
async function runFlagSync(options) {
|
|
561
|
+
const {
|
|
562
|
+
posthog,
|
|
563
|
+
registry,
|
|
564
|
+
namingRegex,
|
|
565
|
+
namingExemptTag = "naming-exempt",
|
|
566
|
+
step = new SimpleStepRunner(),
|
|
567
|
+
dryRun = false,
|
|
568
|
+
callbacks = {},
|
|
569
|
+
logger = consoleLogger
|
|
570
|
+
} = options;
|
|
571
|
+
logger.info("Feature flag sync started");
|
|
572
|
+
const posthogFlags = await step.run(
|
|
573
|
+
"fetch-posthog-flags",
|
|
574
|
+
() => fetchAllFlags(posthog)
|
|
575
|
+
);
|
|
576
|
+
const posthogByKey = new Map(posthogFlags.map((f) => [f.key, f]));
|
|
577
|
+
const registryEntries = Object.values(registry);
|
|
578
|
+
const registryKeys = new Set(registryEntries.map((d) => d.key));
|
|
579
|
+
const toCreate = registryEntries.filter((d) => !posthogByKey.has(d.key));
|
|
580
|
+
const toReconcile = registryEntries.filter((d) => posthogByKey.has(d.key)).flatMap((d) => {
|
|
581
|
+
const existing = posthogByKey.get(d.key);
|
|
582
|
+
if (!existing) return [];
|
|
583
|
+
return [{ def: d, existing }];
|
|
584
|
+
});
|
|
585
|
+
const orphans = posthogFlags.filter((f) => !f.deleted && !registryKeys.has(f.key));
|
|
586
|
+
const namingViolations = posthogFlags.filter(
|
|
587
|
+
(f) => !f.deleted && !hasTag(f, namingExemptTag) && !namingRegex.test(f.key)
|
|
588
|
+
);
|
|
589
|
+
const created = [];
|
|
590
|
+
for (const def of toCreate) {
|
|
591
|
+
await step.run(`create-${def.key}`, async () => {
|
|
592
|
+
const filters = {
|
|
593
|
+
groups: [{ properties: [], rollout_percentage: 0 }]
|
|
594
|
+
};
|
|
595
|
+
await createFlag(
|
|
596
|
+
posthog,
|
|
597
|
+
{
|
|
598
|
+
key: def.key,
|
|
599
|
+
name: def.description,
|
|
600
|
+
tags: initialTagsFor(def),
|
|
601
|
+
active: false,
|
|
602
|
+
filters
|
|
603
|
+
},
|
|
604
|
+
{ dryRun }
|
|
605
|
+
);
|
|
606
|
+
});
|
|
607
|
+
created.push(def.key);
|
|
608
|
+
await callbacks.onCreate?.(def);
|
|
609
|
+
}
|
|
610
|
+
const reconciled = [];
|
|
611
|
+
for (const { def, existing } of toReconcile) {
|
|
612
|
+
const patch = {};
|
|
613
|
+
if (existing.name !== def.description) {
|
|
614
|
+
patch.name = def.description;
|
|
615
|
+
}
|
|
616
|
+
const wanted = [...def.tags ?? []];
|
|
617
|
+
if (def.guardian) wanted.push("guardian");
|
|
618
|
+
const newTags = unionTags(existing.tags, wanted);
|
|
619
|
+
if (newTags) patch.tags = newTags;
|
|
620
|
+
if (Object.keys(patch).length === 0) continue;
|
|
621
|
+
await step.run(`reconcile-${def.key}`, async () => {
|
|
622
|
+
await patchFlag(posthog, existing.id, patch, { dryRun });
|
|
623
|
+
});
|
|
624
|
+
reconciled.push(def.key);
|
|
625
|
+
await callbacks.onReconcile?.(existing, patch);
|
|
626
|
+
}
|
|
627
|
+
for (const orphan of orphans) {
|
|
628
|
+
await callbacks.onOrphan?.(orphan);
|
|
629
|
+
}
|
|
630
|
+
for (const violator of namingViolations) {
|
|
631
|
+
await callbacks.onNamingViolation?.(violator);
|
|
632
|
+
}
|
|
633
|
+
const result = {
|
|
634
|
+
registry_size: registryEntries.length,
|
|
635
|
+
posthog_size: posthogFlags.length,
|
|
636
|
+
created: created.length,
|
|
637
|
+
reconciled: reconciled.length,
|
|
638
|
+
orphans: orphans.length,
|
|
639
|
+
naming_violations: namingViolations.length
|
|
640
|
+
};
|
|
641
|
+
logger.info("Feature flag sync completed", result);
|
|
642
|
+
return result;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
export { DEFAULT_THRESHOLDS, SimpleStepRunner, buildNamingRegex, consoleLogger, createFlag, createRegistry, detectRegression, fetchAllExperiments, fetchAllFlags, fetchFlagsByTag, findTagAddedAt, fmtPct, getArea, getLifecycle, hasTag, isFullyReleased, meetsSampleFloor, mergeThresholds, patchFlag, patchFlagTags, posthogFlagUrl, queryCohortMetrics, runFlagGuardian, runFlagSync, runReleaseTracker, withTagAdded };
|
|
646
|
+
//# sourceMappingURL=index.js.map
|
|
647
|
+
//# sourceMappingURL=index.js.map
|