@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.
Files changed (41) hide show
  1. package/README.md +88 -36
  2. package/dist/client/index.d.ts +17 -22
  3. package/dist/client/index.d.ts.map +1 -1
  4. package/dist/client/index.js +26 -41
  5. package/dist/client/index.js.map +1 -1
  6. package/dist/component/_generated/api.d.ts +4 -0
  7. package/dist/component/_generated/api.d.ts.map +1 -1
  8. package/dist/component/_generated/api.js.map +1 -1
  9. package/dist/component/_generated/component.d.ts +1 -21
  10. package/dist/component/_generated/component.d.ts.map +1 -1
  11. package/dist/component/_generated/server.d.ts +11 -0
  12. package/dist/component/_generated/server.d.ts.map +1 -1
  13. package/dist/component/_generated/server.js +1 -0
  14. package/dist/component/_generated/server.js.map +1 -1
  15. package/dist/component/convex.config.d.ts +18 -1
  16. package/dist/component/convex.config.d.ts.map +1 -1
  17. package/dist/component/convex.config.js +21 -1
  18. package/dist/component/convex.config.js.map +1 -1
  19. package/dist/component/crons.d.ts +12 -0
  20. package/dist/component/crons.d.ts.map +1 -0
  21. package/dist/component/crons.js +42 -0
  22. package/dist/component/crons.js.map +1 -0
  23. package/dist/component/lib.d.ts +18 -32
  24. package/dist/component/lib.d.ts.map +1 -1
  25. package/dist/component/lib.js +90 -60
  26. package/dist/component/lib.js.map +1 -1
  27. package/dist/component/version.d.ts +1 -1
  28. package/dist/component/version.d.ts.map +1 -1
  29. package/dist/component/version.js +1 -1
  30. package/dist/component/version.js.map +1 -1
  31. package/package.json +5 -4
  32. package/src/client/index.test.ts +85 -63
  33. package/src/client/index.ts +35 -60
  34. package/src/component/_generated/api.ts +4 -0
  35. package/src/component/_generated/component.ts +3 -27
  36. package/src/component/_generated/server.ts +11 -0
  37. package/src/component/convex.config.ts +21 -1
  38. package/src/component/crons.test.ts +62 -0
  39. package/src/component/crons.ts +52 -0
  40. package/src/component/lib.ts +86 -59
  41. package/src/component/version.ts +1 -1
@@ -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 `apiKey|host` to support the rare case of multiple credentials sharing one isolate.
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(apiKey: string, host: string): PostHog {
33
- const key = `${apiKey}|${host}`
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(apiKey, { host, flushAt: 1, flushInterval: 0 })
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 client = getClient(args.apiKey, args.host)
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 client = getClient(args.apiKey, args.host)
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 client = getClient(args.apiKey, args.host)
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 client = getClient(args.apiKey, args.host)
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 client = getClient(args.apiKey, args.host)
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 client = getClient(args.apiKey, args.host)
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 client = getClient(args.apiKey, args.host)
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 client = getClient(args.apiKey, args.host)
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
- // 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.
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 latest cached flag definitions, or `null` if none have been fetched yet.
299
+ * Returns the cached flag definitions plus whether local evaluation is configured at all.
271
300
  *
272
- * The `data` field is a JSON-stringified `FlagDefinitions` object (see `client/feature-flags/types.ts`).
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) return null
279
- return { data: row.data, fetchedAt: row.fetchedAt, etag: row.etag }
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 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`
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
- 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'
353
+ args: {},
354
+ handler: async (ctx) => {
355
+ const { projectToken, host, personalApiKey } = readConfig()
330
356
 
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.
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=${projectApiKey}&send_cohorts`
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(projectApiKey, '<token>')}. ` +
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 }
@@ -1 +1 @@
1
- export const version = '1.0.10'
1
+ export const version = '2.0.0'