@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 +2 -2
- package/src/index.ts +6 -2
- package/src/properties.ts +107 -3
- package/src/provider.ts +95 -0
- package/src/service.ts +10 -1
- package/src/types.ts +52 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hogsend/plugin-posthog",
|
|
3
|
-
"version": "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.
|
|
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
|
|
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 ${
|
|
137
|
+
Authorization: `Bearer ${token}`,
|
|
34
138
|
Accept: "application/json",
|
|
35
139
|
},
|
|
36
140
|
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
package/src/provider.ts
ADDED
|
@@ -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
|
-
|
|
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 {
|
|
49
|
+
export type {
|
|
50
|
+
AnalyticsProvider,
|
|
51
|
+
CaptureOptions,
|
|
52
|
+
PersonPropertiesWrite,
|
|
53
|
+
PostHogService,
|
|
54
|
+
} from "@hogsend/core";
|
|
14
55
|
|
|
15
56
|
export interface PersonPropertiesConfig {
|
|
16
|
-
|
|
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 {
|