Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions modules/posthog/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { defineNuxtModule, addImports, addComponent, addPlugin, createResolver, addTypeTemplate } from '@nuxt/kit';
import type { PostHogConfig } from 'posthog-js';
import { defu } from 'defu';

export interface ModuleOptions {
/**
* The PostHog API key
* @default process.env.POSTHOG_API_KEY
* @example 'phc_1234567890abcdef1234567890abcdef1234567890a'
* @type string
* @docs https://posthog.com/docs/api
*/
publicKey: string;

/**
* The PostHog API host
* @default process.env.POSTHOG_API_HOST
* @example 'https://app.posthog.com'
* @type string
* @docs https://posthog.com/docs/api
*/
host: string;

/**
* If set to true, the module will capture page views automatically
* @default true
* @type boolean
* @docs https://posthog.com/docs/product-analytics/capture-events#single-page-apps-and-pageviews
*/
capturePageViews?: boolean;

/**
* PostHog Client options
* @default {
* api_host: process.env.POSTHOG_API_HOST,
* loaded: () => <enable debug mode if in development>
* }
* @type object
* @docs https://posthog.com/docs/libraries/js#config
*/
clientOptions?: Partial<PostHogConfig>;

/**
* If set to true, the module will be disabled (no events will be sent to PostHog).
* This is useful for development environments. Directives and components will still be available for you to use.
* @default false
* @type boolean
*/
disabled?: boolean;
}

type PosthogRuntimeConfig = Required<ModuleOptions>;

export default defineNuxtModule<ModuleOptions>({
meta: {
name: 'nuxt-posthog',
configKey: 'posthog',
},
defaults: {
publicKey: process.env.POSTHOG_API_KEY as string,
host: process.env.POSTHOG_API_HOST as string,
capturePageViews: true,
disabled: false,
},
setup(options, nuxt) {
const { resolve } = createResolver(import.meta.url);

// Public runtimeConfig
nuxt.options.runtimeConfig.public.posthog = defu<PosthogRuntimeConfig, PosthogRuntimeConfig[]>(
nuxt.options.runtimeConfig.public.posthog,
{
publicKey: options.publicKey,
host: options.host,
capturePageViews: options.capturePageViews ?? true,
clientOptions: options.clientOptions ?? {},
disabled: options.disabled ?? false,
},
);

// Make sure url and key are set
if (!nuxt.options.runtimeConfig.public.posthog.publicKey) {
// eslint-disable-next-line no-console
console.warn('Missing PostHog API public key, set it either in `nuxt.config.ts` or via env variable');
}

if (!nuxt.options.runtimeConfig.public.posthog.host) {
// eslint-disable-next-line no-console
console.warn('Missing PostHog API host, set it either in `nuxt.config.ts` or via env variable');
}

addPlugin(resolve('./runtime/plugins/directives'));
addPlugin(resolve('./runtime/plugins/posthog.client'));
addPlugin(resolve('./runtime/plugins/posthog.server'));

addImports({
from: resolve('./runtime/composables/usePostHogFeatureFlag'),
name: 'usePostHogFeatureFlag',
});

addComponent({
filePath: resolve('./runtime/components/PostHogFeatureFlag.vue'),
name: 'PostHogFeatureFlag',
});

addTypeTemplate({
filename: 'types/posthog-directives.d.ts',
src: resolve('./runtime/types/directives.d.ts'),
});
},
});
20 changes: 20 additions & 0 deletions modules/posthog/runtime/components/PostHogFeatureFlag.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<script setup lang="ts">
import { computed } from 'vue';
import usePostHogFeatureFlag from '../composables/usePostHogFeatureFlag';

const { name } = withDefaults(
defineProps<{
name: string;
match?: boolean | string;
}>(),
{ match: true },
);

const { getFeatureFlag } = usePostHogFeatureFlag();

const featureFlag = computed(() => getFeatureFlag(name));
</script>

<template>
<slot v-if="featureFlag?.value === match" :payload="featureFlag.payload" />
</template>
23 changes: 23 additions & 0 deletions modules/posthog/runtime/composables/usePostHogFeatureFlag.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { useState } from '#app';
import type { JsonType } from 'posthog-js';

export default () => {
const posthogFeatureFlags = useState<Record<string, boolean | string> | undefined>('ph-feature-flags');
const posthogFeatureFlagPayloads = useState<Record<string, JsonType> | undefined>('ph-feature-flag-payloads');

const isFeatureEnabled = (feature: string) => {
return posthogFeatureFlags.value?.[feature] ?? false;
};

const getFeatureFlag = (feature: string) => {
return {
value: posthogFeatureFlags.value?.[feature] ?? false,
payload: posthogFeatureFlagPayloads.value?.[feature],
};
};

return {
isFeatureEnabled,
getFeatureFlag,
};
};
88 changes: 88 additions & 0 deletions modules/posthog/runtime/directives/v-capture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { useNuxtApp } from '#app';
import type { ObjectDirective, FunctionDirective, DirectiveBinding } from 'vue';

type CaptureEvent = {
name: string;
properties?: Record<string, any>;
};

type CaptureModifiers = {
click?: boolean;
hover?: boolean;
};

type EventHandler = {
event: string;
handler: (_event: Event) => void;
};

const listeners = new WeakMap<HTMLElement, EventHandler[]>();

const directive: FunctionDirective<HTMLElement, CaptureEvent | string> = (
el,
binding: DirectiveBinding<CaptureEvent | string> & { modifiers: CaptureModifiers },
) => {
const { value, modifiers } = binding;

// Don't bind if the value is undefined
if (!value) {
return;
}

const { $posthog } = useNuxtApp();

function capture(_event: Event) {
if (!$posthog) return;

if (typeof value === 'string') {
$posthog.capture(value);
} else {
$posthog.capture(value.name, value.properties);
}
}

// Determine the events to listen for based on the modifiers
const events: string[] = [];

if (Object.keys(modifiers).length === 0) {
// Default to click if no modifiers are specified
events.push('click');
} else {
if (modifiers.click) events.push('click');
if (modifiers.hover) events.push('mouseenter');
}

// Remove existing event listeners
if (listeners.has(el)) {
const oldEvents = listeners.get(el) as EventHandler[];

oldEvents.forEach(({ event, handler }) => {
el.removeEventListener(event, handler);
});
}

// Add new event listeners and store them
const eventHandlers = events.map((event) => {
const handler = capture.bind(null);
el.addEventListener(event, handler);
return { event, handler };
});

listeners.set(el, eventHandlers);
};

export const vCapture: ObjectDirective = {
mounted: directive,
updated: directive,
unmounted(el) {
if (listeners.has(el)) {
const eventHandlers = listeners.get(el) as EventHandler[];

eventHandlers.forEach(({ event, handler }) => {
el.removeEventListener(event, handler);
});

listeners.delete(el);
}
},
};
6 changes: 6 additions & 0 deletions modules/posthog/runtime/plugins/directives.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { vCapture } from '../directives/v-capture';
import { defineNuxtPlugin } from '#app';

export default defineNuxtPlugin(({ vueApp }) => {
vueApp.directive('capture', vCapture);
});
77 changes: 77 additions & 0 deletions modules/posthog/runtime/plugins/posthog.client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { defineNuxtPlugin, useCookie, useRouter, useRuntimeConfig, useState } from '#app';
import { posthog, type PostHog, type JsonType, type PostHogConfig } from 'posthog-js';
import { defu } from 'defu';

export default defineNuxtPlugin({
name: 'posthog',
enforce: 'pre',
setup: async () => {
const config = useRuntimeConfig().public.posthog;

if (config.disabled) {
return {
provide: {
posthog: null as PostHog | null,
},
};
}

const posthogFeatureFlags = useState<Record<string, boolean | string> | undefined>('ph-feature-flags');
const posthogFeatureFlagPayloads = useState<Record<string, JsonType> | undefined>('ph-feature-flag-payloads');

const clientOptions = defu<PostHogConfig, Partial<PostHogConfig>[]>(config.clientOptions ?? {}, {
api_host: config.host,
ui_host: '<ph_app_host>',
capture_pageview: false,
bootstrap: {
featureFlags: posthogFeatureFlags.value,
featureFlagPayloads: posthogFeatureFlagPayloads.value,
},
loaded: (posthog) => {
if (import.meta.env.MODE === 'development') posthog.debug();
},
});

const identity = useCookie(`ph_${config.publicKey}_posthog`, { default: () => '' }) as any;

clientOptions.bootstrap.distinctID = identity.value?.distinct_id ?? undefined;

const posthogClient = posthog.init(config.publicKey, clientOptions);

if (config.capturePageViews) {
// Make sure that pageviews are captured with each route change
const router = useRouter();

router.beforeEach(() => {
posthog.capture('$pageview');
});
}

posthog.onFeatureFlags((flags, featureFlags) => {
posthogFeatureFlags.value = featureFlags;

posthogFeatureFlagPayloads.value = flags.reduce<Record<string, JsonType>>((acc, flag) => {
acc[flag] = posthog.getFeatureFlagPayload(flag);
return acc;
}, {});
});

return {
provide: {
posthog: (posthogClient ? posthogClient : null) as PostHog | null,
},
};
},
});

declare module '#app' {
interface NuxtApp {
$posthog: PostHog | null;
}
}

declare module '@vue/runtime-core' {
interface ComponentCustomProperties {
$posthog: PostHog | null;
}
}
44 changes: 44 additions & 0 deletions modules/posthog/runtime/plugins/posthog.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { defineNuxtPlugin, useRuntimeConfig, useState, useRequestEvent } from '#app';
import { getCookie } from 'h3';
import { PostHog } from 'posthog-node';
import type { JsonType } from 'posthog-js';

export default defineNuxtPlugin({
name: 'posthog-server',
enforce: 'pre',
setup: async () => {
const config = useRuntimeConfig().public.posthog;

const event = useRequestEvent();
const cookie = event ? getCookie(event, `ph_${config.publicKey}_posthog`) : undefined;
const identity = JSON.parse(cookie ?? '{}');
const distinctId = identity?.distinct_id ?? undefined;

if (config.disabled) {
return {
provide: {
serverPosthog: null as PostHog | null,
},
};
}

if (config.publicKey.length === 0) {
// PostHog public key is not defined. Skipping PostHog setup.
// User has already been warned on dev startup
return {};
}

const posthog = new PostHog(config.publicKey, { host: config.host });

const { featureFlags, featureFlagPayloads } = await posthog.getAllFlagsAndPayloads(distinctId);

useState<Record<string, boolean | string> | undefined>('ph-feature-flags', () => featureFlags);
useState<Record<string, JsonType> | undefined>('ph-feature-flag-payloads', () => featureFlagPayloads);

return {
provide: {
serverPosthog: posthog,
},
};
},
});
Loading