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