@trakoo/posthog 0.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 trakoo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,63 @@
1
+ # @trakoo/posthog
2
+
3
+ PostHog providers for [trakoo](https://www.npmjs.com/package/trakoo), the typed, provider-agnostic analytics library.
4
+
5
+ ## Installation
6
+
7
+ Install the package with only the PostHog SDK for the side you use:
8
+
9
+ ```bash
10
+ # Browser
11
+ pnpm add trakoo @trakoo/posthog posthog-js
12
+
13
+ # Server
14
+ pnpm add trakoo @trakoo/posthog posthog-node
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ `appEvents` is your event registry, created with `defineEvents()` from `trakoo`.
20
+
21
+ ```typescript title="lib/analytics.ts"
22
+ import { createClientAnalytics } from 'trakoo/client';
23
+ import { PostHogClientProvider } from '@trakoo/posthog/client';
24
+ import { appEvents } from './events';
25
+
26
+ export const analytics = createClientAnalytics({
27
+ events: appEvents,
28
+ providers: [
29
+ new PostHogClientProvider({
30
+ token: import.meta.env.VITE_POSTHOG_KEY,
31
+ api_host: import.meta.env.VITE_POSTHOG_HOST
32
+ })
33
+ ]
34
+ });
35
+ ```
36
+
37
+ ```typescript title="lib/server-analytics.ts"
38
+ import { createServerAnalytics } from 'trakoo/server';
39
+ import { PostHogServerProvider } from '@trakoo/posthog/server';
40
+ import { appEvents } from './events';
41
+
42
+ export function createRequestAnalytics() {
43
+ return createServerAnalytics({
44
+ events: appEvents,
45
+ providers: [
46
+ new PostHogServerProvider({
47
+ apiKey: process.env.POSTHOG_API_KEY!,
48
+ host: 'https://us.i.posthog.com'
49
+ })
50
+ ]
51
+ });
52
+ }
53
+ ```
54
+
55
+ PostHog's Node client queues events. Call `shutdown()` on a request-owned instance before the request ends.
56
+
57
+ Server events take their distinct ID from each call. An event without a user is sent anonymously, without a person profile. Pass the visitor's IP and user agent as `context.server` so PostHog can locate the visitor and see their browser. The provider sends them as `$ip` and `$raw_user_agent`, and turns GeoIP on for events that carry an IP unless you set `disableGeoip`.
58
+
59
+ In the browser, PostHog captures page views on its own by default. If you call trakoo's `pageView()`, set `capture_pageview: false` so views aren't counted twice.
60
+
61
+ ## Documentation
62
+
63
+ https://trakoo.co/docs/providers/posthog
@@ -0,0 +1,25 @@
1
+ import { BaseAnalyticsProvider, type BaseEvent, type EventContext } from "trakoo";
2
+ import type { PostHogConfig } from "posthog-js";
3
+ export type PostHogClientConfig = Partial<PostHogConfig> & {
4
+ token: string;
5
+ instanceName?: string;
6
+ debug?: boolean;
7
+ enabled?: boolean;
8
+ };
9
+ export declare class PostHogClientProvider extends BaseAnalyticsProvider {
10
+ name: string;
11
+ private posthog?;
12
+ private initialized;
13
+ private initializePromise?;
14
+ private readonly config;
15
+ private readonly instanceName;
16
+ constructor(config: PostHogClientConfig);
17
+ initialize(): Promise<void>;
18
+ private initializePostHog;
19
+ identify(userId: string, traits?: Record<string, unknown>): void;
20
+ track(event: BaseEvent, context?: EventContext): void;
21
+ pageView(properties?: Record<string, unknown>, context?: EventContext): void;
22
+ pageLeave(properties?: Record<string, unknown>, context?: EventContext): void;
23
+ reset(): void;
24
+ }
25
+ export type { PostHogConfig } from "posthog-js";
package/dist/client.js ADDED
@@ -0,0 +1,113 @@
1
+ import { BaseAnalyticsProvider, } from "trakoo";
2
+ import { isBrowser } from "./environment.js";
3
+ let posthogProviderSequence = 0;
4
+ const nextInstanceName = () => `trakoo_${++posthogProviderSequence}`;
5
+ export class PostHogClientProvider extends BaseAnalyticsProvider {
6
+ name = "PostHog-Client";
7
+ posthog;
8
+ initialized = false;
9
+ initializePromise;
10
+ config;
11
+ instanceName;
12
+ constructor(config) {
13
+ super({ debug: config.debug, enabled: config.enabled });
14
+ this.config = config;
15
+ this.instanceName = config.instanceName ?? nextInstanceName();
16
+ }
17
+ initialize() {
18
+ if (!this.isEnabled() || this.initialized)
19
+ return Promise.resolve();
20
+ if (this.initializePromise)
21
+ return this.initializePromise;
22
+ // Check if we're in a browser environment
23
+ if (!isBrowser()) {
24
+ this.log("Skipping initialization - not in browser environment");
25
+ return Promise.resolve();
26
+ }
27
+ this.initializePromise = this.initializePostHog().catch((error) => {
28
+ this.initializePromise = undefined;
29
+ console.error(`[PostHog-Client] Failed to initialize (${this.getErrorClass(error)})`);
30
+ throw error;
31
+ });
32
+ return this.initializePromise;
33
+ }
34
+ async initializePostHog() {
35
+ // Validate config has required fields
36
+ if (!this.config.token || typeof this.config.token !== "string") {
37
+ throw new Error("PostHog requires a token");
38
+ }
39
+ // Dynamically import PostHog to avoid SSR issues
40
+ const { default: posthog } = await import("posthog-js");
41
+ const { token, instanceName: _instanceName, enabled: _enabled, debug: configDebug, ...posthogConfig } = this.config;
42
+ this.posthog = posthog.init(token, {
43
+ ...posthogConfig,
44
+ debug: configDebug ?? this.debug,
45
+ }, this.instanceName);
46
+ this.initialized = true;
47
+ this.log("Initialized successfully");
48
+ }
49
+ identify(userId, traits) {
50
+ if (!this.isEnabled() || !this.initialized || !this.posthog)
51
+ return;
52
+ this.posthog.identify(userId, traits);
53
+ this.log("Identified user");
54
+ }
55
+ track(event, context) {
56
+ if (!this.isEnabled() || !this.initialized || !this.posthog)
57
+ return;
58
+ // `$current_url` is left to posthog-js, which reads the live location on
59
+ // every capture. The page context is only refreshed by pageView, and a
60
+ // caller's property would override the SDK's full URL.
61
+ //
62
+ // The time stays a property: posthog-js sends any capture that has an
63
+ // options argument, such as `timestamp`, outside its batch queue.
64
+ const properties = {
65
+ ...event.properties,
66
+ category: event.category,
67
+ timestamp: event.timestamp || Date.now(),
68
+ ...(event.userId && { userId: event.userId }),
69
+ ...(event.sessionId && { sessionId: event.sessionId }),
70
+ ...(context?.device && { device: context.device }),
71
+ ...(context?.utm && { utm: context.utm }),
72
+ // Include user email and traits as regular event properties
73
+ ...(context?.user?.email && { user_email: context.user.email }),
74
+ ...(context?.user?.traits && { user_traits: context.user.traits }),
75
+ };
76
+ this.posthog.capture(event.action, properties);
77
+ this.log("Tracked event");
78
+ }
79
+ pageView(properties, context) {
80
+ if (!this.isEnabled() || !this.initialized || !this.posthog || !isBrowser())
81
+ return;
82
+ const pageProperties = {
83
+ ...properties,
84
+ ...(context?.page && {
85
+ path: context.page.path,
86
+ title: context.page.title,
87
+ referrer: context.page.referrer,
88
+ }),
89
+ };
90
+ this.posthog.capture("$pageview", pageProperties);
91
+ this.log("Tracked page view");
92
+ }
93
+ pageLeave(properties, context) {
94
+ if (!this.isEnabled() || !this.initialized || !this.posthog || !isBrowser())
95
+ return;
96
+ const pageLeaveProperties = {
97
+ ...properties,
98
+ ...(context?.page && {
99
+ path: context.page.path,
100
+ title: context.page.title,
101
+ referrer: context.page.referrer,
102
+ }),
103
+ };
104
+ this.posthog.capture("$pageleave", pageLeaveProperties);
105
+ this.log("Tracked page leave");
106
+ }
107
+ reset() {
108
+ if (!this.isEnabled() || !this.initialized || !this.posthog || !isBrowser())
109
+ return;
110
+ this.posthog.reset();
111
+ this.log("Reset user session");
112
+ }
113
+ }
@@ -0,0 +1 @@
1
+ export declare function isBrowser(): boolean;
@@ -0,0 +1,3 @@
1
+ export function isBrowser() {
2
+ return (typeof window !== "undefined" && typeof window.document !== "undefined");
3
+ }
@@ -0,0 +1,29 @@
1
+ import { BaseAnalyticsProvider, type BaseEvent, type EventContext } from "trakoo";
2
+ import type { PostHogOptions } from "posthog-node";
3
+ export declare class PostHogServerProvider extends BaseAnalyticsProvider {
4
+ name: string;
5
+ private client?;
6
+ private initialized;
7
+ private initializePromise?;
8
+ private config;
9
+ constructor(config: {
10
+ apiKey: string;
11
+ } & PostHogOptions & {
12
+ debug?: boolean;
13
+ enabled?: boolean;
14
+ });
15
+ initialize(): Promise<void>;
16
+ private initializePostHog;
17
+ identify(userId: string, traits?: Record<string, unknown>): void;
18
+ track(event: BaseEvent, context?: EventContext): void;
19
+ pageView(properties?: Record<string, unknown>, context?: EventContext): void;
20
+ /**
21
+ * Adds what PostHog reads from its own fields and properties: the event
22
+ * time, the visitor's IP and user agent, and the page and campaign. Identity
23
+ * comes from this call alone, because one server provider serves many users.
24
+ */
25
+ private buildEventMessage;
26
+ reset(): Promise<void>;
27
+ shutdown(): Promise<void>;
28
+ }
29
+ export type { PostHogOptions } from "posthog-node";
package/dist/server.js ADDED
@@ -0,0 +1,196 @@
1
+ import { BaseAnalyticsProvider, } from "trakoo";
2
+ const firstString = (...values) => {
3
+ for (const value of values) {
4
+ if (typeof value === "string" && value)
5
+ return value;
6
+ }
7
+ return undefined;
8
+ };
9
+ /**
10
+ * The `device` context without its IP. The address travels as `$ip`, where
11
+ * PostHog's project setting to discard client IP data applies; a copy nested
12
+ * in `device` would escape that setting.
13
+ */
14
+ const deviceWithoutIp = (device) => {
15
+ if (!device)
16
+ return undefined;
17
+ const { ip: _ip, ...rest } = device;
18
+ return Object.keys(rest).length > 0 ? rest : undefined;
19
+ };
20
+ /**
21
+ * The context PostHog reads from its own standard properties: the page URL,
22
+ * and the campaign fields PostHog attributes traffic by.
23
+ */
24
+ const standardProperties = (context) => {
25
+ const currentUrl = firstString(context?.page?.url, context?.page?.path);
26
+ const utm = context?.utm;
27
+ return {
28
+ ...(currentUrl && { $current_url: currentUrl }),
29
+ ...(utm?.source && { utm_source: utm.source }),
30
+ ...(utm?.medium && { utm_medium: utm.medium }),
31
+ ...(utm?.name && { utm_campaign: utm.name }),
32
+ };
33
+ };
34
+ const isMissingPackageError = (error, packageName) => {
35
+ try {
36
+ if (!error || typeof error !== "object")
37
+ return false;
38
+ const code = Reflect.get(error, "code");
39
+ const message = Reflect.get(error, "message");
40
+ if ((code !== "ERR_MODULE_NOT_FOUND" && code !== "MODULE_NOT_FOUND") ||
41
+ typeof message !== "string") {
42
+ return false;
43
+ }
44
+ return (message.includes(`Cannot find package '${packageName}'`) ||
45
+ message.includes(`Cannot find module '${packageName}'`));
46
+ }
47
+ catch {
48
+ return false;
49
+ }
50
+ };
51
+ export class PostHogServerProvider extends BaseAnalyticsProvider {
52
+ name = "PostHog-Server";
53
+ client;
54
+ initialized = false;
55
+ initializePromise;
56
+ config;
57
+ constructor(config) {
58
+ super({ debug: config.debug, enabled: config.enabled });
59
+ this.config = config;
60
+ }
61
+ initialize() {
62
+ if (!this.isEnabled() || this.initialized)
63
+ return Promise.resolve();
64
+ if (this.initializePromise)
65
+ return this.initializePromise;
66
+ this.initializePromise = this.initializePostHog().catch((error) => {
67
+ this.initializePromise = undefined;
68
+ console.error(`[PostHog-Server] Failed to initialize (${this.getErrorClass(error)})`);
69
+ throw error;
70
+ });
71
+ return this.initializePromise;
72
+ }
73
+ async initializePostHog() {
74
+ // Validate config has required fields
75
+ if (!this.config.apiKey || typeof this.config.apiKey !== "string") {
76
+ throw new Error("PostHog requires an apiKey");
77
+ }
78
+ let PostHogClient;
79
+ try {
80
+ ({ PostHog: PostHogClient } = await import("posthog-node"));
81
+ }
82
+ catch (error) {
83
+ if (isMissingPackageError(error, "posthog-node")) {
84
+ throw new Error("PostHog server provider requires the optional peer package posthog-node");
85
+ }
86
+ throw error;
87
+ }
88
+ // The host is left to the SDK, whose default is PostHog's US ingestion
89
+ // host rather than the legacy app.posthog.com.
90
+ const { apiKey, ...posthogOptions } = this.config;
91
+ this.client = new PostHogClient(apiKey, {
92
+ flushAt: 20,
93
+ flushInterval: 10000,
94
+ ...posthogOptions,
95
+ });
96
+ this.initialized = true;
97
+ this.log("Initialized successfully");
98
+ }
99
+ identify(userId, traits) {
100
+ if (!this.isEnabled() || !this.initialized || !this.client)
101
+ return;
102
+ this.client.identify({
103
+ distinctId: userId,
104
+ properties: traits,
105
+ });
106
+ this.log("Identified user");
107
+ }
108
+ track(event, context) {
109
+ if (!this.isEnabled() || !this.initialized || !this.client)
110
+ return;
111
+ const device = deviceWithoutIp(context?.device);
112
+ const properties = {
113
+ ...event.properties,
114
+ category: event.category,
115
+ ...(event.sessionId && { sessionId: event.sessionId }),
116
+ ...(context?.page && {
117
+ $page_title: context.page.title,
118
+ $referrer: context.page.referrer,
119
+ }),
120
+ ...(device && { device }),
121
+ ...(context?.utm && { utm: context.utm }),
122
+ // Include user email and traits as regular event properties
123
+ ...(context?.user?.email && { user_email: context.user.email }),
124
+ ...(context?.user?.traits && { user_traits: context.user.traits }),
125
+ };
126
+ this.client.capture(this.buildEventMessage({
127
+ event: event.action,
128
+ distinctId: event.userId || context?.user?.userId,
129
+ properties,
130
+ context,
131
+ timestamp: event.timestamp,
132
+ }));
133
+ this.log("Tracked event");
134
+ }
135
+ pageView(properties, context) {
136
+ if (!this.isEnabled() || !this.initialized || !this.client)
137
+ return;
138
+ const pageProperties = {
139
+ ...properties,
140
+ ...(context?.page && {
141
+ path: context.page.path,
142
+ title: context.page.title,
143
+ referrer: context.page.referrer,
144
+ }),
145
+ };
146
+ this.client.capture(this.buildEventMessage({
147
+ event: "$pageview",
148
+ distinctId: context?.user?.userId || context?.user?.email,
149
+ properties: pageProperties,
150
+ context,
151
+ }));
152
+ this.log("Tracked page view");
153
+ }
154
+ /**
155
+ * Adds what PostHog reads from its own fields and properties: the event
156
+ * time, the visitor's IP and user agent, and the page and campaign. Identity
157
+ * comes from this call alone, because one server provider serves many users.
158
+ */
159
+ buildEventMessage({ event, distinctId, properties, context, timestamp, }) {
160
+ const ip = firstString(context?.server?.ip, context?.device?.ip);
161
+ const userAgent = firstString(context?.server?.userAgent, context?.device?.userAgent);
162
+ return {
163
+ // An event without a user gets a distinct ID of its own and no person
164
+ // profile, as PostHog recommends. A shared placeholder ID would merge
165
+ // every anonymous visitor into one person.
166
+ distinctId: distinctId || crypto.randomUUID(),
167
+ event,
168
+ properties: {
169
+ ...properties,
170
+ ...standardProperties(context),
171
+ ...(ip && { $ip: ip }),
172
+ ...(userAgent && { $raw_user_agent: userAgent }),
173
+ ...(!distinctId && { $process_person_profile: false }),
174
+ },
175
+ ...(timestamp !== undefined && { timestamp: new Date(timestamp) }),
176
+ // posthog-node disables GeoIP by default because it would locate the
177
+ // server. A forwarded visitor IP is the one to locate, unless the app
178
+ // configured `disableGeoip` itself.
179
+ ...(ip &&
180
+ this.config.disableGeoip === undefined && { disableGeoip: false }),
181
+ };
182
+ }
183
+ async reset() {
184
+ if (!this.isEnabled() || !this.initialized || !this.client)
185
+ return;
186
+ // Flush any pending events
187
+ await this.client.flush();
188
+ this.log("Flushed pending events");
189
+ }
190
+ async shutdown() {
191
+ if (this.client) {
192
+ await this.client.shutdown();
193
+ this.log("Shutdown complete");
194
+ }
195
+ }
196
+ }
package/package.json ADDED
@@ -0,0 +1,70 @@
1
+ {
2
+ "name": "@trakoo/posthog",
3
+ "version": "0.0.0",
4
+ "description": "PostHog provider for trakoo",
5
+ "type": "module",
6
+ "exports": {
7
+ "./client": {
8
+ "types": "./dist/client.d.ts",
9
+ "import": "./dist/client.js",
10
+ "default": "./dist/client.js"
11
+ },
12
+ "./server": {
13
+ "types": "./dist/server.d.ts",
14
+ "import": "./dist/server.js",
15
+ "default": "./dist/server.js"
16
+ }
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "README.md",
21
+ "LICENSE"
22
+ ],
23
+ "sideEffects": false,
24
+ "keywords": [
25
+ "analytics",
26
+ "trakoo",
27
+ "posthog",
28
+ "typescript"
29
+ ],
30
+ "author": "Chris Jayden <https://github.com/multiplehats>",
31
+ "license": "MIT",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/multiplehats/trakoo.git",
35
+ "directory": "packages/posthog"
36
+ },
37
+ "bugs": {
38
+ "url": "https://github.com/multiplehats/trakoo/issues"
39
+ },
40
+ "homepage": "https://trakoo.co/docs/providers/posthog",
41
+ "publishConfig": {
42
+ "access": "public"
43
+ },
44
+ "engines": {
45
+ "node": ">=20"
46
+ },
47
+ "peerDependencies": {
48
+ "posthog-js": "^1.268.2",
49
+ "posthog-node": "^5.9.0",
50
+ "trakoo": "^1.2.1"
51
+ },
52
+ "peerDependenciesMeta": {
53
+ "posthog-js": {
54
+ "optional": true
55
+ },
56
+ "posthog-node": {
57
+ "optional": true
58
+ }
59
+ },
60
+ "devDependencies": {
61
+ "posthog-js": "^1.434.12",
62
+ "posthog-node": "^5.53.0",
63
+ "trakoo": "1.2.1"
64
+ },
65
+ "scripts": {
66
+ "build": "tsc -p tsconfig.build.json",
67
+ "typecheck": "tsc --noEmit",
68
+ "test": "vitest run"
69
+ }
70
+ }