@hogsend/plugin-posthog 0.18.0 → 0.19.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.19.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.19.0"
27
27
  },
28
28
  "peerDependencies": {
29
29
  "ioredis": ">=5.0.0"
package/src/index.ts CHANGED
@@ -1,11 +1,14 @@
1
1
  export { captureEvent } from "./capture.js";
2
2
  export { createPostHogClient } from "./client.js";
3
- export { getPersonProperties } from "./properties.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,
9
12
  PostHogService,
10
13
  PostHogServiceConfig,
11
14
  } from "./types.js";
package/src/properties.ts CHANGED
@@ -4,6 +4,82 @@ 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
+ personalApiKey: 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.personalApiKey}`,
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,key) 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
+ personalApiKey: string;
55
+ projectId?: string;
56
+ }): Promise<string | undefined> {
57
+ if (opts.projectId) return Promise.resolve(opts.projectId);
58
+ const cacheKey = `${opts.privateHost}::${opts.personalApiKey}`;
59
+ let pending = projectIdCache.get(cacheKey);
60
+ if (!pending) {
61
+ pending = discoverProjectId(opts).then((id) => {
62
+ // Don't cache a failed discovery — let the next call retry.
63
+ if (id === undefined) projectIdCache.delete(cacheKey);
64
+ return id;
65
+ });
66
+ projectIdCache.set(cacheKey, pending);
67
+ }
68
+ return pending;
69
+ }
70
+
71
+ /**
72
+ * Person-property READ via PostHog's private API.
73
+ *
74
+ * Requires a PERSONAL API key (scope `person:read`) — the `phc_` project key
75
+ * is write-only by design (it ships in browser bundles) and can never read.
76
+ * Without `personalApiKey` this resolves `{}` immediately (reads disabled);
77
+ * the engine's timezone fallbacks (contact properties → client default) take
78
+ * over. All upstream errors also soft-fail to `{}`.
79
+ *
80
+ * The private API lives on the APP host (`eu.posthog.com`), not the
81
+ * ingestion host (`eu.i.posthog.com`) — derived via {@link derivePrivateHost}.
82
+ */
7
83
  export async function getPersonProperties(opts: {
8
84
  config: PersonPropertiesConfig;
9
85
  distinctId: string;
@@ -11,6 +87,8 @@ export async function getPersonProperties(opts: {
11
87
  }): Promise<Record<string, unknown>> {
12
88
  const { config, distinctId, cache } = opts;
13
89
 
90
+ if (!config.personalApiKey) return {};
91
+
14
92
  if (cache) {
15
93
  try {
16
94
  const cached = await cache.redis.get(`${CACHE_PREFIX}${distinctId}`);
@@ -22,15 +100,27 @@ export async function getPersonProperties(opts: {
22
100
  }
23
101
  }
24
102
 
25
- const url = new URL("/api/persons/", config.host);
26
- url.searchParams.set("distinct_id", distinctId);
103
+ const privateHost = config.privateHost ?? derivePrivateHost(config.host);
27
104
 
28
105
  let properties: Record<string, unknown> = {};
29
106
 
30
107
  try {
108
+ const projectId = await resolveProjectId({
109
+ privateHost,
110
+ personalApiKey: config.personalApiKey,
111
+ projectId: config.projectId,
112
+ });
113
+ if (!projectId) return {};
114
+
115
+ const url = new URL(
116
+ `/api/environments/${encodeURIComponent(projectId)}/persons/`,
117
+ privateHost,
118
+ );
119
+ url.searchParams.set("distinct_id", distinctId);
120
+
31
121
  const response = await fetch(url.toString(), {
32
122
  headers: {
33
- Authorization: `Bearer ${config.apiKey}`,
123
+ Authorization: `Bearer ${config.personalApiKey}`,
34
124
  Accept: "application/json",
35
125
  },
36
126
  signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
@@ -0,0 +1,82 @@
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 `personalApiKey` (a personal API key scoped
20
+ * `person:read`) against the private API host. Without it,
21
+ * `capabilities.personReads` is false and reads soft-fail to `{}` — the
22
+ * engine falls back to contact properties for timezone resolution.
23
+ */
24
+ export function createPostHogProvider(
25
+ config: PostHogServiceConfig,
26
+ ): AnalyticsProvider {
27
+ const host = config.host ?? DEFAULT_HOST;
28
+ const client = createPostHogClient({ apiKey: config.apiKey, host });
29
+
30
+ const propsConfig: PersonPropertiesConfig = {
31
+ personalApiKey: config.personalApiKey,
32
+ host,
33
+ privateHost: config.privateHost,
34
+ projectId: config.projectId,
35
+ };
36
+
37
+ const propsCache: PersonPropertiesCache | undefined = config.redis
38
+ ? { redis: config.redis, ttlSeconds: config.cacheTtlSeconds ?? 300 }
39
+ : undefined;
40
+
41
+ return defineAnalyticsProvider({
42
+ meta: {
43
+ id: "posthog",
44
+ name: "PostHog",
45
+ description:
46
+ "PostHog capture + person reads/writes (reads need a personal API key).",
47
+ },
48
+ capabilities: {
49
+ personReads: Boolean(config.personalApiKey),
50
+ personWrites: true,
51
+ },
52
+
53
+ async getPersonProperties(distinctId: string) {
54
+ return getPersonProperties({
55
+ config: propsConfig,
56
+ distinctId,
57
+ cache: propsCache,
58
+ });
59
+ },
60
+
61
+ async setPersonProperties({ distinctId, set, setOnce, unset }) {
62
+ if (!set && !setOnce && !unset?.length) return;
63
+ client.capture({
64
+ distinctId,
65
+ event: "$set",
66
+ properties: {
67
+ ...(set ? { $set: set } : {}),
68
+ ...(setOnce ? { $set_once: setOnce } : {}),
69
+ ...(unset?.length ? { $unset: unset } : {}),
70
+ },
71
+ });
72
+ },
73
+
74
+ capture(opts) {
75
+ captureEvent({ client, ...opts });
76
+ },
77
+
78
+ async shutdown() {
79
+ await client.shutdown();
80
+ },
81
+ });
82
+ }
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,8 +1,31 @@
1
1
  import type { Redis } from "ioredis";
2
2
 
3
3
  export interface PostHogServiceConfig {
4
+ /** Project API key (`phc_…`) — capture/flags. Public + WRITE-ONLY by design. */
4
5
  apiKey: string;
6
+ /** Capture/ingestion host, e.g. `https://eu.i.posthog.com`. */
5
7
  host?: string;
8
+ /**
9
+ * Personal API key (scoped `person:read`; add `person:write` for future
10
+ * private-API writes). Person READS are disabled without it: the `phc_`
11
+ * project key cannot read the private API — it ships in every browser
12
+ * bundle, so PostHog makes it write-only (otherwise anyone could dump your
13
+ * persons). See the "Analytics access" docs page.
14
+ */
15
+ personalApiKey?: string;
16
+ /**
17
+ * Private (app) API host. Defaults to the capture host with the `.i.`
18
+ * ingestion label stripped (`eu.i.posthog.com` → `eu.posthog.com`).
19
+ * Self-hosted instances usually serve both on one host — set explicitly
20
+ * if yours differs.
21
+ */
22
+ privateHost?: string;
23
+ /**
24
+ * PostHog project id for the environment-scoped private endpoints. When
25
+ * absent it is discovered once via `GET /api/projects/@current/` with the
26
+ * personal key, then cached for the process lifetime.
27
+ */
28
+ projectId?: string;
6
29
  redis?: Redis;
7
30
  cacheTtlSeconds?: number;
8
31
  }
@@ -10,11 +33,22 @@ export interface PostHogServiceConfig {
10
33
  // The analytics-provider contract now lives in the neutral @hogsend/core
11
34
  // package. These re-exports keep every existing
12
35
  // `import ... from "@hogsend/plugin-posthog"` working unchanged.
13
- export type { CaptureOptions, PostHogService } from "@hogsend/core";
36
+ export type {
37
+ AnalyticsProvider,
38
+ CaptureOptions,
39
+ PersonPropertiesWrite,
40
+ PostHogService,
41
+ } from "@hogsend/core";
14
42
 
15
43
  export interface PersonPropertiesConfig {
16
- apiKey: string;
44
+ /** Personal API key — reads are DISABLED (soft-fail `{}`) without it. */
45
+ personalApiKey?: string;
46
+ /** Capture/ingestion host (private host is derived from it). */
17
47
  host: string;
48
+ /** Private API host override. */
49
+ privateHost?: string;
50
+ /** Project id override (skips `@current` discovery). */
51
+ projectId?: string;
18
52
  }
19
53
 
20
54
  export interface PersonPropertiesCache {