@hogsend/plugin-posthog 0.18.0 → 0.20.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hogsend/plugin-posthog",
3
- "version": "0.18.0",
3
+ "version": "0.20.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -23,7 +23,7 @@
23
23
  },
24
24
  "dependencies": {
25
25
  "posthog-node": "^5.35.1",
26
- "@hogsend/core": "^0.18.0"
26
+ "@hogsend/core": "^0.20.0"
27
27
  },
28
28
  "peerDependencies": {
29
29
  "ioredis": ">=5.0.0"
package/src/index.ts CHANGED
@@ -1,11 +1,15 @@
1
1
  export { captureEvent } from "./capture.js";
2
- export { createPostHogClient } from "./client.js";
3
- export { getPersonProperties } from "./properties.js";
2
+ export { createPostHogClient, DEFAULT_HOST } from "./client.js";
3
+ export { derivePrivateHost, getPersonProperties } from "./properties.js";
4
+ export { createPostHogProvider } from "./provider.js";
4
5
  export { createPostHogService } from "./service.js";
5
6
  export type {
7
+ AnalyticsProvider,
6
8
  CaptureOptions,
7
9
  PersonPropertiesCache,
8
10
  PersonPropertiesConfig,
11
+ PersonPropertiesWrite,
12
+ PostHogAuthTokenAccessor,
9
13
  PostHogService,
10
14
  PostHogServiceConfig,
11
15
  } from "./types.js";
package/src/properties.ts CHANGED
@@ -4,6 +4,89 @@ const CACHE_PREFIX = "posthog:person:";
4
4
  const DEFAULT_TTL = 300;
5
5
  const FETCH_TIMEOUT_MS = 10_000;
6
6
 
7
+ /**
8
+ * Derive the private (app) API host from a capture/ingestion host by
9
+ * stripping PostHog Cloud's `.i.` ingestion label:
10
+ *
11
+ * https://eu.i.posthog.com → https://eu.posthog.com
12
+ * https://us.i.posthog.com → https://us.posthog.com
13
+ *
14
+ * Self-hosted instances serve both planes on one host, so anything that
15
+ * doesn't match the Cloud ingestion pattern passes through unchanged.
16
+ */
17
+ export function derivePrivateHost(host: string): string {
18
+ return host.replace(/^(https?:\/\/[a-z0-9-]+)\.i\.(posthog\.com)/i, "$1.$2");
19
+ }
20
+
21
+ /**
22
+ * Resolve the project id for environment-scoped private endpoints via
23
+ * `GET /api/projects/@current/` (the personal key's scoped project). Returns
24
+ * undefined on any failure — callers soft-fail.
25
+ */
26
+ async function discoverProjectId(opts: {
27
+ privateHost: string;
28
+ token: string;
29
+ }): Promise<string | undefined> {
30
+ try {
31
+ const response = await fetch(
32
+ new URL("/api/projects/@current/", opts.privateHost).toString(),
33
+ {
34
+ headers: {
35
+ Authorization: `Bearer ${opts.token}`,
36
+ Accept: "application/json",
37
+ },
38
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
39
+ },
40
+ );
41
+ if (!response.ok) return undefined;
42
+ const data = (await response.json()) as { id?: number | string };
43
+ return data.id !== undefined ? String(data.id) : undefined;
44
+ } catch {
45
+ return undefined;
46
+ }
47
+ }
48
+
49
+ /** Per-(host,credential) one-shot project-id discovery, shared across calls. */
50
+ const projectIdCache = new Map<string, Promise<string | undefined>>();
51
+
52
+ function resolveProjectId(opts: {
53
+ privateHost: string;
54
+ token: string;
55
+ /**
56
+ * Cache identity, NOT the bearer token: OAuth access tokens rotate every
57
+ * ~10h, so keying on `token` would re-discover after every refresh. Callers
58
+ * pass the (stable) personal key or a fixed "oauth" marker.
59
+ */
60
+ cacheKey: string;
61
+ projectId?: string;
62
+ }): Promise<string | undefined> {
63
+ if (opts.projectId) return Promise.resolve(opts.projectId);
64
+ const cacheKey = `${opts.privateHost}::${opts.cacheKey}`;
65
+ let pending = projectIdCache.get(cacheKey);
66
+ if (!pending) {
67
+ pending = discoverProjectId(opts).then((id) => {
68
+ // Don't cache a failed discovery — let the next call retry.
69
+ if (id === undefined) projectIdCache.delete(cacheKey);
70
+ return id;
71
+ });
72
+ projectIdCache.set(cacheKey, pending);
73
+ }
74
+ return pending;
75
+ }
76
+
77
+ /**
78
+ * Person-property READ via PostHog's private API.
79
+ *
80
+ * Requires a privileged credential (scope `person:read`) — the `phc_` project
81
+ * key is write-only by design (it ships in browser bundles) and can never
82
+ * read. An OAuth token (via `getAuthToken`) is preferred; `personalApiKey` is
83
+ * the fallback. With NEITHER configured this resolves `{}` immediately (reads
84
+ * disabled); the engine's timezone fallbacks (contact properties → client
85
+ * default) take over. All upstream errors also soft-fail to `{}`.
86
+ *
87
+ * The private API lives on the APP host (`eu.posthog.com`), not the
88
+ * ingestion host (`eu.i.posthog.com`) — derived via {@link derivePrivateHost}.
89
+ */
7
90
  export async function getPersonProperties(opts: {
8
91
  config: PersonPropertiesConfig;
9
92
  distinctId: string;
@@ -11,6 +94,8 @@ export async function getPersonProperties(opts: {
11
94
  }): Promise<Record<string, unknown>> {
12
95
  const { config, distinctId, cache } = opts;
13
96
 
97
+ if (!config.personalApiKey && !config.getAuthToken) return {};
98
+
14
99
  if (cache) {
15
100
  try {
16
101
  const cached = await cache.redis.get(`${CACHE_PREFIX}${distinctId}`);
@@ -22,15 +107,34 @@ export async function getPersonProperties(opts: {
22
107
  }
23
108
  }
24
109
 
25
- const url = new URL("/api/persons/", config.host);
26
- url.searchParams.set("distinct_id", distinctId);
110
+ const privateHost = config.privateHost ?? derivePrivateHost(config.host);
27
111
 
28
112
  let properties: Record<string, unknown> = {};
29
113
 
30
114
  try {
115
+ // OAuth preferred, personal key fallback — a revoked/failed OAuth
116
+ // credential degrades to the personal key for free.
117
+ const oauthToken = config.getAuthToken ? await config.getAuthToken() : null;
118
+ const token = oauthToken ?? config.personalApiKey;
119
+ if (!token) return {};
120
+
121
+ const projectId = await resolveProjectId({
122
+ privateHost,
123
+ token,
124
+ cacheKey: config.personalApiKey ?? "oauth",
125
+ projectId: config.projectId,
126
+ });
127
+ if (!projectId) return {};
128
+
129
+ const url = new URL(
130
+ `/api/environments/${encodeURIComponent(projectId)}/persons/`,
131
+ privateHost,
132
+ );
133
+ url.searchParams.set("distinct_id", distinctId);
134
+
31
135
  const response = await fetch(url.toString(), {
32
136
  headers: {
33
- Authorization: `Bearer ${config.apiKey}`,
137
+ Authorization: `Bearer ${token}`,
34
138
  Accept: "application/json",
35
139
  },
36
140
  signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
@@ -0,0 +1,95 @@
1
+ import { type AnalyticsProvider, defineAnalyticsProvider } from "@hogsend/core";
2
+ import { captureEvent } from "./capture.js";
3
+ import { createPostHogClient, DEFAULT_HOST } from "./client.js";
4
+ import { getPersonProperties } from "./properties.js";
5
+ import type {
6
+ PersonPropertiesCache,
7
+ PersonPropertiesConfig,
8
+ PostHogServiceConfig,
9
+ } from "./types.js";
10
+
11
+ /**
12
+ * The PostHog implementation of the neutral `AnalyticsProvider` contract —
13
+ * the reference implementation, the way `createResendProvider` is for email.
14
+ *
15
+ * Credential split (PostHog's design, not Hogsend's):
16
+ * - **capture + person WRITES** use the public project key (`apiKey`) — person
17
+ * writes ride the capture pipeline as `$set`/`$set_once`, so propagation
18
+ * needs NO extra credential.
19
+ * - **person READS** need a privileged credential against the private API
20
+ * host: an engine-injected OAuth accessor (`authToken`, preferred) or
21
+ * `personalApiKey` (a personal API key scoped `person:read`, the fallback).
22
+ * With neither, `capabilities.personReads` is false and reads soft-fail to
23
+ * `{}` — the engine falls back to contact properties for timezone
24
+ * resolution.
25
+ */
26
+ export function createPostHogProvider(
27
+ config: PostHogServiceConfig,
28
+ ): AnalyticsProvider {
29
+ const host = config.host ?? DEFAULT_HOST;
30
+ const client = createPostHogClient({ apiKey: config.apiKey, host });
31
+ const authToken = config.authToken;
32
+
33
+ const propsConfig: PersonPropertiesConfig = {
34
+ personalApiKey: config.personalApiKey,
35
+ getAuthToken: authToken ? () => authToken.getToken() : undefined,
36
+ host,
37
+ privateHost: config.privateHost,
38
+ projectId: config.projectId,
39
+ };
40
+
41
+ const propsCache: PersonPropertiesCache | undefined = config.redis
42
+ ? { redis: config.redis, ttlSeconds: config.cacheTtlSeconds ?? 300 }
43
+ : undefined;
44
+
45
+ return defineAnalyticsProvider({
46
+ meta: {
47
+ id: "posthog",
48
+ name: "PostHog",
49
+ description:
50
+ "PostHog capture + person reads/writes (reads need a personal API key).",
51
+ },
52
+ capabilities: {
53
+ // LIVE getter: the container builds providers at BOOT, but the OAuth
54
+ // credential can be stored at RUNTIME via `hogsend connect posthog`.
55
+ // A getter means every reader (boot nudge, doctor, future Studio
56
+ // status) sees current truth without rebuilding the provider.
57
+ get personReads() {
58
+ return (
59
+ Boolean(config.personalApiKey) || (authToken?.isAvailable() ?? false)
60
+ );
61
+ },
62
+ personWrites: true,
63
+ oauth: true,
64
+ },
65
+
66
+ async getPersonProperties(distinctId: string) {
67
+ return getPersonProperties({
68
+ config: propsConfig,
69
+ distinctId,
70
+ cache: propsCache,
71
+ });
72
+ },
73
+
74
+ async setPersonProperties({ distinctId, set, setOnce, unset }) {
75
+ if (!set && !setOnce && !unset?.length) return;
76
+ client.capture({
77
+ distinctId,
78
+ event: "$set",
79
+ properties: {
80
+ ...(set ? { $set: set } : {}),
81
+ ...(setOnce ? { $set_once: setOnce } : {}),
82
+ ...(unset?.length ? { $unset: unset } : {}),
83
+ },
84
+ });
85
+ },
86
+
87
+ capture(opts) {
88
+ captureEvent({ client, ...opts });
89
+ },
90
+
91
+ async shutdown() {
92
+ await client.shutdown();
93
+ },
94
+ });
95
+ }
package/src/service.ts CHANGED
@@ -9,6 +9,13 @@ import type {
9
9
  PostHogServiceConfig,
10
10
  } from "./types.js";
11
11
 
12
+ /**
13
+ * @deprecated Prefer {@link createPostHogProvider} (the neutral
14
+ * `AnalyticsProvider` contract). This PostHog-shaped service predates it and
15
+ * is kept so existing `createHogsendClient({ analytics })` call sites and
16
+ * `getPostHog()` consumers keep compiling; person reads honour the same
17
+ * `personalApiKey` config as the provider.
18
+ */
12
19
  export function createPostHogService(
13
20
  config: PostHogServiceConfig,
14
21
  ): PostHogService {
@@ -16,8 +23,10 @@ export function createPostHogService(
16
23
  const client = createPostHogClient({ apiKey: config.apiKey, host });
17
24
 
18
25
  const propsConfig: PersonPropertiesConfig = {
19
- apiKey: config.apiKey,
26
+ personalApiKey: config.personalApiKey,
20
27
  host,
28
+ privateHost: config.privateHost,
29
+ projectId: config.projectId,
21
30
  };
22
31
 
23
32
  const propsCache: PersonPropertiesCache | undefined = config.redis
package/src/types.ts CHANGED
@@ -1,20 +1,70 @@
1
1
  import type { Redis } from "ioredis";
2
2
 
3
+ /**
4
+ * Engine-injected OAuth auth accessor. `getToken()` resolves a live bearer
5
+ * token (or null — degrade); `isAvailable()` is the synchronous best-known
6
+ * credential state, used for live capability reporting. The plugin stays a
7
+ * dumb wire: it neither refreshes nor stores anything.
8
+ */
9
+ export interface PostHogAuthTokenAccessor {
10
+ getToken(): Promise<string | null>;
11
+ isAvailable(): boolean;
12
+ }
13
+
3
14
  export interface PostHogServiceConfig {
15
+ /** Project API key (`phc_…`) — capture/flags. Public + WRITE-ONLY by design. */
4
16
  apiKey: string;
17
+ /** Capture/ingestion host, e.g. `https://eu.i.posthog.com`. */
5
18
  host?: string;
19
+ /**
20
+ * Personal API key (scoped `person:read`; add `person:write` for future
21
+ * private-API writes). Person READS are disabled without it: the `phc_`
22
+ * project key cannot read the private API — it ships in every browser
23
+ * bundle, so PostHog makes it write-only (otherwise anyone could dump your
24
+ * persons). See the "Analytics access" docs page.
25
+ */
26
+ personalApiKey?: string;
27
+ /**
28
+ * Private (app) API host. Defaults to the capture host with the `.i.`
29
+ * ingestion label stripped (`eu.i.posthog.com` → `eu.posthog.com`).
30
+ * Self-hosted instances usually serve both on one host — set explicitly
31
+ * if yours differs.
32
+ */
33
+ privateHost?: string;
34
+ /**
35
+ * PostHog project id for the environment-scoped private endpoints. When
36
+ * absent it is discovered once via `GET /api/projects/@current/` with the
37
+ * personal key, then cached for the process lifetime.
38
+ */
39
+ projectId?: string;
6
40
  redis?: Redis;
7
41
  cacheTtlSeconds?: number;
42
+ /** OAuth accessor — preferred over `personalApiKey` when it yields a token. */
43
+ authToken?: PostHogAuthTokenAccessor;
8
44
  }
9
45
 
10
46
  // The analytics-provider contract now lives in the neutral @hogsend/core
11
47
  // package. These re-exports keep every existing
12
48
  // `import ... from "@hogsend/plugin-posthog"` working unchanged.
13
- export type { CaptureOptions, PostHogService } from "@hogsend/core";
49
+ export type {
50
+ AnalyticsProvider,
51
+ CaptureOptions,
52
+ PersonPropertiesWrite,
53
+ PostHogService,
54
+ } from "@hogsend/core";
14
55
 
15
56
  export interface PersonPropertiesConfig {
16
- apiKey: string;
57
+ /** Personal API key — fallback when OAuth yields no token. Reads are
58
+ * DISABLED (soft-fail `{}`) when BOTH this and `getAuthToken` are absent. */
59
+ personalApiKey?: string;
60
+ /** Async OAuth token resolver (the accessor's getToken, pre-bound). */
61
+ getAuthToken?: () => Promise<string | null>;
62
+ /** Capture/ingestion host (private host is derived from it). */
17
63
  host: string;
64
+ /** Private API host override. */
65
+ privateHost?: string;
66
+ /** Project id override (skips `@current` discovery). */
67
+ projectId?: string;
18
68
  }
19
69
 
20
70
  export interface PersonPropertiesCache {