@posthog/convex 0.2.32 → 1.0.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/README.md +132 -16
- package/dist/client/feature-flags/crypto.d.ts +2 -0
- package/dist/client/feature-flags/crypto.d.ts.map +1 -0
- package/dist/client/feature-flags/crypto.js +11 -0
- package/dist/client/feature-flags/crypto.js.map +1 -0
- package/dist/client/feature-flags/evaluator.d.ts +47 -0
- package/dist/client/feature-flags/evaluator.d.ts.map +1 -0
- package/dist/client/feature-flags/evaluator.js +346 -0
- package/dist/client/feature-flags/evaluator.js.map +1 -0
- package/dist/client/feature-flags/index.d.ts +4 -0
- package/dist/client/feature-flags/index.d.ts.map +1 -0
- package/dist/client/feature-flags/index.js +3 -0
- package/dist/client/feature-flags/index.js.map +1 -0
- package/dist/client/feature-flags/match-property.d.ts +12 -0
- package/dist/client/feature-flags/match-property.d.ts.map +1 -0
- package/dist/client/feature-flags/match-property.js +340 -0
- package/dist/client/feature-flags/match-property.js.map +1 -0
- package/dist/client/feature-flags/types.d.ts +63 -0
- package/dist/client/feature-flags/types.d.ts.map +1 -0
- package/dist/client/feature-flags/types.js +2 -0
- package/dist/client/feature-flags/types.js.map +1 -0
- package/dist/client/index.d.ts +71 -36
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +143 -32
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/component.d.ts +8 -35
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/lib.d.ts +76 -46
- package/dist/component/lib.d.ts.map +1 -1
- package/dist/component/lib.js +311 -99
- package/dist/component/lib.js.map +1 -1
- package/dist/component/schema.d.ts +18 -1
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/schema.js +16 -2
- package/dist/component/schema.js.map +1 -1
- package/dist/component/version.d.ts +2 -0
- package/dist/component/version.d.ts.map +1 -0
- package/dist/component/version.js +2 -0
- package/dist/component/version.js.map +1 -0
- package/package.json +5 -5
- package/src/client/feature-flags/crypto.ts +12 -0
- package/src/client/feature-flags/evaluator.test.ts +401 -0
- package/src/client/feature-flags/evaluator.ts +467 -0
- package/src/client/feature-flags/index.ts +15 -0
- package/src/client/feature-flags/match-property.test.ts +75 -0
- package/src/client/feature-flags/match-property.ts +347 -0
- package/src/client/feature-flags/types.ts +72 -0
- package/src/client/index.test.ts +60 -12
- package/src/client/index.ts +227 -70
- package/src/component/_generated/component.ts +7 -50
- package/src/component/lib.ts +340 -127
- package/src/component/schema.ts +16 -2
- package/src/component/version.ts +1 -0
package/dist/component/lib.d.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import type { FeatureFlagValue, JsonType } from '@posthog/core';
|
|
2
1
|
export declare const capture: import("convex/server").RegisteredAction<"public", {
|
|
3
2
|
disableGeoip?: boolean | undefined;
|
|
4
3
|
groups?: string | undefined;
|
|
@@ -43,77 +42,108 @@ export declare const captureException: import("convex/server").RegisteredAction<
|
|
|
43
42
|
host: string;
|
|
44
43
|
errorMessage: string;
|
|
45
44
|
}, Promise<void>>;
|
|
46
|
-
export declare const
|
|
47
|
-
disableGeoip?: boolean | undefined;
|
|
48
|
-
groups?: any;
|
|
49
|
-
groupProperties?: any;
|
|
50
|
-
personProperties?: any;
|
|
51
|
-
sendFeatureFlagEvents?: boolean | undefined;
|
|
52
|
-
apiKey: string;
|
|
53
|
-
distinctId: string;
|
|
54
|
-
host: string;
|
|
55
|
-
key: string;
|
|
56
|
-
}, Promise<FeatureFlagValue | null>>;
|
|
57
|
-
export declare const isFeatureEnabled: import("convex/server").RegisteredAction<"public", {
|
|
45
|
+
export declare const evaluateFlag: import("convex/server").RegisteredAction<"public", {
|
|
58
46
|
disableGeoip?: boolean | undefined;
|
|
59
47
|
groups?: any;
|
|
48
|
+
flagKeys?: string[] | undefined;
|
|
60
49
|
groupProperties?: any;
|
|
61
50
|
personProperties?: any;
|
|
62
|
-
sendFeatureFlagEvents?: boolean | undefined;
|
|
63
51
|
apiKey: string;
|
|
64
52
|
distinctId: string;
|
|
65
53
|
host: string;
|
|
66
54
|
key: string;
|
|
67
|
-
}, Promise<
|
|
68
|
-
export declare const
|
|
55
|
+
}, Promise<import("@posthog/core").FeatureFlagValue | null>>;
|
|
56
|
+
export declare const evaluateFlagPayload: import("convex/server").RegisteredAction<"public", {
|
|
69
57
|
disableGeoip?: boolean | undefined;
|
|
70
58
|
groups?: any;
|
|
59
|
+
flagKeys?: string[] | undefined;
|
|
71
60
|
groupProperties?: any;
|
|
72
61
|
personProperties?: any;
|
|
73
|
-
sendFeatureFlagEvents?: boolean | undefined;
|
|
74
|
-
matchValue?: string | boolean | undefined;
|
|
75
62
|
apiKey: string;
|
|
76
63
|
distinctId: string;
|
|
77
64
|
host: string;
|
|
78
65
|
key: string;
|
|
79
|
-
}, Promise<
|
|
80
|
-
|
|
66
|
+
}, Promise<string | number | boolean | {
|
|
67
|
+
[key: string]: import("@posthog/core").JsonType;
|
|
68
|
+
} | import("@posthog/core").JsonType[] | null>>;
|
|
69
|
+
export declare const evaluateAllFlags: import("convex/server").RegisteredAction<"public", {
|
|
81
70
|
disableGeoip?: boolean | undefined;
|
|
82
71
|
groups?: any;
|
|
72
|
+
flagKeys?: string[] | undefined;
|
|
83
73
|
groupProperties?: any;
|
|
84
74
|
personProperties?: any;
|
|
85
|
-
sendFeatureFlagEvents?: boolean | undefined;
|
|
86
75
|
apiKey: string;
|
|
87
76
|
distinctId: string;
|
|
88
77
|
host: string;
|
|
89
|
-
key: string;
|
|
90
78
|
}, Promise<{
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
79
|
+
featureFlags: Record<string, unknown>;
|
|
80
|
+
featureFlagPayloads: Record<string, unknown>;
|
|
81
|
+
}>>;
|
|
82
|
+
/**
|
|
83
|
+
* Returns the latest cached flag definitions, or `null` if none have been fetched yet.
|
|
84
|
+
*
|
|
85
|
+
* The `data` field is a JSON-stringified `FlagDefinitions` object (see `client/feature-flags/types.ts`).
|
|
86
|
+
*/
|
|
87
|
+
export declare const getFlagDefinitions: import("convex/server").RegisteredQuery<"public", {}, Promise<{
|
|
88
|
+
data: string;
|
|
89
|
+
fetchedAt: number;
|
|
90
|
+
etag: string | undefined;
|
|
95
91
|
} | null>>;
|
|
96
|
-
export declare const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
92
|
+
export declare const _setFlagDefinitions: import("convex/server").RegisteredMutation<"internal", {
|
|
93
|
+
etag?: string | undefined;
|
|
94
|
+
data: string;
|
|
95
|
+
}, Promise<void>>;
|
|
96
|
+
export declare const _getCurrentEtag: import("convex/server").RegisteredQuery<"internal", {}, Promise<string | undefined>>;
|
|
97
|
+
/**
|
|
98
|
+
* Fetches flag definitions from PostHog's local-evaluation endpoint and stores them in the
|
|
99
|
+
* `flagDefinitions` table. Called by the consumer app's own cron — they pass in the keys via the
|
|
100
|
+
* `PostHog` client class which knows them already.
|
|
101
|
+
*
|
|
102
|
+
* Args:
|
|
103
|
+
* - `apiKey` — the project API key (`phc_…`)
|
|
104
|
+
* - `personalApiKey` — a feature flags secure API key (`phs_…`, recommended) or personal API
|
|
105
|
+
* key (`phx_…`) with feature-flag read access; local eval is disabled if missing
|
|
106
|
+
* - `host` — optional, defaults to `https://us.i.posthog.com`
|
|
107
|
+
*/
|
|
108
|
+
export declare const refreshFlagDefinitions: import("convex/server").RegisteredAction<"public", {
|
|
109
|
+
host?: string | undefined;
|
|
112
110
|
apiKey: string;
|
|
113
|
-
|
|
114
|
-
host: string;
|
|
111
|
+
personalApiKey: string;
|
|
115
112
|
}, Promise<{
|
|
116
|
-
|
|
117
|
-
|
|
113
|
+
status: "skipped";
|
|
114
|
+
reason: "missing-keys";
|
|
115
|
+
} | {
|
|
116
|
+
status: "error";
|
|
117
|
+
reason: "fetch-failed";
|
|
118
|
+
} | {
|
|
119
|
+
status: "unchanged";
|
|
120
|
+
reason?: undefined;
|
|
121
|
+
} | {
|
|
122
|
+
status: "error";
|
|
123
|
+
reason: "auth";
|
|
124
|
+
} | {
|
|
125
|
+
status: "error";
|
|
126
|
+
reason: "quota";
|
|
127
|
+
} | {
|
|
128
|
+
status: "error";
|
|
129
|
+
reason: "rate-limited";
|
|
130
|
+
} | {
|
|
131
|
+
status: "empty";
|
|
132
|
+
reason?: undefined;
|
|
133
|
+
} | {
|
|
134
|
+
status: "stale";
|
|
135
|
+
reason?: undefined;
|
|
136
|
+
} | {
|
|
137
|
+
status: "error";
|
|
138
|
+
reason: "unexpected-status";
|
|
139
|
+
} | {
|
|
140
|
+
status: "error";
|
|
141
|
+
reason: "parse-failed";
|
|
142
|
+
} | {
|
|
143
|
+
status: "error";
|
|
144
|
+
reason: "invalid-shape";
|
|
145
|
+
} | {
|
|
146
|
+
status: "updated";
|
|
147
|
+
reason?: undefined;
|
|
118
148
|
}>>;
|
|
119
149
|
//# sourceMappingURL=lib.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"lib.d.ts","sourceRoot":"","sources":["../../src/component/lib.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"lib.d.ts","sourceRoot":"","sources":["../../src/component/lib.ts"],"names":[],"mappings":"AAoDA,eAAO,MAAM,OAAO;;;;;;;;;;;iBA0BlB,CAAA;AAEF,eAAO,MAAM,QAAQ;;;;;;iBA+BnB,CAAA;AAEF,eAAO,MAAM,aAAa;;;;;;;;iBA0BxB,CAAA;AAEF,eAAO,MAAM,KAAK;;;;;;iBAgBhB,CAAA;AAEF,eAAO,MAAM,gBAAgB;;;;;;;;iBAiB3B,CAAA;AAqCF,eAAO,MAAM,YAAY;;;;;;;;;;4DAavB,CAAA;AAEF,eAAO,MAAM,mBAAmB;;;;;;;;;;;;+CAW9B,CAAA;AAEF,eAAO,MAAM,gBAAgB;;;;;;;;;;;;GAe3B,CAAA;AAYF;;;;GAIG;AACH,eAAO,MAAM,kBAAkB;;;;UAO7B,CAAA;AAOF,eAAO,MAAM,mBAAmB;;;iBAW9B,CAAA;AAEF,eAAO,MAAM,eAAe,sFAM1B,CAAA;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,sBAAsB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmKjC,CAAA"}
|
package/dist/component/lib.js
CHANGED
|
@@ -1,8 +1,39 @@
|
|
|
1
|
-
import { PostHog } from 'posthog-node/edge';
|
|
2
|
-
import { action } from './_generated/server.js';
|
|
1
|
+
import { PostHog as PostHogEdge } from 'posthog-node/edge';
|
|
2
|
+
import { action, internalMutation, internalQuery, query } from './_generated/server.js';
|
|
3
|
+
import { api, internal } from './_generated/api.js';
|
|
3
4
|
import { v } from 'convex/values';
|
|
4
|
-
|
|
5
|
-
|
|
5
|
+
import { version } from './version.js';
|
|
6
|
+
/**
|
|
7
|
+
* Brand events sent through this component as `posthog-convex` rather than `posthog-edge` in the
|
|
8
|
+
* `$lib` / `$lib_version` properties — makes them filterable in PostHog and lets us attribute
|
|
9
|
+
* issues to the integration vs. raw `posthog-node` usage.
|
|
10
|
+
*/
|
|
11
|
+
class PostHog extends PostHogEdge {
|
|
12
|
+
getLibraryId() {
|
|
13
|
+
return 'posthog-convex';
|
|
14
|
+
}
|
|
15
|
+
getLibraryVersion() {
|
|
16
|
+
return version;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Cache PostHog clients across action invocations within the same Convex isolate.
|
|
21
|
+
*
|
|
22
|
+
* Convex reuses JS isolates between invocations, so module-level state survives. Constructing
|
|
23
|
+
* a fresh client per call (and tearing it down with `shutdown()`) is wasted work — the client
|
|
24
|
+
* carries no per-invocation state once `flush()` has drained its queue.
|
|
25
|
+
*
|
|
26
|
+
* Keyed by `apiKey|host` to support the rare case of multiple credentials sharing one isolate.
|
|
27
|
+
*/
|
|
28
|
+
const clientCache = new Map();
|
|
29
|
+
function getClient(apiKey, host) {
|
|
30
|
+
const key = `${apiKey}|${host}`;
|
|
31
|
+
let client = clientCache.get(key);
|
|
32
|
+
if (!client) {
|
|
33
|
+
client = new PostHog(apiKey, { host, flushAt: 1, flushInterval: 0 });
|
|
34
|
+
clientCache.set(key, client);
|
|
35
|
+
}
|
|
36
|
+
return client;
|
|
6
37
|
}
|
|
7
38
|
/** Properties are JSON-serialized to bypass Convex's restriction on `$`-prefixed field names. */
|
|
8
39
|
function parseProperties(json) {
|
|
@@ -30,8 +61,8 @@ export const capture = action({
|
|
|
30
61
|
disableGeoip: v.optional(v.boolean()),
|
|
31
62
|
},
|
|
32
63
|
handler: async (_ctx, args) => {
|
|
33
|
-
const client =
|
|
34
|
-
client.
|
|
64
|
+
const client = getClient(args.apiKey, args.host);
|
|
65
|
+
await client.captureImmediate({
|
|
35
66
|
distinctId: args.distinctId,
|
|
36
67
|
event: args.event,
|
|
37
68
|
properties: parseProperties(args.properties),
|
|
@@ -41,7 +72,6 @@ export const capture = action({
|
|
|
41
72
|
uuid: args.uuid,
|
|
42
73
|
disableGeoip: args.disableGeoip,
|
|
43
74
|
});
|
|
44
|
-
await client.shutdown();
|
|
45
75
|
},
|
|
46
76
|
});
|
|
47
77
|
export const identify = action({
|
|
@@ -53,13 +83,23 @@ export const identify = action({
|
|
|
53
83
|
disableGeoip: v.optional(v.boolean()),
|
|
54
84
|
},
|
|
55
85
|
handler: async (_ctx, args) => {
|
|
56
|
-
const client =
|
|
57
|
-
|
|
86
|
+
const client = getClient(args.apiKey, args.host);
|
|
87
|
+
// posthog-node's `identifyImmediate` is missing an `await` on `identifyStatelessImmediate`
|
|
88
|
+
// (packages/node/src/client.ts:674), so the returned promise resolves before the event hits
|
|
89
|
+
// the wire. We sidestep that by composing the `$identify` event the same way `identifyImmediate`
|
|
90
|
+
// does and routing it through `captureImmediate`, which awaits correctly.
|
|
91
|
+
const properties = parseProperties(args.properties) ?? {};
|
|
92
|
+
const { $set, $set_once, $anon_distinct_id, ...rest } = properties;
|
|
93
|
+
await client.captureImmediate({
|
|
58
94
|
distinctId: args.distinctId,
|
|
59
|
-
|
|
95
|
+
event: '$identify',
|
|
96
|
+
properties: {
|
|
97
|
+
$set: $set ?? rest,
|
|
98
|
+
$set_once: $set_once ?? {},
|
|
99
|
+
$anon_distinct_id,
|
|
100
|
+
},
|
|
60
101
|
disableGeoip: args.disableGeoip,
|
|
61
102
|
});
|
|
62
|
-
await client.shutdown();
|
|
63
103
|
},
|
|
64
104
|
});
|
|
65
105
|
export const groupIdentify = action({
|
|
@@ -73,15 +113,20 @@ export const groupIdentify = action({
|
|
|
73
113
|
disableGeoip: v.optional(v.boolean()),
|
|
74
114
|
},
|
|
75
115
|
handler: async (_ctx, args) => {
|
|
76
|
-
const client =
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
distinctId: args.distinctId
|
|
116
|
+
const client = getClient(args.apiKey, args.host);
|
|
117
|
+
// posthog-node doesn't expose a `groupIdentifyImmediate`, so we send the same `$groupidentify`
|
|
118
|
+
// event via `captureImmediate` to keep parity with capture/identify/alias/captureException —
|
|
119
|
+
// resolve when the network call completes, without resorting to shutdown().
|
|
120
|
+
await client.captureImmediate({
|
|
121
|
+
distinctId: args.distinctId || `$${args.groupType}_${args.groupKey}`,
|
|
122
|
+
event: '$groupidentify',
|
|
123
|
+
properties: {
|
|
124
|
+
$group_type: args.groupType,
|
|
125
|
+
$group_key: args.groupKey,
|
|
126
|
+
$group_set: parseProperties(args.properties) ?? {},
|
|
127
|
+
},
|
|
82
128
|
disableGeoip: args.disableGeoip,
|
|
83
129
|
});
|
|
84
|
-
await client.shutdown();
|
|
85
130
|
},
|
|
86
131
|
});
|
|
87
132
|
export const alias = action({
|
|
@@ -93,13 +138,12 @@ export const alias = action({
|
|
|
93
138
|
disableGeoip: v.optional(v.boolean()),
|
|
94
139
|
},
|
|
95
140
|
handler: async (_ctx, args) => {
|
|
96
|
-
const client =
|
|
97
|
-
client.
|
|
141
|
+
const client = getClient(args.apiKey, args.host);
|
|
142
|
+
await client.aliasImmediate({
|
|
98
143
|
distinctId: args.distinctId,
|
|
99
144
|
alias: args.alias,
|
|
100
145
|
disableGeoip: args.disableGeoip,
|
|
101
146
|
});
|
|
102
|
-
await client.shutdown();
|
|
103
147
|
},
|
|
104
148
|
});
|
|
105
149
|
export const captureException = action({
|
|
@@ -113,121 +157,289 @@ export const captureException = action({
|
|
|
113
157
|
additionalProperties: v.optional(v.string()),
|
|
114
158
|
},
|
|
115
159
|
handler: async (_ctx, args) => {
|
|
116
|
-
const client =
|
|
160
|
+
const client = getClient(args.apiKey, args.host);
|
|
117
161
|
const error = new Error(args.errorMessage);
|
|
118
162
|
if (args.errorName)
|
|
119
163
|
error.name = args.errorName;
|
|
120
164
|
if (args.errorStack)
|
|
121
165
|
error.stack = args.errorStack;
|
|
122
|
-
client.
|
|
123
|
-
await client.shutdown();
|
|
166
|
+
await client.captureExceptionImmediate(error, args.distinctId, parseProperties(args.additionalProperties));
|
|
124
167
|
},
|
|
125
168
|
});
|
|
126
|
-
// Feature flag
|
|
127
|
-
|
|
169
|
+
// --- Feature flag remote evaluation ---
|
|
170
|
+
//
|
|
171
|
+
// These actions hit PostHog's `/flags` endpoint directly via `posthog-node`. Use them when
|
|
172
|
+
// local evaluation isn't available (no personal API key) or can't reach a verdict (experience
|
|
173
|
+
// continuity flags, static cohorts, properties you don't have in your server context). They
|
|
174
|
+
// require an action context — that's the trade for not needing flag definitions cached upfront.
|
|
175
|
+
const remoteFlagsArgs = {
|
|
128
176
|
apiKey: v.string(),
|
|
129
177
|
host: v.string(),
|
|
130
|
-
key: v.string(),
|
|
131
178
|
distinctId: v.string(),
|
|
132
179
|
groups: v.optional(v.any()),
|
|
133
180
|
personProperties: v.optional(v.any()),
|
|
134
181
|
groupProperties: v.optional(v.any()),
|
|
135
|
-
sendFeatureFlagEvents: v.optional(v.boolean()),
|
|
136
182
|
disableGeoip: v.optional(v.boolean()),
|
|
183
|
+
flagKeys: v.optional(v.array(v.string())),
|
|
137
184
|
};
|
|
138
|
-
function
|
|
185
|
+
function remoteFlagsOptions(args) {
|
|
139
186
|
return {
|
|
140
187
|
groups: args.groups,
|
|
141
188
|
personProperties: args.personProperties,
|
|
142
189
|
groupProperties: args.groupProperties,
|
|
143
|
-
sendFeatureFlagEvents: args.sendFeatureFlagEvents,
|
|
144
190
|
disableGeoip: args.disableGeoip,
|
|
191
|
+
flagKeys: args.flagKeys,
|
|
192
|
+
onlyEvaluateLocally: false,
|
|
145
193
|
};
|
|
146
194
|
}
|
|
147
|
-
export const
|
|
148
|
-
args:
|
|
195
|
+
export const evaluateFlag = action({
|
|
196
|
+
args: { ...remoteFlagsArgs, key: v.string() },
|
|
149
197
|
handler: async (_ctx, args) => {
|
|
150
|
-
const client =
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
198
|
+
const client = getClient(args.apiKey, args.host);
|
|
199
|
+
// Scope the request to just the flag the caller asked about — otherwise PostHog evaluates
|
|
200
|
+
// every flag in the project on every call. Honour an explicit `flagKeys` override when given.
|
|
201
|
+
const snapshot = await client.evaluateFlags(args.distinctId, {
|
|
202
|
+
...remoteFlagsOptions(args),
|
|
203
|
+
flagKeys: args.flagKeys ?? [args.key],
|
|
204
|
+
});
|
|
205
|
+
const value = snapshot.getFlag(args.key);
|
|
206
|
+
return value ?? null;
|
|
154
207
|
},
|
|
155
208
|
});
|
|
156
|
-
export const
|
|
157
|
-
args:
|
|
209
|
+
export const evaluateFlagPayload = action({
|
|
210
|
+
args: { ...remoteFlagsArgs, key: v.string() },
|
|
158
211
|
handler: async (_ctx, args) => {
|
|
159
|
-
const client =
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
212
|
+
const client = getClient(args.apiKey, args.host);
|
|
213
|
+
const snapshot = await client.evaluateFlags(args.distinctId, {
|
|
214
|
+
...remoteFlagsOptions(args),
|
|
215
|
+
flagKeys: args.flagKeys ?? [args.key],
|
|
216
|
+
});
|
|
217
|
+
const payload = snapshot.getFlagPayload(args.key);
|
|
218
|
+
return payload ?? null;
|
|
163
219
|
},
|
|
164
220
|
});
|
|
165
|
-
export const
|
|
166
|
-
args:
|
|
167
|
-
...featureFlagArgs,
|
|
168
|
-
matchValue: v.optional(v.union(v.string(), v.boolean())),
|
|
169
|
-
},
|
|
221
|
+
export const evaluateAllFlags = action({
|
|
222
|
+
args: remoteFlagsArgs,
|
|
170
223
|
handler: async (_ctx, args) => {
|
|
171
|
-
const client =
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
224
|
+
const client = getClient(args.apiKey, args.host);
|
|
225
|
+
const snapshot = await client.evaluateFlags(args.distinctId, remoteFlagsOptions(args));
|
|
226
|
+
const featureFlags = {};
|
|
227
|
+
const featureFlagPayloads = {};
|
|
228
|
+
for (const key of snapshot.keys) {
|
|
229
|
+
const value = snapshot.getFlag(key);
|
|
230
|
+
if (value !== undefined)
|
|
231
|
+
featureFlags[key] = value;
|
|
232
|
+
const payload = snapshot.getFlagPayload(key);
|
|
233
|
+
if (payload !== undefined)
|
|
234
|
+
featureFlagPayloads[key] = payload;
|
|
235
|
+
}
|
|
236
|
+
return { featureFlags, featureFlagPayloads };
|
|
175
237
|
},
|
|
176
238
|
});
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
239
|
+
// --- Feature flag local evaluation ---
|
|
240
|
+
//
|
|
241
|
+
// Feature flag definitions are fetched on demand by `refreshFlagDefinitions` and stored in the
|
|
242
|
+
// `flagDefinitions` table. Clients read them via `getFlagDefinitions` and evaluate flags locally
|
|
243
|
+
// — there is no per-call action for flag evaluation.
|
|
244
|
+
//
|
|
245
|
+
// The action takes credentials as args. The consumer's app schedules the refresh cron and passes
|
|
246
|
+
// them in — typically via `posthog.refreshFlagDefinitions(ctx)` on the client class, which
|
|
247
|
+
// forwards the keys it was constructed with.
|
|
248
|
+
/**
|
|
249
|
+
* Returns the latest cached flag definitions, or `null` if none have been fetched yet.
|
|
250
|
+
*
|
|
251
|
+
* The `data` field is a JSON-stringified `FlagDefinitions` object (see `client/feature-flags/types.ts`).
|
|
252
|
+
*/
|
|
253
|
+
export const getFlagDefinitions = query({
|
|
254
|
+
args: {},
|
|
255
|
+
handler: async (ctx) => {
|
|
256
|
+
const row = await ctx.db.query('flagDefinitions').order('desc').first();
|
|
257
|
+
if (!row)
|
|
184
258
|
return null;
|
|
185
|
-
return {
|
|
186
|
-
key: result.key,
|
|
187
|
-
enabled: result.enabled,
|
|
188
|
-
variant: result.variant ?? null,
|
|
189
|
-
payload: result.payload ?? null,
|
|
190
|
-
};
|
|
259
|
+
return { data: row.data, fetchedAt: row.fetchedAt, etag: row.etag };
|
|
191
260
|
},
|
|
192
261
|
});
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
groups: args.groups,
|
|
209
|
-
personProperties: args.personProperties,
|
|
210
|
-
groupProperties: args.groupProperties,
|
|
211
|
-
disableGeoip: args.disableGeoip,
|
|
212
|
-
flagKeys: args.flagKeys,
|
|
213
|
-
});
|
|
214
|
-
await client.shutdown();
|
|
215
|
-
return result;
|
|
262
|
+
// All three queries against `flagDefinitions` use `.order('desc').first()` so they all see the
|
|
263
|
+
// same row even if a stray duplicate ever lands in the table. Without consistent ordering,
|
|
264
|
+
// `_setFlagDefinitions` could upsert against an older row than the one `getFlagDefinitions`
|
|
265
|
+
// returns, leaving the row callers actually read perpetually stale.
|
|
266
|
+
export const _setFlagDefinitions = internalMutation({
|
|
267
|
+
args: { data: v.string(), etag: v.optional(v.string()) },
|
|
268
|
+
handler: async (ctx, args) => {
|
|
269
|
+
const existing = await ctx.db.query('flagDefinitions').order('desc').first();
|
|
270
|
+
const next = { data: args.data, fetchedAt: Date.now(), etag: args.etag };
|
|
271
|
+
if (existing) {
|
|
272
|
+
await ctx.db.replace(existing._id, next);
|
|
273
|
+
}
|
|
274
|
+
else {
|
|
275
|
+
await ctx.db.insert('flagDefinitions', next);
|
|
276
|
+
}
|
|
216
277
|
},
|
|
217
278
|
});
|
|
218
|
-
export const
|
|
219
|
-
args:
|
|
220
|
-
handler: async (
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
279
|
+
export const _getCurrentEtag = internalQuery({
|
|
280
|
+
args: {},
|
|
281
|
+
handler: async (ctx) => {
|
|
282
|
+
const row = await ctx.db.query('flagDefinitions').order('desc').first();
|
|
283
|
+
return row?.etag;
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
/**
|
|
287
|
+
* Fetches flag definitions from PostHog's local-evaluation endpoint and stores them in the
|
|
288
|
+
* `flagDefinitions` table. Called by the consumer app's own cron — they pass in the keys via the
|
|
289
|
+
* `PostHog` client class which knows them already.
|
|
290
|
+
*
|
|
291
|
+
* Args:
|
|
292
|
+
* - `apiKey` — the project API key (`phc_…`)
|
|
293
|
+
* - `personalApiKey` — a feature flags secure API key (`phs_…`, recommended) or personal API
|
|
294
|
+
* key (`phx_…`) with feature-flag read access; local eval is disabled if missing
|
|
295
|
+
* - `host` — optional, defaults to `https://us.i.posthog.com`
|
|
296
|
+
*/
|
|
297
|
+
export const refreshFlagDefinitions = action({
|
|
298
|
+
args: {
|
|
299
|
+
apiKey: v.string(),
|
|
300
|
+
personalApiKey: v.string(),
|
|
301
|
+
host: v.optional(v.string()),
|
|
302
|
+
},
|
|
303
|
+
handler: async (ctx, args) => {
|
|
304
|
+
const projectApiKey = args.apiKey.trim();
|
|
305
|
+
const personalApiKey = args.personalApiKey.trim();
|
|
306
|
+
const host = (args.host?.trim() || '').replace(/\/$/, '') || 'https://us.i.posthog.com';
|
|
307
|
+
if (!projectApiKey || !personalApiKey) {
|
|
308
|
+
// Local evaluation requires both keys. Return a status rather than throwing so the caller
|
|
309
|
+
// (typically a cron) can surface it cleanly.
|
|
310
|
+
return { status: 'skipped', reason: 'missing-keys' };
|
|
311
|
+
}
|
|
312
|
+
const etag = await ctx.runQuery(internal.lib._getCurrentEtag, {});
|
|
313
|
+
const url = `${host}/flags/definitions?token=${projectApiKey}&send_cohorts`;
|
|
314
|
+
const headers = {
|
|
315
|
+
'Content-Type': 'application/json',
|
|
316
|
+
Authorization: `Bearer ${personalApiKey}`,
|
|
317
|
+
};
|
|
318
|
+
if (etag)
|
|
319
|
+
headers['If-None-Match'] = etag;
|
|
320
|
+
// PostHog's `/flags/definitions` endpoint sits behind a warm-on-demand cache. The first
|
|
321
|
+
// call after a flag is created — or any time the cache evicts — comes back as a 503 with
|
|
322
|
+
// "Required data not found in cache. … Please try again later." Retry transient 5xx (and
|
|
323
|
+
// 429s, since rate limiting on a one-minute cron is similarly worth waiting out) with
|
|
324
|
+
// bounded exponential backoff so a single cold-cache hit doesn't make callers wait a full
|
|
325
|
+
// cron tick. Tests override the delays via env var to keep retry-heavy cases snappy.
|
|
326
|
+
const testOverride = Number(process.env.POSTHOG_FLAGS_RETRY_DELAY_MS_OVERRIDE);
|
|
327
|
+
const RETRY_DELAYS_MS = Number.isFinite(testOverride) && testOverride >= 0
|
|
328
|
+
? [testOverride, testOverride, testOverride]
|
|
329
|
+
: [1500, 3000, 6000];
|
|
330
|
+
let response;
|
|
331
|
+
let attempt = 0;
|
|
332
|
+
while (true) {
|
|
333
|
+
try {
|
|
334
|
+
response = await fetch(url, { method: 'GET', headers });
|
|
335
|
+
}
|
|
336
|
+
catch (err) {
|
|
337
|
+
console.warn('[PostHog] Failed to fetch flag definitions:', err);
|
|
338
|
+
return { status: 'error', reason: 'fetch-failed' };
|
|
339
|
+
}
|
|
340
|
+
const transient = response.status === 429 || (response.status >= 500 && response.status < 600);
|
|
341
|
+
if (!transient || attempt >= RETRY_DELAYS_MS.length)
|
|
342
|
+
break;
|
|
343
|
+
const wait = RETRY_DELAYS_MS[attempt];
|
|
344
|
+
attempt++;
|
|
345
|
+
// Drain the body so the connection can be reused.
|
|
346
|
+
try {
|
|
347
|
+
await response.text();
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
// ignore
|
|
351
|
+
}
|
|
352
|
+
console.warn(`[PostHog] Flag definitions fetch returned ${response.status}; retrying in ${wait}ms (attempt ${attempt}/${RETRY_DELAYS_MS.length}).`);
|
|
353
|
+
await new Promise((r) => setTimeout(r, wait));
|
|
354
|
+
}
|
|
355
|
+
if (response.status === 304) {
|
|
356
|
+
return { status: 'unchanged' };
|
|
357
|
+
}
|
|
358
|
+
if (response.status === 401 || response.status === 403) {
|
|
359
|
+
console.warn(`[PostHog] Flag definitions fetch failed with ${response.status}. ` +
|
|
360
|
+
`Check that the personal/feature-flags-secure API key has read access to feature flags.`);
|
|
361
|
+
return { status: 'error', reason: 'auth' };
|
|
362
|
+
}
|
|
363
|
+
if (response.status === 402) {
|
|
364
|
+
console.warn('[PostHog] Feature flags quota limit exceeded — disabling local evaluation.');
|
|
365
|
+
await ctx.runMutation(internal.lib._setFlagDefinitions, {
|
|
366
|
+
data: JSON.stringify({ flags: [], groupTypeMapping: {}, cohorts: {} }),
|
|
367
|
+
etag: undefined,
|
|
368
|
+
});
|
|
369
|
+
return { status: 'error', reason: 'quota' };
|
|
370
|
+
}
|
|
371
|
+
if (response.status === 429) {
|
|
372
|
+
console.warn('[PostHog] Rate limited while fetching flag definitions (after retries).');
|
|
373
|
+
return { status: 'error', reason: 'rate-limited' };
|
|
374
|
+
}
|
|
375
|
+
if (response.status !== 200) {
|
|
376
|
+
let bodyText = '<no body>';
|
|
377
|
+
try {
|
|
378
|
+
bodyText = (await response.text()).slice(0, 500);
|
|
379
|
+
}
|
|
380
|
+
catch {
|
|
381
|
+
// ignore — body wasn't readable
|
|
382
|
+
}
|
|
383
|
+
// PostHog returns 503 with `Required data not found in cache` for two indistinguishable
|
|
384
|
+
// cases: (a) the project has zero flag definitions configured, and (b) the warm-on-demand
|
|
385
|
+
// cache evicted and hasn't repopulated yet. We can't tell which, so we treat them the same
|
|
386
|
+
// way: if we have no existing defs cached, persist an empty snapshot so eval methods can
|
|
387
|
+
// resolve flag lookups to `undefined` cleanly and the UI stops looking broken. If we
|
|
388
|
+
// already had defs cached, leave them alone — last-known-good beats a flap.
|
|
389
|
+
const looksCacheCold = response.status === 503 && bodyText.toLowerCase().includes('required data not found in cache');
|
|
390
|
+
if (looksCacheCold) {
|
|
391
|
+
const existing = await ctx.runQuery(api.lib.getFlagDefinitions, {});
|
|
392
|
+
const STALE_AFTER_MS = 5 * 60 * 1000;
|
|
393
|
+
if (existing === null) {
|
|
394
|
+
// No prior cache — write an empty snapshot so subsequent reads are deterministic and
|
|
395
|
+
// the UI shows "no flags" instead of "loading".
|
|
396
|
+
await ctx.runMutation(internal.lib._setFlagDefinitions, {
|
|
397
|
+
data: JSON.stringify({ flags: [], groupTypeMapping: {}, cohorts: {} }),
|
|
398
|
+
etag: undefined,
|
|
399
|
+
});
|
|
400
|
+
console.info("[PostHog] No flag definitions returned (project may have no flags yet, or PostHog's cache is warming). Cached an empty snapshot.");
|
|
401
|
+
return { status: 'empty' };
|
|
402
|
+
}
|
|
403
|
+
if (Date.now() - existing.fetchedAt > STALE_AFTER_MS) {
|
|
404
|
+
// We had cached defs but haven't successfully refreshed them in a while — could be that
|
|
405
|
+
// every flag was deleted upstream and PostHog now responds with "no flags in cache" 503s.
|
|
406
|
+
// Replace with an empty snapshot rather than serving stale data indefinitely.
|
|
407
|
+
await ctx.runMutation(internal.lib._setFlagDefinitions, {
|
|
408
|
+
data: JSON.stringify({ flags: [], groupTypeMapping: {}, cohorts: {} }),
|
|
409
|
+
etag: undefined,
|
|
410
|
+
});
|
|
411
|
+
console.info('[PostHog] Cached flag definitions are >5 minutes stale and PostHog reports an empty cache. Replaced with an empty snapshot.');
|
|
412
|
+
return { status: 'empty' };
|
|
413
|
+
}
|
|
414
|
+
// Recent cached defs — keep them while PostHog's cache potentially warms back up.
|
|
415
|
+
return { status: 'stale' };
|
|
416
|
+
}
|
|
417
|
+
console.warn(`[PostHog] Unexpected status ${response.status} fetching flag definitions from ${url.replace(projectApiKey, '<token>')}. ` +
|
|
418
|
+
`Response body: ${bodyText}`);
|
|
419
|
+
return { status: 'error', reason: 'unexpected-status' };
|
|
420
|
+
}
|
|
421
|
+
let body;
|
|
422
|
+
try {
|
|
423
|
+
body = (await response.json());
|
|
424
|
+
}
|
|
425
|
+
catch (err) {
|
|
426
|
+
console.warn('[PostHog] Failed to parse flag definitions response:', err);
|
|
427
|
+
return { status: 'error', reason: 'parse-failed' };
|
|
428
|
+
}
|
|
429
|
+
if (!('flags' in body)) {
|
|
430
|
+
console.warn('[PostHog] Flag definitions response missing `flags` field.');
|
|
431
|
+
return { status: 'error', reason: 'invalid-shape' };
|
|
432
|
+
}
|
|
433
|
+
const data = JSON.stringify({
|
|
434
|
+
flags: body.flags ?? [],
|
|
435
|
+
groupTypeMapping: body.group_type_mapping ?? {},
|
|
436
|
+
cohorts: body.cohorts ?? {},
|
|
437
|
+
});
|
|
438
|
+
await ctx.runMutation(internal.lib._setFlagDefinitions, {
|
|
439
|
+
data,
|
|
440
|
+
etag: response.headers.get('ETag') ?? undefined,
|
|
228
441
|
});
|
|
229
|
-
|
|
230
|
-
return result;
|
|
442
|
+
return { status: 'updated' };
|
|
231
443
|
},
|
|
232
444
|
});
|
|
233
445
|
//# sourceMappingURL=lib.js.map
|