@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 +2 -2
- package/src/index.ts +4 -1
- package/src/properties.ts +93 -3
- package/src/provider.ts +82 -0
- package/src/service.ts +10 -1
- package/src/types.ts +36 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hogsend/plugin-posthog",
|
|
3
|
-
"version": "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.
|
|
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
|
|
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.
|
|
123
|
+
Authorization: `Bearer ${config.personalApiKey}`,
|
|
34
124
|
Accept: "application/json",
|
|
35
125
|
},
|
|
36
126
|
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
package/src/provider.ts
ADDED
|
@@ -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
|
-
|
|
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 {
|
|
36
|
+
export type {
|
|
37
|
+
AnalyticsProvider,
|
|
38
|
+
CaptureOptions,
|
|
39
|
+
PersonPropertiesWrite,
|
|
40
|
+
PostHogService,
|
|
41
|
+
} from "@hogsend/core";
|
|
14
42
|
|
|
15
43
|
export interface PersonPropertiesConfig {
|
|
16
|
-
|
|
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 {
|