@posthog/convex 1.0.10 → 2.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 +88 -36
- package/dist/client/index.d.ts +17 -22
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +26 -41
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/api.d.ts +4 -0
- package/dist/component/_generated/api.d.ts.map +1 -1
- package/dist/component/_generated/api.js.map +1 -1
- package/dist/component/_generated/component.d.ts +1 -21
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/_generated/server.d.ts +11 -0
- package/dist/component/_generated/server.d.ts.map +1 -1
- package/dist/component/_generated/server.js +1 -0
- package/dist/component/_generated/server.js.map +1 -1
- package/dist/component/convex.config.d.ts +18 -1
- package/dist/component/convex.config.d.ts.map +1 -1
- package/dist/component/convex.config.js +21 -1
- package/dist/component/convex.config.js.map +1 -1
- package/dist/component/crons.d.ts +12 -0
- package/dist/component/crons.d.ts.map +1 -0
- package/dist/component/crons.js +42 -0
- package/dist/component/crons.js.map +1 -0
- package/dist/component/lib.d.ts +18 -32
- package/dist/component/lib.d.ts.map +1 -1
- package/dist/component/lib.js +90 -60
- package/dist/component/lib.js.map +1 -1
- package/dist/component/version.d.ts +1 -1
- package/dist/component/version.d.ts.map +1 -1
- package/dist/component/version.js +1 -1
- package/dist/component/version.js.map +1 -1
- package/package.json +5 -4
- package/src/client/index.test.ts +85 -63
- package/src/client/index.ts +35 -60
- package/src/component/_generated/api.ts +4 -0
- package/src/component/_generated/component.ts +3 -27
- package/src/component/_generated/server.ts +11 -0
- package/src/component/convex.config.ts +21 -1
- package/src/component/crons.test.ts +62 -0
- package/src/component/crons.ts +52 -0
- package/src/component/lib.ts +86 -59
- package/src/component/version.ts +1 -1
package/src/component/lib.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { PostHog as PostHogEdge } from 'posthog-node/edge'
|
|
2
|
-
import { action, internalMutation, internalQuery, query } from './_generated/server.js'
|
|
2
|
+
import { action, env, internalMutation, internalQuery, query } from './_generated/server.js'
|
|
3
3
|
import { api, internal } from './_generated/api.js'
|
|
4
4
|
import { v } from 'convex/values'
|
|
5
5
|
import { version } from './version.js'
|
|
@@ -18,6 +18,33 @@ class PostHog extends PostHogEdge {
|
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
const DEFAULT_HOST = 'https://us.i.posthog.com'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Resolve the credentials and host the component was configured with.
|
|
25
|
+
*
|
|
26
|
+
* Reads the typed `env` from `_generated/server` (declared in `convex.config.ts`). The
|
|
27
|
+
* installing app wires the values via `app.use(posthog, { env: { ... } })`, typically
|
|
28
|
+
* threading them straight through from its own deployment env vars. Trimming guards
|
|
29
|
+
* against accidental whitespace from `npx convex env set`.
|
|
30
|
+
*/
|
|
31
|
+
function readConfig(): { projectToken: string; host: string; personalApiKey: string } {
|
|
32
|
+
const projectToken = (env.POSTHOG_PROJECT_TOKEN ?? '').trim()
|
|
33
|
+
const host = (env.POSTHOG_HOST ?? '').trim() || DEFAULT_HOST
|
|
34
|
+
const personalApiKey = (env.POSTHOG_PERSONAL_API_KEY ?? '').trim()
|
|
35
|
+
if (!projectToken) {
|
|
36
|
+
// Convex's typed env-var validation should prevent an empty `POSTHOG_PROJECT_TOKEN` at deploy time,
|
|
37
|
+
// but the gate is enforced at the app's `convex.config.ts`. Log loudly here so anyone hitting
|
|
38
|
+
// an unexpected empty value (e.g. the token was cleared post-deploy on a stale isolate) has a trail
|
|
39
|
+
// to follow rather than silently dropped events.
|
|
40
|
+
console.warn(
|
|
41
|
+
'[PostHog] POSTHOG_PROJECT_TOKEN is not configured; this event will be dropped. ' +
|
|
42
|
+
'Set it with `npx convex env set POSTHOG_PROJECT_TOKEN phc_…` and redeploy.'
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
return { projectToken, host, personalApiKey }
|
|
46
|
+
}
|
|
47
|
+
|
|
21
48
|
/**
|
|
22
49
|
* Cache PostHog clients across action invocations within the same Convex isolate.
|
|
23
50
|
*
|
|
@@ -25,15 +52,16 @@ class PostHog extends PostHogEdge {
|
|
|
25
52
|
* a fresh client per call (and tearing it down with `shutdown()`) is wasted work — the client
|
|
26
53
|
* carries no per-invocation state once `flush()` has drained its queue.
|
|
27
54
|
*
|
|
28
|
-
* Keyed by `
|
|
55
|
+
* Keyed by `projectToken|host` so a deployment that rotates its env vars (via `npx convex env set`)
|
|
56
|
+
* picks up the new client without restarting the isolate.
|
|
29
57
|
*/
|
|
30
58
|
const clientCache = new Map<string, PostHog>()
|
|
31
59
|
|
|
32
|
-
function getClient(
|
|
33
|
-
const key = `${
|
|
60
|
+
function getClient(projectToken: string, host: string): PostHog {
|
|
61
|
+
const key = `${projectToken}|${host}`
|
|
34
62
|
let client = clientCache.get(key)
|
|
35
63
|
if (!client) {
|
|
36
|
-
client = new PostHog(
|
|
64
|
+
client = new PostHog(projectToken, { host, flushAt: 1, flushInterval: 0 })
|
|
37
65
|
clientCache.set(key, client)
|
|
38
66
|
}
|
|
39
67
|
return client
|
|
@@ -52,8 +80,6 @@ function parseProperties(json: string | undefined): Record<string, unknown> | un
|
|
|
52
80
|
|
|
53
81
|
export const capture = action({
|
|
54
82
|
args: {
|
|
55
|
-
apiKey: v.string(),
|
|
56
|
-
host: v.string(),
|
|
57
83
|
distinctId: v.string(),
|
|
58
84
|
event: v.string(),
|
|
59
85
|
properties: v.optional(v.string()),
|
|
@@ -64,7 +90,9 @@ export const capture = action({
|
|
|
64
90
|
disableGeoip: v.optional(v.boolean()),
|
|
65
91
|
},
|
|
66
92
|
handler: async (_ctx, args) => {
|
|
67
|
-
const
|
|
93
|
+
const { projectToken, host } = readConfig()
|
|
94
|
+
if (!projectToken) return
|
|
95
|
+
const client = getClient(projectToken, host)
|
|
68
96
|
await client.captureImmediate({
|
|
69
97
|
distinctId: args.distinctId,
|
|
70
98
|
event: args.event,
|
|
@@ -80,14 +108,14 @@ export const capture = action({
|
|
|
80
108
|
|
|
81
109
|
export const identify = action({
|
|
82
110
|
args: {
|
|
83
|
-
apiKey: v.string(),
|
|
84
|
-
host: v.string(),
|
|
85
111
|
distinctId: v.string(),
|
|
86
112
|
properties: v.optional(v.string()),
|
|
87
113
|
disableGeoip: v.optional(v.boolean()),
|
|
88
114
|
},
|
|
89
115
|
handler: async (_ctx, args) => {
|
|
90
|
-
const
|
|
116
|
+
const { projectToken, host } = readConfig()
|
|
117
|
+
if (!projectToken) return
|
|
118
|
+
const client = getClient(projectToken, host)
|
|
91
119
|
// posthog-node's `identifyImmediate` is missing an `await` on `identifyStatelessImmediate`
|
|
92
120
|
// (packages/node/src/client.ts:674), so the returned promise resolves before the event hits
|
|
93
121
|
// the wire. We sidestep that by composing the `$identify` event the same way `identifyImmediate`
|
|
@@ -113,8 +141,6 @@ export const identify = action({
|
|
|
113
141
|
|
|
114
142
|
export const groupIdentify = action({
|
|
115
143
|
args: {
|
|
116
|
-
apiKey: v.string(),
|
|
117
|
-
host: v.string(),
|
|
118
144
|
groupType: v.string(),
|
|
119
145
|
groupKey: v.string(),
|
|
120
146
|
properties: v.optional(v.string()),
|
|
@@ -122,7 +148,9 @@ export const groupIdentify = action({
|
|
|
122
148
|
disableGeoip: v.optional(v.boolean()),
|
|
123
149
|
},
|
|
124
150
|
handler: async (_ctx, args) => {
|
|
125
|
-
const
|
|
151
|
+
const { projectToken, host } = readConfig()
|
|
152
|
+
if (!projectToken) return
|
|
153
|
+
const client = getClient(projectToken, host)
|
|
126
154
|
// posthog-node doesn't expose a `groupIdentifyImmediate`, so we send the same `$groupidentify`
|
|
127
155
|
// event via `captureImmediate` to keep parity with capture/identify/alias/captureException —
|
|
128
156
|
// resolve when the network call completes, without resorting to shutdown().
|
|
@@ -141,14 +169,14 @@ export const groupIdentify = action({
|
|
|
141
169
|
|
|
142
170
|
export const alias = action({
|
|
143
171
|
args: {
|
|
144
|
-
apiKey: v.string(),
|
|
145
|
-
host: v.string(),
|
|
146
172
|
distinctId: v.string(),
|
|
147
173
|
alias: v.string(),
|
|
148
174
|
disableGeoip: v.optional(v.boolean()),
|
|
149
175
|
},
|
|
150
176
|
handler: async (_ctx, args) => {
|
|
151
|
-
const
|
|
177
|
+
const { projectToken, host } = readConfig()
|
|
178
|
+
if (!projectToken) return
|
|
179
|
+
const client = getClient(projectToken, host)
|
|
152
180
|
await client.aliasImmediate({
|
|
153
181
|
distinctId: args.distinctId,
|
|
154
182
|
alias: args.alias,
|
|
@@ -159,8 +187,6 @@ export const alias = action({
|
|
|
159
187
|
|
|
160
188
|
export const captureException = action({
|
|
161
189
|
args: {
|
|
162
|
-
apiKey: v.string(),
|
|
163
|
-
host: v.string(),
|
|
164
190
|
distinctId: v.optional(v.string()),
|
|
165
191
|
errorMessage: v.string(),
|
|
166
192
|
errorStack: v.optional(v.string()),
|
|
@@ -168,7 +194,9 @@ export const captureException = action({
|
|
|
168
194
|
additionalProperties: v.optional(v.string()),
|
|
169
195
|
},
|
|
170
196
|
handler: async (_ctx, args) => {
|
|
171
|
-
const
|
|
197
|
+
const { projectToken, host } = readConfig()
|
|
198
|
+
if (!projectToken) return
|
|
199
|
+
const client = getClient(projectToken, host)
|
|
172
200
|
const error = new Error(args.errorMessage)
|
|
173
201
|
if (args.errorName) error.name = args.errorName
|
|
174
202
|
if (args.errorStack) error.stack = args.errorStack
|
|
@@ -184,8 +212,6 @@ export const captureException = action({
|
|
|
184
212
|
// require an action context — that's the trade for not needing flag definitions cached upfront.
|
|
185
213
|
|
|
186
214
|
const remoteFlagsArgs = {
|
|
187
|
-
apiKey: v.string(),
|
|
188
|
-
host: v.string(),
|
|
189
215
|
distinctId: v.string(),
|
|
190
216
|
groups: v.optional(v.any()),
|
|
191
217
|
personProperties: v.optional(v.any()),
|
|
@@ -214,7 +240,9 @@ function remoteFlagsOptions(args: {
|
|
|
214
240
|
export const evaluateFlag = action({
|
|
215
241
|
args: { ...remoteFlagsArgs, key: v.string() },
|
|
216
242
|
handler: async (_ctx, args) => {
|
|
217
|
-
const
|
|
243
|
+
const { projectToken, host } = readConfig()
|
|
244
|
+
if (!projectToken) return null
|
|
245
|
+
const client = getClient(projectToken, host)
|
|
218
246
|
// Scope the request to just the flag the caller asked about — otherwise PostHog evaluates
|
|
219
247
|
// every flag in the project on every call. Honour an explicit `flagKeys` override when given.
|
|
220
248
|
const snapshot = await client.evaluateFlags(args.distinctId, {
|
|
@@ -229,7 +257,9 @@ export const evaluateFlag = action({
|
|
|
229
257
|
export const evaluateFlagPayload = action({
|
|
230
258
|
args: { ...remoteFlagsArgs, key: v.string() },
|
|
231
259
|
handler: async (_ctx, args) => {
|
|
232
|
-
const
|
|
260
|
+
const { projectToken, host } = readConfig()
|
|
261
|
+
if (!projectToken) return null
|
|
262
|
+
const client = getClient(projectToken, host)
|
|
233
263
|
const snapshot = await client.evaluateFlags(args.distinctId, {
|
|
234
264
|
...remoteFlagsOptions(args),
|
|
235
265
|
flagKeys: args.flagKeys ?? [args.key],
|
|
@@ -242,7 +272,9 @@ export const evaluateFlagPayload = action({
|
|
|
242
272
|
export const evaluateAllFlags = action({
|
|
243
273
|
args: remoteFlagsArgs,
|
|
244
274
|
handler: async (_ctx, args) => {
|
|
245
|
-
const
|
|
275
|
+
const { projectToken, host } = readConfig()
|
|
276
|
+
if (!projectToken) return { featureFlags: {}, featureFlagPayloads: {} }
|
|
277
|
+
const client = getClient(projectToken, host)
|
|
246
278
|
const snapshot = await client.evaluateFlags(args.distinctId, remoteFlagsOptions(args))
|
|
247
279
|
const featureFlags: Record<string, unknown> = {}
|
|
248
280
|
const featureFlagPayloads: Record<string, unknown> = {}
|
|
@@ -258,25 +290,30 @@ export const evaluateAllFlags = action({
|
|
|
258
290
|
|
|
259
291
|
// --- Feature flag local evaluation ---
|
|
260
292
|
//
|
|
261
|
-
//
|
|
262
|
-
// `
|
|
263
|
-
//
|
|
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.
|
|
293
|
+
// Flag definitions are fetched on a cron registered inside this component (only when
|
|
294
|
+
// `POSTHOG_PERSONAL_API_KEY` is set — see `crons.ts`) and stored in the `flagDefinitions`
|
|
295
|
+
// table. Clients read them via `getFlagDefinitions` and evaluate flags locally — there is
|
|
296
|
+
// no per-call action for flag evaluation.
|
|
268
297
|
|
|
269
298
|
/**
|
|
270
|
-
* Returns the
|
|
299
|
+
* Returns the cached flag definitions plus whether local evaluation is configured at all.
|
|
271
300
|
*
|
|
272
|
-
*
|
|
301
|
+
* `localEvalConfigured` reflects whether `POSTHOG_PERSONAL_API_KEY` is set on the component —
|
|
302
|
+
* the client uses this to distinguish "you haven't set up local eval" (throw, point the user
|
|
303
|
+
* at the remote `evaluateFlag` methods) from "PAK is set but the cron hasn't fetched yet"
|
|
304
|
+
* (return `undefined` gracefully). `data` is null until the first successful refresh.
|
|
305
|
+
*
|
|
306
|
+
* `data` is a JSON-stringified `FlagDefinitions` object (see `client/feature-flags/types.ts`).
|
|
273
307
|
*/
|
|
274
308
|
export const getFlagDefinitions = query({
|
|
275
309
|
args: {},
|
|
276
310
|
handler: async (ctx) => {
|
|
311
|
+
const localEvalConfigured = !!(env.POSTHOG_PERSONAL_API_KEY ?? '').trim()
|
|
277
312
|
const row = await ctx.db.query('flagDefinitions').order('desc').first()
|
|
278
|
-
if (!row)
|
|
279
|
-
|
|
313
|
+
if (!row) {
|
|
314
|
+
return { localEvalConfigured, data: null, fetchedAt: null, etag: undefined }
|
|
315
|
+
}
|
|
316
|
+
return { localEvalConfigured, data: row.data, fetchedAt: row.fetchedAt, etag: row.etag }
|
|
280
317
|
},
|
|
281
318
|
})
|
|
282
319
|
|
|
@@ -308,35 +345,25 @@ export const _getCurrentEtag = internalQuery({
|
|
|
308
345
|
|
|
309
346
|
/**
|
|
310
347
|
* Fetches flag definitions from PostHog's local-evaluation endpoint and stores them in the
|
|
311
|
-
* `flagDefinitions` table. Called by the
|
|
312
|
-
* `
|
|
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`
|
|
348
|
+
* `flagDefinitions` table. Called automatically by the cron registered in `crons.ts` when
|
|
349
|
+
* `POSTHOG_PERSONAL_API_KEY` is set, and also exposed publicly so the client's
|
|
350
|
+
* `reloadFeatureFlags(ctx)` method (parity with `posthog-node`) can trigger an on-demand refresh.
|
|
319
351
|
*/
|
|
320
352
|
export const refreshFlagDefinitions = action({
|
|
321
|
-
args: {
|
|
322
|
-
|
|
323
|
-
personalApiKey
|
|
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'
|
|
353
|
+
args: {},
|
|
354
|
+
handler: async (ctx) => {
|
|
355
|
+
const { projectToken, host, personalApiKey } = readConfig()
|
|
330
356
|
|
|
331
|
-
if (!
|
|
332
|
-
//
|
|
333
|
-
// (
|
|
357
|
+
if (!projectToken || !personalApiKey) {
|
|
358
|
+
// The cron is conditionally registered on `POSTHOG_PERSONAL_API_KEY`, so reaching this branch
|
|
359
|
+
// means either env vars were cleared after deploy (cron still scheduled) or the project token wasn't
|
|
360
|
+
// configured. Return a status rather than throwing so the cron doesn't churn on errors.
|
|
334
361
|
return { status: 'skipped' as const, reason: 'missing-keys' as const }
|
|
335
362
|
}
|
|
336
363
|
|
|
337
364
|
const etag = await ctx.runQuery(internal.lib._getCurrentEtag, {})
|
|
338
365
|
|
|
339
|
-
const url = `${host}/flags/definitions?token=${
|
|
366
|
+
const url = `${host.replace(/\/$/, '')}/flags/definitions?token=${projectToken}&send_cohorts`
|
|
340
367
|
const headers: Record<string, string> = {
|
|
341
368
|
'Content-Type': 'application/json',
|
|
342
369
|
Authorization: `Bearer ${personalApiKey}`,
|
|
@@ -419,7 +446,7 @@ export const refreshFlagDefinitions = action({
|
|
|
419
446
|
if (looksCacheCold) {
|
|
420
447
|
const existing = await ctx.runQuery(api.lib.getFlagDefinitions, {})
|
|
421
448
|
const STALE_AFTER_MS = 5 * 60 * 1000
|
|
422
|
-
if (existing === null) {
|
|
449
|
+
if (existing.fetchedAt === null) {
|
|
423
450
|
// No prior cache — write an empty snapshot so subsequent reads are deterministic and
|
|
424
451
|
// the UI shows "no flags" instead of "loading".
|
|
425
452
|
await ctx.runMutation(internal.lib._setFlagDefinitions, {
|
|
@@ -449,7 +476,7 @@ export const refreshFlagDefinitions = action({
|
|
|
449
476
|
}
|
|
450
477
|
|
|
451
478
|
console.warn(
|
|
452
|
-
`[PostHog] Unexpected status ${response.status} fetching flag definitions from ${url.replace(
|
|
479
|
+
`[PostHog] Unexpected status ${response.status} fetching flag definitions from ${url.replace(projectToken, '<token>')}. ` +
|
|
453
480
|
`Response body: ${bodyText}`
|
|
454
481
|
)
|
|
455
482
|
return { status: 'error' as const, reason: 'unexpected-status' as const }
|
package/src/component/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const version = '
|
|
1
|
+
export const version = '2.0.0'
|