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/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