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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 backhandvolley
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,184 @@
1
+ # posthog-flag-toolkit
2
+
3
+ [![CI](https://github.com/backhandvolley/posthog-flag-toolkit/actions/workflows/ci.yml/badge.svg)](https://github.com/backhandvolley/posthog-flag-toolkit/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/posthog-flag-toolkit)](https://www.npmjs.com/package/posthog-flag-toolkit)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
6
+
7
+ Type-safe feature flag registry, one-way sync, release tracking, and auto-rollback guardian for [PostHog](https://posthog.com). Framework-agnostic core with optional adapters for Inngest and Slack.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm install posthog-flag-toolkit
13
+ ```
14
+
15
+ ## Quick Start
16
+
17
+ ### 1. Define your registry
18
+
19
+ ```typescript
20
+ import { createRegistry, type FlagDefinition } from "posthog-flag-toolkit";
21
+
22
+ const { NAMING_REGEX, getLifecycle, getArea } = createRegistry({
23
+ areas: ["studio", "ai", "billing", "auth"] as const,
24
+ });
25
+
26
+ const registry = {
27
+ RELEASE_STUDIO_NEW_EDITOR: {
28
+ key: "release_studio_new_editor",
29
+ description: "Gates the new Tiptap-based editor in the studio.",
30
+ owner: "@yourname",
31
+ tags: ["studio"],
32
+ guardian: true,
33
+ },
34
+ } as const satisfies Record<string, FlagDefinition>;
35
+ ```
36
+
37
+ ### 2. Sync flags to PostHog
38
+
39
+ ```typescript
40
+ import { runFlagSync } from "posthog-flag-toolkit";
41
+
42
+ const result = await runFlagSync({
43
+ posthog: { apiKey: "phx_...", projectId: "12345" },
44
+ registry,
45
+ namingRegex: NAMING_REGEX,
46
+ callbacks: {
47
+ onCreate: (def) => console.log(`Created ${def.key}`),
48
+ onOrphan: (flag) => console.log(`Orphan: ${flag.key}`),
49
+ },
50
+ });
51
+ ```
52
+
53
+ ### 3. Track releases
54
+
55
+ ```typescript
56
+ import { runReleaseTracker } from "posthog-flag-toolkit";
57
+
58
+ const result = await runReleaseTracker({
59
+ posthog: { apiKey: "phx_...", projectId: "12345" },
60
+ registry,
61
+ namingRegex: NAMING_REGEX,
62
+ callbacks: {
63
+ onNewRelease: (flag, type) => console.log(`Released: ${flag.key} (${type})`),
64
+ onStale: (flag, days) => console.log(`Stale: ${flag.key} (${days} days)`),
65
+ onDigestReady: (digest) => sendEmail(digest),
66
+ },
67
+ });
68
+ ```
69
+
70
+ ### 4. Auto-rollback with Guardian
71
+
72
+ ```typescript
73
+ import { runFlagGuardian } from "posthog-flag-toolkit";
74
+
75
+ const result = await runFlagGuardian({
76
+ posthog: { apiKey: "phx_...", projectId: "12345" },
77
+ thresholds: { errorRateRatioThreshold: 2.0 },
78
+ callbacks: {
79
+ onRegression: (evaluation) => alert(evaluation),
80
+ onEnforced: (flag) => console.log(`Auto-disabled: ${flag.key}`),
81
+ },
82
+ });
83
+ ```
84
+
85
+ ## Inngest Adapter
86
+
87
+ For durable execution with [Inngest](https://www.inngest.com/), pass `step` as the `StepRunner`:
88
+
89
+ ```typescript
90
+ import { runFlagSync } from "posthog-flag-toolkit";
91
+
92
+ export const syncFn = inngest.createFunction(
93
+ { id: "flag-sync" },
94
+ { cron: "*/10 * * * *" },
95
+ async ({ step }) => {
96
+ return runFlagSync({ posthog: config, registry, namingRegex, step });
97
+ },
98
+ );
99
+ ```
100
+
101
+ Or use the `withInngestCron` helper:
102
+
103
+ ```typescript
104
+ import { withInngestCron } from "posthog-flag-toolkit/adapters/inngest";
105
+
106
+ export const syncFn = withInngestCron(inngest, {
107
+ id: "flag-sync",
108
+ name: "Sync flags",
109
+ cron: "*/10 * * * *",
110
+ run: (step) => runFlagSync({ posthog: config, registry, namingRegex, step }),
111
+ });
112
+ ```
113
+
114
+ ## Slack Adapter
115
+
116
+ ```typescript
117
+ import { postGuardianAlert } from "posthog-flag-toolkit/adapters/slack";
118
+
119
+ await postGuardianAlert(webhookUrl, {
120
+ severity: "critical",
121
+ flagKey: "release_studio_new_editor",
122
+ flagName: "New Editor",
123
+ decision: "auto_disabled",
124
+ enforced: true,
125
+ reason: "error rate ratio 3.2x",
126
+ metrics: { treatmentErrorRate: 0.15, controlErrorRate: 0.047 },
127
+ posthogFlagUrl: "https://us.posthog.com/project/12345/feature_flags/42",
128
+ });
129
+ ```
130
+
131
+ ## API
132
+
133
+ ### Core Functions
134
+
135
+ | Function | Description |
136
+ |---|---|
137
+ | `runFlagSync(options)` | One-way sync from registry to PostHog (create, reconcile, detect orphans) |
138
+ | `runReleaseTracker(options)` | Detect 100% rollout releases, stale flags, experiments |
139
+ | `runFlagGuardian(options)` | Compare treatment/control cohorts, auto-disable on regression |
140
+ | `createRegistry(config)` | Factory for typed flag registry with naming regex |
141
+
142
+ ### PostHog API Helpers
143
+
144
+ | Function | Description |
145
+ |---|---|
146
+ | `fetchAllFlags(config)` | Paginated fetch of all flags |
147
+ | `fetchFlagsByTag(config, tag)` | Fetch flags by tag (substring filter) |
148
+ | `createFlag(config, body)` | Create a new flag |
149
+ | `patchFlag(config, flagId, patch)` | Update a flag |
150
+ | `isFullyReleased(flag)` | Check if flag is active + 100% rollout |
151
+ | `queryCohortMetrics(params)` | HogQL query for treatment/control metrics |
152
+
153
+ ### Guardian Decision Logic
154
+
155
+ | Function | Description |
156
+ |---|---|
157
+ | `detectRegression(metrics, thresholds)` | Pure function: compare metrics against thresholds |
158
+ | `meetsSampleFloor(metrics, thresholds)` | Check if both cohorts have enough data |
159
+ | `mergeThresholds(overrides?)` | Merge custom thresholds with defaults |
160
+
161
+ All `run*` functions accept an optional `step: StepRunner` parameter. Inngest's `step` object satisfies the interface natively. Without it, a `SimpleStepRunner` is used (direct execution, no durability).
162
+
163
+ ## Configuration
164
+
165
+ ### PostHogClientConfig
166
+
167
+ ```typescript
168
+ { apiKey: string; projectId: string; baseUrl?: string }
169
+ ```
170
+
171
+ ### GuardianThresholds
172
+
173
+ | Option | Default | Description |
174
+ |---|---|---|
175
+ | `windowMinutes` | 20 | HogQL lookback window |
176
+ | `minSampleSize` | 100 | Min events per cohort |
177
+ | `minUniqueUsers` | 50 | Min distinct users per cohort |
178
+ | `errorRateRatioThreshold` | 2.0 | Treatment/control error rate ratio |
179
+ | `publishSuccessDropThreshold` | 0.15 | Max acceptable success rate drop (pp) |
180
+ | `cooldownMinutes` | 30 | Skip recently-updated flags |
181
+
182
+ ## License
183
+
184
+ MIT
@@ -0,0 +1,20 @@
1
+ 'use strict';
2
+
3
+ // src/adapters/inngest.ts
4
+ function withInngestCron(inngest, config) {
5
+ return inngest.createFunction(
6
+ {
7
+ id: config.id,
8
+ name: config.name,
9
+ retries: config.retries ?? 1
10
+ },
11
+ { cron: config.cron },
12
+ async ({ step }) => {
13
+ return config.run(step);
14
+ }
15
+ );
16
+ }
17
+
18
+ exports.withInngestCron = withInngestCron;
19
+ //# sourceMappingURL=inngest.cjs.map
20
+ //# sourceMappingURL=inngest.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/adapters/inngest.ts"],"names":[],"mappings":";;;AA4BO,SAAS,eAAA,CACd,SACA,MAAA,EACA;AACA,EAAA,OAAO,OAAA,CAAQ,cAAA;AAAA,IACb;AAAA,MACE,IAAI,MAAA,CAAO,EAAA;AAAA,MACX,MAAM,MAAA,CAAO,IAAA;AAAA,MACb,OAAA,EAAS,OAAO,OAAA,IAAW;AAAA,KAC7B;AAAA,IACA,EAAE,IAAA,EAAM,MAAA,CAAO,IAAA,EAAK;AAAA,IACpB,OAAO,EAAE,IAAA,EAAK,KAA8E;AAC1F,MAAA,OAAO,MAAA,CAAO,IAAI,IAAI,CAAA;AAAA,IACxB;AAAA,GACF;AACF","file":"inngest.cjs","sourcesContent":["/**\n * Inngest adapter — wraps `run*` functions into `inngest.createFunction` calls,\n * passing `step` through as the StepRunner.\n */\n\n// biome-ignore lint/suspicious/noExplicitAny: Inngest client type varies by consumer's event map\ntype InngestClient = any;\n\nexport interface InngestCronConfig<TResult> {\n id: string;\n name: string;\n cron: string;\n retries?: number;\n run: (step: { run: <T>(id: string, fn: () => Promise<T>) => Promise<T> }) => Promise<TResult>;\n}\n\n/**\n * Wrap a pure `run*` function as an Inngest cron function.\n *\n * ```ts\n * const fn = withInngestCron(inngest, {\n * id: \"flag-sync\",\n * name: \"Sync flags\",\n * cron: \"* /10 * * * *\",\n * run: (step) => runFlagSync({ posthog: config, registry, namingRegex, step }),\n * });\n * ```\n */\nexport function withInngestCron<TResult>(\n inngest: InngestClient,\n config: InngestCronConfig<TResult>,\n) {\n return inngest.createFunction(\n {\n id: config.id,\n name: config.name,\n retries: config.retries ?? 1,\n },\n { cron: config.cron },\n async ({ step }: { step: { run: <T>(id: string, fn: () => Promise<T>) => Promise<T> } }) => {\n return config.run(step);\n },\n );\n}\n"]}
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Inngest adapter — wraps `run*` functions into `inngest.createFunction` calls,
3
+ * passing `step` through as the StepRunner.
4
+ */
5
+ type InngestClient = any;
6
+ interface InngestCronConfig<TResult> {
7
+ id: string;
8
+ name: string;
9
+ cron: string;
10
+ retries?: number;
11
+ run: (step: {
12
+ run: <T>(id: string, fn: () => Promise<T>) => Promise<T>;
13
+ }) => Promise<TResult>;
14
+ }
15
+ /**
16
+ * Wrap a pure `run*` function as an Inngest cron function.
17
+ *
18
+ * ```ts
19
+ * const fn = withInngestCron(inngest, {
20
+ * id: "flag-sync",
21
+ * name: "Sync flags",
22
+ * cron: "* /10 * * * *",
23
+ * run: (step) => runFlagSync({ posthog: config, registry, namingRegex, step }),
24
+ * });
25
+ * ```
26
+ */
27
+ declare function withInngestCron<TResult>(inngest: InngestClient, config: InngestCronConfig<TResult>): any;
28
+
29
+ export { type InngestCronConfig, withInngestCron };
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Inngest adapter — wraps `run*` functions into `inngest.createFunction` calls,
3
+ * passing `step` through as the StepRunner.
4
+ */
5
+ type InngestClient = any;
6
+ interface InngestCronConfig<TResult> {
7
+ id: string;
8
+ name: string;
9
+ cron: string;
10
+ retries?: number;
11
+ run: (step: {
12
+ run: <T>(id: string, fn: () => Promise<T>) => Promise<T>;
13
+ }) => Promise<TResult>;
14
+ }
15
+ /**
16
+ * Wrap a pure `run*` function as an Inngest cron function.
17
+ *
18
+ * ```ts
19
+ * const fn = withInngestCron(inngest, {
20
+ * id: "flag-sync",
21
+ * name: "Sync flags",
22
+ * cron: "* /10 * * * *",
23
+ * run: (step) => runFlagSync({ posthog: config, registry, namingRegex, step }),
24
+ * });
25
+ * ```
26
+ */
27
+ declare function withInngestCron<TResult>(inngest: InngestClient, config: InngestCronConfig<TResult>): any;
28
+
29
+ export { type InngestCronConfig, withInngestCron };
@@ -0,0 +1,18 @@
1
+ // src/adapters/inngest.ts
2
+ function withInngestCron(inngest, config) {
3
+ return inngest.createFunction(
4
+ {
5
+ id: config.id,
6
+ name: config.name,
7
+ retries: config.retries ?? 1
8
+ },
9
+ { cron: config.cron },
10
+ async ({ step }) => {
11
+ return config.run(step);
12
+ }
13
+ );
14
+ }
15
+
16
+ export { withInngestCron };
17
+ //# sourceMappingURL=inngest.js.map
18
+ //# sourceMappingURL=inngest.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/adapters/inngest.ts"],"names":[],"mappings":";AA4BO,SAAS,eAAA,CACd,SACA,MAAA,EACA;AACA,EAAA,OAAO,OAAA,CAAQ,cAAA;AAAA,IACb;AAAA,MACE,IAAI,MAAA,CAAO,EAAA;AAAA,MACX,MAAM,MAAA,CAAO,IAAA;AAAA,MACb,OAAA,EAAS,OAAO,OAAA,IAAW;AAAA,KAC7B;AAAA,IACA,EAAE,IAAA,EAAM,MAAA,CAAO,IAAA,EAAK;AAAA,IACpB,OAAO,EAAE,IAAA,EAAK,KAA8E;AAC1F,MAAA,OAAO,MAAA,CAAO,IAAI,IAAI,CAAA;AAAA,IACxB;AAAA,GACF;AACF","file":"inngest.js","sourcesContent":["/**\n * Inngest adapter — wraps `run*` functions into `inngest.createFunction` calls,\n * passing `step` through as the StepRunner.\n */\n\n// biome-ignore lint/suspicious/noExplicitAny: Inngest client type varies by consumer's event map\ntype InngestClient = any;\n\nexport interface InngestCronConfig<TResult> {\n id: string;\n name: string;\n cron: string;\n retries?: number;\n run: (step: { run: <T>(id: string, fn: () => Promise<T>) => Promise<T> }) => Promise<TResult>;\n}\n\n/**\n * Wrap a pure `run*` function as an Inngest cron function.\n *\n * ```ts\n * const fn = withInngestCron(inngest, {\n * id: \"flag-sync\",\n * name: \"Sync flags\",\n * cron: \"* /10 * * * *\",\n * run: (step) => runFlagSync({ posthog: config, registry, namingRegex, step }),\n * });\n * ```\n */\nexport function withInngestCron<TResult>(\n inngest: InngestClient,\n config: InngestCronConfig<TResult>,\n) {\n return inngest.createFunction(\n {\n id: config.id,\n name: config.name,\n retries: config.retries ?? 1,\n },\n { cron: config.cron },\n async ({ step }: { step: { run: <T>(id: string, fn: () => Promise<T>) => Promise<T> } }) => {\n return config.run(step);\n },\n );\n}\n"]}
@@ -0,0 +1,75 @@
1
+ 'use strict';
2
+
3
+ // src/adapters/slack.ts
4
+ function fmtPct(n) {
5
+ if (n == null) return "\u2014";
6
+ return `${(n * 100).toFixed(2)}%`;
7
+ }
8
+ async function postGuardianAlert(webhookUrl, opts) {
9
+ try {
10
+ if (!webhookUrl) return;
11
+ const color = opts.severity === "critical" ? "#DC2626" : "#EAB308";
12
+ const prefix = opts.enforced ? ":rotating_light: *FLAG AUTO-DISABLED*" : ":warning: *Regression detected (dry-run)*";
13
+ const mention = opts.enforced ? "<!channel> " : "";
14
+ const blocks = [
15
+ {
16
+ type: "section",
17
+ text: {
18
+ type: "mrkdwn",
19
+ text: `${mention}${prefix}
20
+ *Flag*: \`${opts.flagKey}\` \u2014 ${opts.flagName}`
21
+ }
22
+ },
23
+ {
24
+ type: "section",
25
+ fields: [
26
+ { type: "mrkdwn", text: `*Reason*
27
+ ${opts.reason}` },
28
+ { type: "mrkdwn", text: `*Decision*
29
+ ${opts.decision}` },
30
+ {
31
+ type: "mrkdwn",
32
+ text: `*Error rate*
33
+ Treatment: ${fmtPct(opts.metrics.treatmentErrorRate)}
34
+ Control: ${fmtPct(opts.metrics.controlErrorRate)}`
35
+ },
36
+ {
37
+ type: "mrkdwn",
38
+ text: `*Publish success*
39
+ Treatment: ${fmtPct(opts.metrics.treatmentPublishSuccessRate)}
40
+ Control: ${fmtPct(opts.metrics.controlPublishSuccessRate)}`
41
+ }
42
+ ]
43
+ }
44
+ ];
45
+ const actionElements = [
46
+ {
47
+ type: "button",
48
+ text: { type: "plain_text", text: "Open in PostHog" },
49
+ url: opts.posthogFlagUrl
50
+ }
51
+ ];
52
+ if (opts.inngestRunUrl) {
53
+ actionElements.push({
54
+ type: "button",
55
+ text: { type: "plain_text", text: "Inngest run" },
56
+ url: opts.inngestRunUrl
57
+ });
58
+ }
59
+ blocks.push({ type: "actions", elements: actionElements });
60
+ const res = await fetch(webhookUrl, {
61
+ method: "POST",
62
+ headers: { "Content-Type": "application/json" },
63
+ body: JSON.stringify({ attachments: [{ color, blocks }] })
64
+ });
65
+ if (!res.ok) {
66
+ console.warn(`Slack webhook failed: ${res.status} ${await res.text().catch(() => "")}`);
67
+ }
68
+ } catch (err) {
69
+ console.warn("Slack alert dispatch failed:", err instanceof Error ? err.message : String(err));
70
+ }
71
+ }
72
+
73
+ exports.postGuardianAlert = postGuardianAlert;
74
+ //# sourceMappingURL=slack.cjs.map
75
+ //# sourceMappingURL=slack.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/adapters/slack.ts"],"names":[],"mappings":"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","file":"slack.cjs","sourcesContent":["/**\n * Slack Block Kit formatter for Guardian alerts.\n * Webhook URL is passed as a parameter — no secret resolution.\n */\n\nexport interface GuardianAlertOptions {\n severity: \"warning\" | \"critical\";\n flagKey: string;\n flagName: string;\n decision: \"regression_detected\" | \"auto_disabled\";\n enforced: boolean;\n reason: string;\n metrics: {\n treatmentErrorRate?: number | null;\n controlErrorRate?: number | null;\n treatmentPublishSuccessRate?: number | null;\n controlPublishSuccessRate?: number | null;\n };\n posthogFlagUrl: string;\n inngestRunUrl?: string;\n}\n\nfunction fmtPct(n: number | null | undefined): string {\n if (n == null) return \"—\";\n return `${(n * 100).toFixed(2)}%`;\n}\n\n/**\n * Post a Guardian alert to Slack via Incoming Webhook.\n * No-op if `webhookUrl` is falsy. Swallows errors.\n */\nexport async function postGuardianAlert(\n webhookUrl: string | null | undefined,\n opts: GuardianAlertOptions,\n): Promise<void> {\n try {\n if (!webhookUrl) return;\n\n const color = opts.severity === \"critical\" ? \"#DC2626\" : \"#EAB308\";\n const prefix = opts.enforced\n ? \":rotating_light: *FLAG AUTO-DISABLED*\"\n : \":warning: *Regression detected (dry-run)*\";\n const mention = opts.enforced ? \"<!channel> \" : \"\";\n\n const blocks: unknown[] = [\n {\n type: \"section\",\n text: {\n type: \"mrkdwn\",\n text: `${mention}${prefix}\\n*Flag*: \\`${opts.flagKey}\\` — ${opts.flagName}`,\n },\n },\n {\n type: \"section\",\n fields: [\n { type: \"mrkdwn\", text: `*Reason*\\n${opts.reason}` },\n { type: \"mrkdwn\", text: `*Decision*\\n${opts.decision}` },\n {\n type: \"mrkdwn\",\n text: `*Error rate*\\nTreatment: ${fmtPct(opts.metrics.treatmentErrorRate)}\\nControl: ${fmtPct(opts.metrics.controlErrorRate)}`,\n },\n {\n type: \"mrkdwn\",\n text: `*Publish success*\\nTreatment: ${fmtPct(opts.metrics.treatmentPublishSuccessRate)}\\nControl: ${fmtPct(opts.metrics.controlPublishSuccessRate)}`,\n },\n ],\n },\n ];\n\n const actionElements: unknown[] = [\n {\n type: \"button\",\n text: { type: \"plain_text\", text: \"Open in PostHog\" },\n url: opts.posthogFlagUrl,\n },\n ];\n if (opts.inngestRunUrl) {\n actionElements.push({\n type: \"button\",\n text: { type: \"plain_text\", text: \"Inngest run\" },\n url: opts.inngestRunUrl,\n });\n }\n blocks.push({ type: \"actions\", elements: actionElements });\n\n const res = await fetch(webhookUrl, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ attachments: [{ color, blocks }] }),\n });\n\n if (!res.ok) {\n console.warn(`Slack webhook failed: ${res.status} ${await res.text().catch(() => \"\")}`);\n }\n } catch (err) {\n console.warn(\"Slack alert dispatch failed:\", err instanceof Error ? err.message : String(err));\n }\n}\n"]}
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Slack Block Kit formatter for Guardian alerts.
3
+ * Webhook URL is passed as a parameter — no secret resolution.
4
+ */
5
+ interface GuardianAlertOptions {
6
+ severity: "warning" | "critical";
7
+ flagKey: string;
8
+ flagName: string;
9
+ decision: "regression_detected" | "auto_disabled";
10
+ enforced: boolean;
11
+ reason: string;
12
+ metrics: {
13
+ treatmentErrorRate?: number | null;
14
+ controlErrorRate?: number | null;
15
+ treatmentPublishSuccessRate?: number | null;
16
+ controlPublishSuccessRate?: number | null;
17
+ };
18
+ posthogFlagUrl: string;
19
+ inngestRunUrl?: string;
20
+ }
21
+ /**
22
+ * Post a Guardian alert to Slack via Incoming Webhook.
23
+ * No-op if `webhookUrl` is falsy. Swallows errors.
24
+ */
25
+ declare function postGuardianAlert(webhookUrl: string | null | undefined, opts: GuardianAlertOptions): Promise<void>;
26
+
27
+ export { type GuardianAlertOptions, postGuardianAlert };
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Slack Block Kit formatter for Guardian alerts.
3
+ * Webhook URL is passed as a parameter — no secret resolution.
4
+ */
5
+ interface GuardianAlertOptions {
6
+ severity: "warning" | "critical";
7
+ flagKey: string;
8
+ flagName: string;
9
+ decision: "regression_detected" | "auto_disabled";
10
+ enforced: boolean;
11
+ reason: string;
12
+ metrics: {
13
+ treatmentErrorRate?: number | null;
14
+ controlErrorRate?: number | null;
15
+ treatmentPublishSuccessRate?: number | null;
16
+ controlPublishSuccessRate?: number | null;
17
+ };
18
+ posthogFlagUrl: string;
19
+ inngestRunUrl?: string;
20
+ }
21
+ /**
22
+ * Post a Guardian alert to Slack via Incoming Webhook.
23
+ * No-op if `webhookUrl` is falsy. Swallows errors.
24
+ */
25
+ declare function postGuardianAlert(webhookUrl: string | null | undefined, opts: GuardianAlertOptions): Promise<void>;
26
+
27
+ export { type GuardianAlertOptions, postGuardianAlert };
@@ -0,0 +1,73 @@
1
+ // src/adapters/slack.ts
2
+ function fmtPct(n) {
3
+ if (n == null) return "\u2014";
4
+ return `${(n * 100).toFixed(2)}%`;
5
+ }
6
+ async function postGuardianAlert(webhookUrl, opts) {
7
+ try {
8
+ if (!webhookUrl) return;
9
+ const color = opts.severity === "critical" ? "#DC2626" : "#EAB308";
10
+ const prefix = opts.enforced ? ":rotating_light: *FLAG AUTO-DISABLED*" : ":warning: *Regression detected (dry-run)*";
11
+ const mention = opts.enforced ? "<!channel> " : "";
12
+ const blocks = [
13
+ {
14
+ type: "section",
15
+ text: {
16
+ type: "mrkdwn",
17
+ text: `${mention}${prefix}
18
+ *Flag*: \`${opts.flagKey}\` \u2014 ${opts.flagName}`
19
+ }
20
+ },
21
+ {
22
+ type: "section",
23
+ fields: [
24
+ { type: "mrkdwn", text: `*Reason*
25
+ ${opts.reason}` },
26
+ { type: "mrkdwn", text: `*Decision*
27
+ ${opts.decision}` },
28
+ {
29
+ type: "mrkdwn",
30
+ text: `*Error rate*
31
+ Treatment: ${fmtPct(opts.metrics.treatmentErrorRate)}
32
+ Control: ${fmtPct(opts.metrics.controlErrorRate)}`
33
+ },
34
+ {
35
+ type: "mrkdwn",
36
+ text: `*Publish success*
37
+ Treatment: ${fmtPct(opts.metrics.treatmentPublishSuccessRate)}
38
+ Control: ${fmtPct(opts.metrics.controlPublishSuccessRate)}`
39
+ }
40
+ ]
41
+ }
42
+ ];
43
+ const actionElements = [
44
+ {
45
+ type: "button",
46
+ text: { type: "plain_text", text: "Open in PostHog" },
47
+ url: opts.posthogFlagUrl
48
+ }
49
+ ];
50
+ if (opts.inngestRunUrl) {
51
+ actionElements.push({
52
+ type: "button",
53
+ text: { type: "plain_text", text: "Inngest run" },
54
+ url: opts.inngestRunUrl
55
+ });
56
+ }
57
+ blocks.push({ type: "actions", elements: actionElements });
58
+ const res = await fetch(webhookUrl, {
59
+ method: "POST",
60
+ headers: { "Content-Type": "application/json" },
61
+ body: JSON.stringify({ attachments: [{ color, blocks }] })
62
+ });
63
+ if (!res.ok) {
64
+ console.warn(`Slack webhook failed: ${res.status} ${await res.text().catch(() => "")}`);
65
+ }
66
+ } catch (err) {
67
+ console.warn("Slack alert dispatch failed:", err instanceof Error ? err.message : String(err));
68
+ }
69
+ }
70
+
71
+ export { postGuardianAlert };
72
+ //# sourceMappingURL=slack.js.map
73
+ //# sourceMappingURL=slack.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/adapters/slack.ts"],"names":[],"mappings":"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","file":"slack.js","sourcesContent":["/**\n * Slack Block Kit formatter for Guardian alerts.\n * Webhook URL is passed as a parameter — no secret resolution.\n */\n\nexport interface GuardianAlertOptions {\n severity: \"warning\" | \"critical\";\n flagKey: string;\n flagName: string;\n decision: \"regression_detected\" | \"auto_disabled\";\n enforced: boolean;\n reason: string;\n metrics: {\n treatmentErrorRate?: number | null;\n controlErrorRate?: number | null;\n treatmentPublishSuccessRate?: number | null;\n controlPublishSuccessRate?: number | null;\n };\n posthogFlagUrl: string;\n inngestRunUrl?: string;\n}\n\nfunction fmtPct(n: number | null | undefined): string {\n if (n == null) return \"—\";\n return `${(n * 100).toFixed(2)}%`;\n}\n\n/**\n * Post a Guardian alert to Slack via Incoming Webhook.\n * No-op if `webhookUrl` is falsy. Swallows errors.\n */\nexport async function postGuardianAlert(\n webhookUrl: string | null | undefined,\n opts: GuardianAlertOptions,\n): Promise<void> {\n try {\n if (!webhookUrl) return;\n\n const color = opts.severity === \"critical\" ? \"#DC2626\" : \"#EAB308\";\n const prefix = opts.enforced\n ? \":rotating_light: *FLAG AUTO-DISABLED*\"\n : \":warning: *Regression detected (dry-run)*\";\n const mention = opts.enforced ? \"<!channel> \" : \"\";\n\n const blocks: unknown[] = [\n {\n type: \"section\",\n text: {\n type: \"mrkdwn\",\n text: `${mention}${prefix}\\n*Flag*: \\`${opts.flagKey}\\` — ${opts.flagName}`,\n },\n },\n {\n type: \"section\",\n fields: [\n { type: \"mrkdwn\", text: `*Reason*\\n${opts.reason}` },\n { type: \"mrkdwn\", text: `*Decision*\\n${opts.decision}` },\n {\n type: \"mrkdwn\",\n text: `*Error rate*\\nTreatment: ${fmtPct(opts.metrics.treatmentErrorRate)}\\nControl: ${fmtPct(opts.metrics.controlErrorRate)}`,\n },\n {\n type: \"mrkdwn\",\n text: `*Publish success*\\nTreatment: ${fmtPct(opts.metrics.treatmentPublishSuccessRate)}\\nControl: ${fmtPct(opts.metrics.controlPublishSuccessRate)}`,\n },\n ],\n },\n ];\n\n const actionElements: unknown[] = [\n {\n type: \"button\",\n text: { type: \"plain_text\", text: \"Open in PostHog\" },\n url: opts.posthogFlagUrl,\n },\n ];\n if (opts.inngestRunUrl) {\n actionElements.push({\n type: \"button\",\n text: { type: \"plain_text\", text: \"Inngest run\" },\n url: opts.inngestRunUrl,\n });\n }\n blocks.push({ type: \"actions\", elements: actionElements });\n\n const res = await fetch(webhookUrl, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ attachments: [{ color, blocks }] }),\n });\n\n if (!res.ok) {\n console.warn(`Slack webhook failed: ${res.status} ${await res.text().catch(() => \"\")}`);\n }\n } catch (err) {\n console.warn(\"Slack alert dispatch failed:\", err instanceof Error ? err.message : String(err));\n }\n}\n"]}