@posthog/convex 0.2.33 → 1.0.1

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.
Files changed (53) hide show
  1. package/README.md +132 -16
  2. package/dist/client/feature-flags/crypto.d.ts +2 -0
  3. package/dist/client/feature-flags/crypto.d.ts.map +1 -0
  4. package/dist/client/feature-flags/crypto.js +11 -0
  5. package/dist/client/feature-flags/crypto.js.map +1 -0
  6. package/dist/client/feature-flags/evaluator.d.ts +47 -0
  7. package/dist/client/feature-flags/evaluator.d.ts.map +1 -0
  8. package/dist/client/feature-flags/evaluator.js +346 -0
  9. package/dist/client/feature-flags/evaluator.js.map +1 -0
  10. package/dist/client/feature-flags/index.d.ts +4 -0
  11. package/dist/client/feature-flags/index.d.ts.map +1 -0
  12. package/dist/client/feature-flags/index.js +3 -0
  13. package/dist/client/feature-flags/index.js.map +1 -0
  14. package/dist/client/feature-flags/match-property.d.ts +12 -0
  15. package/dist/client/feature-flags/match-property.d.ts.map +1 -0
  16. package/dist/client/feature-flags/match-property.js +340 -0
  17. package/dist/client/feature-flags/match-property.js.map +1 -0
  18. package/dist/client/feature-flags/types.d.ts +63 -0
  19. package/dist/client/feature-flags/types.d.ts.map +1 -0
  20. package/dist/client/feature-flags/types.js +2 -0
  21. package/dist/client/feature-flags/types.js.map +1 -0
  22. package/dist/client/index.d.ts +71 -36
  23. package/dist/client/index.d.ts.map +1 -1
  24. package/dist/client/index.js +143 -32
  25. package/dist/client/index.js.map +1 -1
  26. package/dist/component/_generated/component.d.ts +8 -35
  27. package/dist/component/_generated/component.d.ts.map +1 -1
  28. package/dist/component/lib.d.ts +76 -46
  29. package/dist/component/lib.d.ts.map +1 -1
  30. package/dist/component/lib.js +311 -99
  31. package/dist/component/lib.js.map +1 -1
  32. package/dist/component/schema.d.ts +18 -1
  33. package/dist/component/schema.d.ts.map +1 -1
  34. package/dist/component/schema.js +16 -2
  35. package/dist/component/schema.js.map +1 -1
  36. package/dist/component/version.d.ts +2 -0
  37. package/dist/component/version.d.ts.map +1 -0
  38. package/dist/component/version.js +2 -0
  39. package/dist/component/version.js.map +1 -0
  40. package/package.json +5 -5
  41. package/src/client/feature-flags/crypto.ts +12 -0
  42. package/src/client/feature-flags/evaluator.test.ts +401 -0
  43. package/src/client/feature-flags/evaluator.ts +467 -0
  44. package/src/client/feature-flags/index.ts +15 -0
  45. package/src/client/feature-flags/match-property.test.ts +75 -0
  46. package/src/client/feature-flags/match-property.ts +347 -0
  47. package/src/client/feature-flags/types.ts +72 -0
  48. package/src/client/index.test.ts +60 -12
  49. package/src/client/index.ts +227 -70
  50. package/src/component/_generated/component.ts +7 -50
  51. package/src/component/lib.ts +340 -127
  52. package/src/component/schema.ts +16 -2
  53. package/src/component/version.ts +1 -0
package/README.md CHANGED
@@ -1,5 +1,5 @@
1
1
  <p align="center">
2
- <img alt="@posthog/convex" src="https://raw.githubusercontent.com/PostHog/posthog/master/frontend/public/hedgehog/heart-hog.png" width="200">
2
+ <img alt="@posthog/convex" src="https://res.cloudinary.com/dmukukwp6/image/upload/q_auto,f_auto/posthog_convex_c017c269f8.png" width="500">
3
3
  </p>
4
4
 
5
5
  <h1 align="center">@posthog/convex</h1>
@@ -10,14 +10,12 @@
10
10
 
11
11
  <p align="center">
12
12
  <a href="https://www.npmjs.com/package/@posthog/convex"><img src="https://badge.fury.io/js/@posthog%2Fconvex.svg" alt="npm version"></a>
13
+ <a href="https://www.convex.dev/components/posthog/convex"><img src="https://www.convex.dev/components/badge/posthog/convex" alt="Convex Component"></a>
13
14
  </p>
14
15
 
15
- > [!WARNING]
16
- > This package is in alpha and under active development. APIs may change between releases.
17
-
18
16
  ## 🦔 What is this?
19
17
 
20
- The official [PostHog](https://posthog.com) component for [Convex](https://convex.dev). Capture events, identify users, manage groups, and evaluate feature flags — all from your mutations and actions.
18
+ The official [PostHog](https://posthog.com) component for [Convex](https://convex.dev). Capture events, identify users, manage groups, and evaluate feature flags — all from your queries, mutations, and actions.
21
19
 
22
20
  Found a bug? Feature request? [File it here](https://github.com/PostHog/posthog-js/issues).
23
21
 
@@ -42,32 +40,63 @@ app.use(posthog);
42
40
  export default app;
43
41
  ```
44
42
 
45
- Set your PostHog API key and host:
43
+ Set your PostHog credentials on your Convex deployment:
46
44
 
47
45
  ```sh
48
46
  npx convex env set POSTHOG_API_KEY phc_your_project_api_key
49
47
  npx convex env set POSTHOG_HOST https://us.i.posthog.com
50
48
  ```
51
49
 
52
- Create a `convex/posthog.ts` file to initialize the client:
50
+ To enable local feature flag evaluation, also set a [feature flags secure API key](https://posthog.com/docs/feature-flags/local-evaluation#step-1-find-your-feature-flags-secure-api-key) (`phs_…`) with read access to feature flags:
51
+
52
+ ```sh
53
+ npx convex env set POSTHOG_PERSONAL_API_KEY phs_your_feature_flags_secure_api_key
54
+ ```
55
+
56
+ > Personal API keys (`phx_…`) also still work for local evaluation, but PostHog recommends the project-scoped feature flags secure API key going forward.
57
+
58
+ Create a `convex/posthog.ts` file to initialize the client. Read the keys from `process.env` and pass them to the constructor — the client captures them and forwards them to component actions as needed:
53
59
 
54
60
  ```ts
55
61
  // convex/posthog.ts
56
62
  import { PostHog } from "@posthog/convex";
57
63
  import { components } from "./_generated/api";
58
64
 
59
- export const posthog = new PostHog(components.posthog);
65
+ export const posthog = new PostHog(components.posthog, {
66
+ apiKey: process.env.POSTHOG_API_KEY,
67
+ personalApiKey: process.env.POSTHOG_PERSONAL_API_KEY,
68
+ host: process.env.POSTHOG_HOST,
69
+ });
60
70
  ```
61
71
 
62
- You can also pass the API key and host explicitly:
72
+ Schedule a cron in your own `convex/crons.ts` that refreshes the flag definitions on whatever interval suits you. The client class captures the keys you passed in `posthog.ts` and forwards them automatically:
63
73
 
64
74
  ```ts
65
- export const posthog = new PostHog(components.posthog, {
66
- apiKey: "phc_...",
67
- host: "https://eu.i.posthog.com",
75
+ // convex/crons.ts
76
+ import { cronJobs } from "convex/server";
77
+ import { internalAction } from "./_generated/server";
78
+ import { internal } from "./_generated/api";
79
+ import { posthog } from "./posthog";
80
+
81
+ export const refreshPosthogFlags = internalAction({
82
+ args: {},
83
+ handler: async (ctx) => {
84
+ await posthog.refreshFlagDefinitions(ctx);
85
+ },
68
86
  });
87
+
88
+ const crons = cronJobs();
89
+ crons.interval(
90
+ "refresh posthog feature flag definitions",
91
+ { minutes: 1 },
92
+ internal.crons.refreshPosthogFlags
93
+ );
94
+
95
+ export default crons;
69
96
  ```
70
97
 
98
+ That's the whole setup — feature flag methods will start returning live values on the next cron tick, or you can call `posthog.refreshFlagDefinitions(ctx)` from an action whenever you want an immediate refresh.
99
+
71
100
  ## 📊 Capturing Events
72
101
 
73
102
  Import `posthog` from your setup file and call methods directly:
@@ -143,11 +172,35 @@ await posthog.alias(ctx, {
143
172
  });
144
173
  ```
145
174
 
175
+ ### captureException
176
+
177
+ Send an exception to PostHog's error tracking pipeline. Accepts an `Error`, a string, or any object with a `message` field.
178
+
179
+ ```ts
180
+ try {
181
+ await chargeCard(...);
182
+ } catch (error) {
183
+ await posthog.captureException(ctx, {
184
+ error,
185
+ distinctId: "user_123",
186
+ additionalProperties: { plan: "pro" },
187
+ });
188
+ throw error;
189
+ }
190
+ ```
191
+
192
+ If you'd rather have **every** uncaught error from your Convex deployment forwarded to PostHog automatically — including ones you didn't explicitly wrap — wire up Convex's first-party PostHog exception reporting integration from the Convex dashboard. Setup lives at [docs.convex.dev/production/integrations/exception-reporting#configuring-posthog-error-tracking](https://docs.convex.dev/production/integrations/exception-reporting#configuring-posthog-error-tracking). Use `captureException` here for cases where you want explicit control (e.g. attaching custom `additionalProperties`); use the Convex-side integration for catch-all coverage.
193
+
146
194
  All of the above methods schedule the PostHog API call asynchronously via `ctx.scheduler.runAfter`, so they return immediately without blocking your mutation or action.
147
195
 
148
196
  ## 🚩 Feature Flags
149
197
 
150
- Feature flag methods evaluate flags by calling the PostHog API and returning the result. They require an **action** context (they use `ctx.runAction` internally).
198
+ Two evaluation paths, pick the one that fits the flag:
199
+
200
+ - **Local** (`getFeatureFlag`, `isFeatureEnabled`, …) — evaluates against definitions cached by the cron. Works in **queries, mutations, and actions**, no per-call network round-trip, reactive (a query reading a flag re-runs when definitions change). Requires `POSTHOG_PERSONAL_API_KEY`. Can't handle every flag — see [the limitations](#local-evaluation--limitations) below.
201
+ - **Remote** (`evaluateFlag`, `evaluateFlagPayload`, `evaluateAllFlags`) — hits PostHog's `/flags` endpoint directly. Action-context only, no `personalApiKey` needed, handles every flag.
202
+
203
+ The local methods are documented first; remote is at the bottom of this section.
151
204
 
152
205
  ### getFeatureFlag
153
206
 
@@ -155,10 +208,10 @@ Get a flag's value.
155
208
 
156
209
  ```ts
157
210
  import { posthog } from "./posthog";
158
- import { action } from "./_generated/server";
211
+ import { query } from "./_generated/server";
159
212
  import { v } from "convex/values";
160
213
 
161
- export const getDiscount = action({
214
+ export const getDiscount = query({
162
215
  args: { userId: v.string() },
163
216
  handler: async (ctx, args) => {
164
217
  const flag = await posthog.getFeatureFlag(ctx, {
@@ -231,7 +284,70 @@ const { featureFlags, featureFlagPayloads } =
231
284
  });
232
285
  ```
233
286
 
234
- All feature flag methods accept optional `groups`, `personProperties`, `groupProperties`, `sendFeatureFlagEvents`, and `disableGeoip` options. `getAllFlags` and `getAllFlagsAndPayloads` also accept `flagKeys` to filter which flags to evaluate.
287
+ All feature flag methods accept optional `groups`, `personProperties`, `groupProperties`, and `disableGeoip` options. `getAllFlags` and `getAllFlagsAndPayloads` also accept `flagKeys` to filter which flags to evaluate.
288
+
289
+ ### Local evaluation — limitations
290
+
291
+ Local eval can't reach a verdict for every flag, and for those this component will return `null`. The cases:
292
+
293
+ - **Experience continuity flags.** Flags with [persist across authentication steps](https://posthog.com/docs/feature-flags/creating-feature-flags#persisting-feature-flags-across-authentication-steps) need server-side anon→identified tracking and aren't included in local eval.
294
+ - **Static cohorts.** Cohort membership for static cohorts lives only on the server.
295
+ - **Properties not passed in.** Local eval can only see what you give it. If a flag targets `email` or `$browser_version` and you don't pass those in `personProperties`, it can't resolve.
296
+ - **Cohorts that don't fit the local-eval shape.** Cohorts with variant overrides, non-person properties, more than one cohort in the same flag definition, nested AND/OR filters, or grouped with other conditions can't be translated for local eval. See [the PostHog docs](https://posthog.com/docs/feature-flags/local-evaluation#dynamic-cohort-restrictions) for the full list.
297
+
298
+ Local eval doesn't fire `$feature_flag_called` events. PostHog Experiments counts exposures off these — `posthog-node` emits them automatically on every local eval, but this component can't do the same: Convex queries are pure functions, so they can't schedule a `capture` from inside the eval path without breaking Convex's contract. If you're running an experiment against a locally-evaluated flag, fire one manually from a mutation or action:
299
+
300
+ ```ts
301
+ await posthog.capture(ctx, {
302
+ event: "$feature_flag_called",
303
+ distinctId: userId,
304
+ properties: {
305
+ $feature_flag: "flag-key",
306
+ $feature_flag_response: value,
307
+ locally_evaluated: true,
308
+ },
309
+ });
310
+ ```
311
+
312
+ There are also reasons you might *not want* local eval at all, even when it's possible:
313
+
314
+ - **Low-traffic projects.** PostHog bills each `/flags/definitions` poll as 10 flag-request equivalents. For projects that evaluate fewer flags than that per polling interval, remote evaluation is cheaper.
315
+ - **Need-it-now changes.** Local eval accepts up to one polling interval of staleness (default 1 minute with our cron). For flags that must flip in well under that, you want remote eval.
316
+ - **No personal API key.** If you don't want to set `POSTHOG_PERSONAL_API_KEY`, the local methods aren't useful — there's nothing for them to read.
317
+
318
+ For any of those, use the remote-eval methods below instead.
319
+
320
+ ### Remote evaluation
321
+
322
+ Sibling methods that hit PostHog's `/flags` endpoint directly. They require an **action** context (each call is a network round trip) and don't need `personalApiKey`. They handle every case local eval can't.
323
+
324
+ ```ts
325
+ import { posthog } from "./posthog";
326
+ import { action } from "./_generated/server";
327
+ import { v } from "convex/values";
328
+
329
+ export const getContinuityFlag = action({
330
+ args: { userId: v.string() },
331
+ handler: async (ctx, args) => {
332
+ const value = await posthog.evaluateFlag(ctx, {
333
+ key: "my-experience-continuity-flag",
334
+ distinctId: args.userId,
335
+ personProperties: { plan: "pro" },
336
+ });
337
+ return value;
338
+ },
339
+ });
340
+ ```
341
+
342
+ Three methods:
343
+
344
+ | Method | Returns |
345
+ | --- | --- |
346
+ | `posthog.evaluateFlag(ctx, args)` | `FeatureFlagValue \| null` |
347
+ | `posthog.evaluateFlagPayload(ctx, args)` | `JsonType \| null` |
348
+ | `posthog.evaluateAllFlags(ctx, args)` | `{ featureFlags, featureFlagPayloads }` |
349
+
350
+ Same option shape as the local methods (`groups`, `personProperties`, `groupProperties`, `disableGeoip`, `flagKeys` on the all-flags variant). Pick local when the flag is suitable and the cost of `/flags/definitions` polling is justified; pick remote when it isn't.
235
351
 
236
352
  ## 📦 Example
237
353
 
@@ -0,0 +1,2 @@
1
+ export declare function hashSHA1(text: string): Promise<string>;
2
+ //# sourceMappingURL=crypto.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"crypto.d.ts","sourceRoot":"","sources":["../../../src/client/feature-flags/crypto.ts"],"names":[],"mappings":"AAEA,wBAAsB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAS5D"}
@@ -0,0 +1,11 @@
1
+ /// <reference lib="dom" />
2
+ export async function hashSHA1(text) {
3
+ const subtle = globalThis.crypto?.subtle;
4
+ if (!subtle) {
5
+ throw new Error('SubtleCrypto API not available');
6
+ }
7
+ const hashBuffer = await subtle.digest('SHA-1', new TextEncoder().encode(text));
8
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
9
+ return hashArray.map((byte) => byte.toString(16).padStart(2, '0')).join('');
10
+ }
11
+ //# sourceMappingURL=crypto.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"crypto.js","sourceRoot":"","sources":["../../../src/client/feature-flags/crypto.ts"],"names":[],"mappings":"AAAA,2BAA2B;AAE3B,MAAM,CAAC,KAAK,UAAU,QAAQ,CAAC,IAAY;IACzC,MAAM,MAAM,GAAG,UAAU,CAAC,MAAM,EAAE,MAAM,CAAA;IACxC,IAAI,CAAC,MAAM,EAAE,CAAC;QACZ,MAAM,IAAI,KAAK,CAAC,gCAAgC,CAAC,CAAA;IACnD,CAAC;IAED,MAAM,UAAU,GAAG,MAAM,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,IAAI,WAAW,EAAE,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAA;IAC/E,MAAM,SAAS,GAAG,KAAK,CAAC,IAAI,CAAC,IAAI,UAAU,CAAC,UAAU,CAAC,CAAC,CAAA;IACxD,OAAO,SAAS,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAA;AAC7E,CAAC"}
@@ -0,0 +1,47 @@
1
+ import type { FeatureFlagEvaluationContext, FeatureFlagValue, FlagDefinitions, JsonType, PostHogFeatureFlag } from './types.js';
2
+ export type EvaluationResult = {
3
+ value: FeatureFlagValue;
4
+ payload: JsonType | null;
5
+ };
6
+ export declare class LocalFeatureFlagEvaluator {
7
+ readonly flags: PostHogFeatureFlag[];
8
+ readonly flagsByKey: Record<string, PostHogFeatureFlag>;
9
+ readonly groupTypeMapping: Record<string, string>;
10
+ readonly cohorts: FlagDefinitions['cohorts'];
11
+ debugMode: boolean;
12
+ constructor(definitions: FlagDefinitions);
13
+ debug(enabled?: boolean): void;
14
+ private logMsgIfDebug;
15
+ private createEvaluationContext;
16
+ /**
17
+ * Evaluate a single flag locally. Returns the value or `undefined` if eval was inconclusive.
18
+ * `undefined` means the caller has no way to determine the flag value locally — typically
19
+ * because the flag uses experience continuity, a static cohort, or properties that weren't
20
+ * provided.
21
+ */
22
+ getFeatureFlag(key: string, distinctId: string, groups?: Record<string, string>, personProperties?: Record<string, any>, groupProperties?: Record<string, Record<string, any>>): Promise<FeatureFlagValue | undefined>;
23
+ getFeatureFlagResult(key: string, distinctId: string, groups?: Record<string, string>, personProperties?: Record<string, any>, groupProperties?: Record<string, Record<string, any>>): Promise<EvaluationResult | undefined>;
24
+ /**
25
+ * Returns the payload for a flag value, or `null` when the flag is unknown / evaluated to a
26
+ * non-matching value / has no payload configured. Returns `undefined` when the flag couldn't
27
+ * be evaluated locally — mirroring `getFeatureFlag` so callers can tell apart "no payload"
28
+ * from "eval unavailable".
29
+ */
30
+ getFeatureFlagPayload(key: string, distinctId: string, matchValue: FeatureFlagValue | undefined, groups?: Record<string, string>, personProperties?: Record<string, any>, groupProperties?: Record<string, Record<string, any>>): Promise<JsonType | null | undefined>;
31
+ getAllFlagsAndPayloads(distinctId: string, groups?: Record<string, string>, personProperties?: Record<string, any>, groupProperties?: Record<string, Record<string, any>>, flagKeys?: string[]): Promise<{
32
+ featureFlags: Record<string, FeatureFlagValue>;
33
+ featureFlagPayloads: Record<string, JsonType>;
34
+ }>;
35
+ computeFlagAndPayloadLocally(flag: PostHogFeatureFlag, ctx: FeatureFlagEvaluationContext, options?: {
36
+ matchValue?: FeatureFlagValue;
37
+ }): Promise<EvaluationResult>;
38
+ private computeFlagValueLocally;
39
+ private getBucketingValueForFlag;
40
+ private getPayloadForValue;
41
+ private evaluateFlagDependency;
42
+ private matchFeatureFlagProperties;
43
+ private isConditionMatch;
44
+ private getMatchingVariant;
45
+ private variantLookupTable;
46
+ }
47
+ //# sourceMappingURL=evaluator.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"evaluator.d.ts","sourceRoot":"","sources":["../../../src/client/feature-flags/evaluator.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,346 @@
1
+ import { hashSHA1 } from './crypto.js';
2
+ import { InconclusiveMatchError, RequiresServerEvaluation, matchCohort, matchProperty } from './match-property.js';
3
+ // Matches posthog-node's hashing constant exactly; the value is larger than Number.MAX_SAFE_INTEGER
4
+ // by design and we don't want the no-loss-of-precision rule to coerce it to a different number.
5
+ // eslint-disable-next-line no-loss-of-precision
6
+ const LONG_SCALE = 0xfffffffffffffff;
7
+ async function _hash(key, bucketingValue, salt = '') {
8
+ const hashString = await hashSHA1(`${key}.${bucketingValue}${salt}`);
9
+ return parseInt(hashString.slice(0, 15), 16) / LONG_SCALE;
10
+ }
11
+ export class LocalFeatureFlagEvaluator {
12
+ flags;
13
+ flagsByKey;
14
+ groupTypeMapping;
15
+ cohorts;
16
+ debugMode = false;
17
+ constructor(definitions) {
18
+ this.flags = definitions.flags ?? [];
19
+ this.flagsByKey = this.flags.reduce((acc, flag) => {
20
+ acc[flag.key] = flag;
21
+ return acc;
22
+ }, {});
23
+ this.groupTypeMapping = definitions.groupTypeMapping ?? {};
24
+ this.cohorts = definitions.cohorts ?? {};
25
+ }
26
+ debug(enabled = true) {
27
+ this.debugMode = enabled;
28
+ }
29
+ logMsgIfDebug(fn) {
30
+ if (this.debugMode)
31
+ fn();
32
+ }
33
+ createEvaluationContext(distinctId, groups = {}, personProperties = {}, groupProperties = {}) {
34
+ return { distinctId, groups, personProperties, groupProperties, evaluationCache: {} };
35
+ }
36
+ /**
37
+ * Evaluate a single flag locally. Returns the value or `undefined` if eval was inconclusive.
38
+ * `undefined` means the caller has no way to determine the flag value locally — typically
39
+ * because the flag uses experience continuity, a static cohort, or properties that weren't
40
+ * provided.
41
+ */
42
+ async getFeatureFlag(key, distinctId, groups = {}, personProperties = {}, groupProperties = {}) {
43
+ const flag = this.flagsByKey[key];
44
+ if (flag === undefined)
45
+ return undefined;
46
+ const ctx = this.createEvaluationContext(distinctId, groups, personProperties, groupProperties);
47
+ try {
48
+ const { value } = await this.computeFlagAndPayloadLocally(flag, ctx);
49
+ return value;
50
+ }
51
+ catch (e) {
52
+ if (e instanceof RequiresServerEvaluation || e instanceof InconclusiveMatchError) {
53
+ this.logMsgIfDebug(() => console.debug(`[FEATURE FLAGS] ${e.name} when computing flag locally: ${key}: ${e.message}`));
54
+ return undefined;
55
+ }
56
+ throw e;
57
+ }
58
+ }
59
+ async getFeatureFlagResult(key, distinctId, groups = {}, personProperties = {}, groupProperties = {}) {
60
+ const flag = this.flagsByKey[key];
61
+ if (flag === undefined)
62
+ return undefined;
63
+ const ctx = this.createEvaluationContext(distinctId, groups, personProperties, groupProperties);
64
+ try {
65
+ return await this.computeFlagAndPayloadLocally(flag, ctx);
66
+ }
67
+ catch (e) {
68
+ if (e instanceof RequiresServerEvaluation || e instanceof InconclusiveMatchError) {
69
+ this.logMsgIfDebug(() => console.debug(`[FEATURE FLAGS] ${e.name} when computing flag locally: ${key}: ${e.message}`));
70
+ return undefined;
71
+ }
72
+ throw e;
73
+ }
74
+ }
75
+ /**
76
+ * Returns the payload for a flag value, or `null` when the flag is unknown / evaluated to a
77
+ * non-matching value / has no payload configured. Returns `undefined` when the flag couldn't
78
+ * be evaluated locally — mirroring `getFeatureFlag` so callers can tell apart "no payload"
79
+ * from "eval unavailable".
80
+ */
81
+ async getFeatureFlagPayload(key, distinctId, matchValue, groups = {}, personProperties = {}, groupProperties = {}) {
82
+ const flag = this.flagsByKey[key];
83
+ if (flag === undefined)
84
+ return null;
85
+ if (matchValue !== undefined) {
86
+ return this.getPayloadForValue(key, matchValue);
87
+ }
88
+ const result = await this.getFeatureFlagResult(key, distinctId, groups, personProperties, groupProperties);
89
+ if (result === undefined)
90
+ return undefined;
91
+ return result.payload;
92
+ }
93
+ async getAllFlagsAndPayloads(distinctId, groups = {}, personProperties = {}, groupProperties = {}, flagKeys) {
94
+ const featureFlags = {};
95
+ const featureFlagPayloads = {};
96
+ const flagsToEvaluate = flagKeys ? flagKeys.map((k) => this.flagsByKey[k]).filter(Boolean) : this.flags;
97
+ const sharedContext = {
98
+ distinctId,
99
+ groups,
100
+ personProperties,
101
+ groupProperties,
102
+ evaluationCache: {},
103
+ };
104
+ await Promise.all(flagsToEvaluate.map(async (flag) => {
105
+ try {
106
+ const { value, payload } = await this.computeFlagAndPayloadLocally(flag, sharedContext);
107
+ featureFlags[flag.key] = value;
108
+ if (payload != null)
109
+ featureFlagPayloads[flag.key] = payload;
110
+ }
111
+ catch (e) {
112
+ if (e instanceof RequiresServerEvaluation || e instanceof InconclusiveMatchError) {
113
+ this.logMsgIfDebug(() => console.debug(`[FEATURE FLAGS] ${e.name} when computing flag locally: ${flag.key}: ${e.message}`));
114
+ return;
115
+ }
116
+ throw e;
117
+ }
118
+ }));
119
+ return { featureFlags, featureFlagPayloads };
120
+ }
121
+ async computeFlagAndPayloadLocally(flag, ctx, options = {}) {
122
+ const flagValue = options.matchValue !== undefined ? options.matchValue : await this.computeFlagValueLocally(flag, ctx);
123
+ return { value: flagValue, payload: this.getPayloadForValue(flag.key, flagValue) };
124
+ }
125
+ async computeFlagValueLocally(flag, ctx) {
126
+ const { distinctId, groups, personProperties, groupProperties } = ctx;
127
+ // Order matters: an inactive flag is always false regardless of continuity. Checking
128
+ // `ensure_experience_continuity` first would cause a disabled-but-continuity flag to come
129
+ // back as undefined instead of the correct `false`.
130
+ if (!flag.active)
131
+ return false;
132
+ if (flag.ensure_experience_continuity) {
133
+ throw new InconclusiveMatchError('Flag has experience continuity enabled');
134
+ }
135
+ const flagFilters = flag.filters || {};
136
+ const aggregation_group_type_index = flagFilters.aggregation_group_type_index;
137
+ if (aggregation_group_type_index != undefined) {
138
+ const groupName = this.groupTypeMapping[String(aggregation_group_type_index)];
139
+ if (!groupName) {
140
+ this.logMsgIfDebug(() => console.warn(`[FEATURE FLAGS] Unknown group type index ${aggregation_group_type_index} for feature flag ${flag.key}`));
141
+ throw new InconclusiveMatchError('Flag has unknown group type index');
142
+ }
143
+ if (!(groupName in groups)) {
144
+ this.logMsgIfDebug(() => console.warn(`[FEATURE FLAGS] Can't compute group feature flag: ${flag.key} without group names passed in`));
145
+ return false;
146
+ }
147
+ const focusedGroupProperties = groupProperties[groupName];
148
+ return await this.matchFeatureFlagProperties(flag, groups[groupName], focusedGroupProperties, ctx);
149
+ }
150
+ const bucketingValue = this.getBucketingValueForFlag(flag, distinctId, personProperties);
151
+ if (bucketingValue === undefined) {
152
+ this.logMsgIfDebug(() => console.warn(`[FEATURE FLAGS] Can't compute feature flag: ${flag.key} without $device_id, falling back to server evaluation`));
153
+ throw new InconclusiveMatchError(`Can't compute feature flag: ${flag.key} without $device_id`);
154
+ }
155
+ return await this.matchFeatureFlagProperties(flag, bucketingValue, personProperties, ctx);
156
+ }
157
+ getBucketingValueForFlag(flag, distinctId, properties) {
158
+ if (flag.filters?.aggregation_group_type_index != undefined)
159
+ return distinctId;
160
+ if (flag.bucketing_identifier === 'device_id') {
161
+ const deviceId = properties?.$device_id;
162
+ if (deviceId === undefined || deviceId === null || deviceId === '')
163
+ return undefined;
164
+ return deviceId;
165
+ }
166
+ return distinctId;
167
+ }
168
+ getPayloadForValue(key, flagValue) {
169
+ if (flagValue === false || flagValue === null || flagValue === undefined)
170
+ return null;
171
+ let payload = null;
172
+ const payloads = this.flagsByKey[key]?.filters?.payloads;
173
+ if (!payloads)
174
+ return null;
175
+ if (typeof flagValue === 'boolean') {
176
+ payload = payloads[flagValue.toString()] || null;
177
+ }
178
+ else if (typeof flagValue === 'string') {
179
+ payload = payloads[flagValue] || null;
180
+ }
181
+ if (payload == null)
182
+ return null;
183
+ if (typeof payload === 'object')
184
+ return payload;
185
+ if (typeof payload === 'string') {
186
+ try {
187
+ return JSON.parse(payload);
188
+ }
189
+ catch {
190
+ return payload;
191
+ }
192
+ }
193
+ return payload;
194
+ }
195
+ async evaluateFlagDependency(property, ctx) {
196
+ const { evaluationCache } = ctx;
197
+ const targetFlagKey = property.key;
198
+ if (!('dependency_chain' in property)) {
199
+ throw new InconclusiveMatchError(`Flag dependency property for '${targetFlagKey}' is missing required 'dependency_chain' field`);
200
+ }
201
+ const dependencyChain = property.dependency_chain;
202
+ if (!Array.isArray(dependencyChain)) {
203
+ throw new InconclusiveMatchError(`Flag dependency property for '${targetFlagKey}' has an invalid 'dependency_chain'`);
204
+ }
205
+ if (dependencyChain.length === 0) {
206
+ throw new InconclusiveMatchError(`Circular dependency detected for flag '${targetFlagKey}'`);
207
+ }
208
+ for (const depFlagKey of dependencyChain) {
209
+ if (!(depFlagKey in evaluationCache)) {
210
+ const depFlag = this.flagsByKey[depFlagKey];
211
+ if (!depFlag) {
212
+ throw new InconclusiveMatchError(`Missing flag dependency '${depFlagKey}' for flag '${targetFlagKey}'`);
213
+ }
214
+ if (!depFlag.active) {
215
+ evaluationCache[depFlagKey] = false;
216
+ }
217
+ else {
218
+ try {
219
+ evaluationCache[depFlagKey] = await this.computeFlagValueLocally(depFlag, ctx);
220
+ }
221
+ catch (error) {
222
+ throw new InconclusiveMatchError(`Error evaluating flag dependency '${depFlagKey}' for flag '${targetFlagKey}': ${error}`);
223
+ }
224
+ }
225
+ }
226
+ const cached = evaluationCache[depFlagKey];
227
+ if (cached === null || cached === undefined) {
228
+ throw new InconclusiveMatchError(`Dependency '${depFlagKey}' could not be evaluated`);
229
+ }
230
+ }
231
+ return flagEvaluatesToExpectedValue(property.value, evaluationCache[targetFlagKey]);
232
+ }
233
+ async matchFeatureFlagProperties(flag, bucketingValue, properties, ctx) {
234
+ const flagFilters = flag.filters || {};
235
+ const flagConditions = flagFilters.groups || [];
236
+ const flagAggregation = flagFilters.aggregation_group_type_index;
237
+ const { groups, groupProperties } = ctx;
238
+ let isInconclusive = false;
239
+ let result = undefined;
240
+ for (const condition of flagConditions) {
241
+ try {
242
+ const conditionAggregation = condition.aggregation_group_type_index !== undefined
243
+ ? condition.aggregation_group_type_index
244
+ : flagAggregation;
245
+ let effectiveProperties = properties;
246
+ let effectiveBucketingValue = bucketingValue;
247
+ if (conditionAggregation !== flagAggregation) {
248
+ if (conditionAggregation !== null && conditionAggregation !== undefined) {
249
+ const groupName = this.groupTypeMapping[String(conditionAggregation)];
250
+ if (!groupName || !(groupName in groups)) {
251
+ this.logMsgIfDebug(() => console.debug(`[FEATURE FLAGS] Skipping group condition for flag '${flag.key}': group type index ${conditionAggregation} not available`));
252
+ continue;
253
+ }
254
+ if (!(groupName in groupProperties)) {
255
+ isInconclusive = true;
256
+ continue;
257
+ }
258
+ effectiveProperties = groupProperties[groupName];
259
+ effectiveBucketingValue = groups[groupName];
260
+ }
261
+ }
262
+ if (await this.isConditionMatch(flag, effectiveBucketingValue, condition, effectiveProperties, ctx)) {
263
+ const variantOverride = condition.variant;
264
+ const flagVariants = flagFilters.multivariate?.variants || [];
265
+ if (variantOverride && flagVariants.some((variant) => variant.key === variantOverride)) {
266
+ result = variantOverride;
267
+ }
268
+ else {
269
+ result = (await this.getMatchingVariant(flag, effectiveBucketingValue)) || true;
270
+ }
271
+ break;
272
+ }
273
+ }
274
+ catch (e) {
275
+ if (e instanceof RequiresServerEvaluation)
276
+ throw e;
277
+ if (e instanceof InconclusiveMatchError) {
278
+ isInconclusive = true;
279
+ }
280
+ else {
281
+ throw e;
282
+ }
283
+ }
284
+ }
285
+ if (result !== undefined)
286
+ return result;
287
+ if (isInconclusive) {
288
+ throw new InconclusiveMatchError("Can't determine if feature flag is enabled or not with given properties");
289
+ }
290
+ return false;
291
+ }
292
+ async isConditionMatch(flag, bucketingValue, condition, properties, ctx) {
293
+ const rolloutPercentage = condition.rollout_percentage;
294
+ const warn = (msg) => this.logMsgIfDebug(() => console.warn(msg));
295
+ if ((condition.properties || []).length > 0) {
296
+ for (const prop of condition.properties) {
297
+ let matches;
298
+ if (prop.type === 'cohort') {
299
+ matches = matchCohort(prop, properties, this.cohorts, this.debugMode);
300
+ }
301
+ else if (prop.type === 'flag') {
302
+ matches = await this.evaluateFlagDependency(prop, ctx);
303
+ }
304
+ else {
305
+ matches = matchProperty(prop, properties, warn);
306
+ }
307
+ // `matchPropertyGroup` (cohort path) inverts on `negation`; the top-level flag condition
308
+ // path needs to do the same or any negated property on a flag-level filter quietly passes.
309
+ if (prop.negation)
310
+ matches = !matches;
311
+ if (!matches)
312
+ return false;
313
+ }
314
+ if (rolloutPercentage == undefined)
315
+ return true;
316
+ }
317
+ if (rolloutPercentage != undefined && (await _hash(flag.key, bucketingValue)) > rolloutPercentage / 100.0) {
318
+ return false;
319
+ }
320
+ return true;
321
+ }
322
+ async getMatchingVariant(flag, bucketingValue) {
323
+ const hashValue = await _hash(flag.key, bucketingValue, 'variant');
324
+ return this.variantLookupTable(flag).find((v) => hashValue >= v.valueMin && hashValue < v.valueMax)?.key;
325
+ }
326
+ variantLookupTable(flag) {
327
+ const table = [];
328
+ let valueMin = 0;
329
+ const multivariates = flag.filters?.multivariate?.variants || [];
330
+ for (const variant of multivariates) {
331
+ const valueMax = valueMin + variant.rollout_percentage / 100.0;
332
+ table.push({ valueMin, valueMax, key: variant.key });
333
+ valueMin = valueMax;
334
+ }
335
+ return table;
336
+ }
337
+ }
338
+ function flagEvaluatesToExpectedValue(expectedValue, flagValue) {
339
+ if (typeof expectedValue === 'boolean') {
340
+ return expectedValue === flagValue || (typeof flagValue === 'string' && flagValue !== '' && expectedValue === true);
341
+ }
342
+ if (typeof expectedValue === 'string')
343
+ return flagValue === expectedValue;
344
+ return false;
345
+ }
346
+ //# sourceMappingURL=evaluator.js.map