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/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
|
+
[](https://github.com/backhandvolley/posthog-flag-toolkit/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/posthog-flag-toolkit)
|
|
5
|
+
[](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"]}
|