diff --git a/.deploy/ssh/with-cloudflare/demo/docker-compose.api.demo.template.yml b/.deploy/ssh/with-cloudflare/demo/docker-compose.api.demo.template.yml index 7e220108fb6..3a5d3ea2315 100644 --- a/.deploy/ssh/with-cloudflare/demo/docker-compose.api.demo.template.yml +++ b/.deploy/ssh/with-cloudflare/demo/docker-compose.api.demo.template.yml @@ -33,6 +33,10 @@ services: SENTRY_HTTP_TRACING_ENABLED: '${SENTRY_HTTP_TRACING_ENABLED:-}' SENTRY_POSTGRES_TRACKING_ENABLED: '${SENTRY_POSTGRES_TRACKING_ENABLED:-}' SENTRY_PROFILING_ENABLED: '${SENTRY_PROFILING_ENABLED:-}' + POSTHOG_KEY: '${POSTHOG_KEY:-}' + POSTHOG_HOST: '${POSTHOG_HOST:-}' + POSTHOG_ENABLED: '${POSTHOG_ENABLED:-}' + POSTHOG_FLUSH_INTERVAL: '${POSTHOG_FLUSH_INTERVAL:-}' AWS_ACCESS_KEY_ID: '${AWS_ACCESS_KEY_ID:-}' AWS_SECRET_ACCESS_KEY: '${AWS_SECRET_ACCESS_KEY:-}' AWS_REGION: '${AWS_REGION:-}' diff --git a/.deploy/ssh/with-cloudflare/prod/docker-compose.api.prod.template.yml b/.deploy/ssh/with-cloudflare/prod/docker-compose.api.prod.template.yml index 32b1fa202fa..0d2ac8344ea 100644 --- a/.deploy/ssh/with-cloudflare/prod/docker-compose.api.prod.template.yml +++ b/.deploy/ssh/with-cloudflare/prod/docker-compose.api.prod.template.yml @@ -33,6 +33,10 @@ services: SENTRY_HTTP_TRACING_ENABLED: '${SENTRY_HTTP_TRACING_ENABLED}' SENTRY_POSTGRES_TRACKING_ENABLED: '${SENTRY_POSTGRES_TRACKING_ENABLED}' SENTRY_PROFILING_ENABLED: '${SENTRY_PROFILING_ENABLED}' + POSTHOG_KEY: '${POSTHOG_KEY}' + POSTHOG_HOST: '${POSTHOG_HOST}' + POSTHOG_ENABLED: '${POSTHOG_ENABLED}' + POSTHOG_FLUSH_INTERVAL: '${POSTHOG_FLUSH_INTERVAL}' AWS_ACCESS_KEY_ID: '${AWS_ACCESS_KEY_ID}' AWS_SECRET_ACCESS_KEY: '${AWS_SECRET_ACCESS_KEY}' AWS_REGION: '${AWS_REGION}' diff --git a/.deploy/ssh/with-cloudflare/stage/docker-compose.api.stage.template.yml b/.deploy/ssh/with-cloudflare/stage/docker-compose.api.stage.template.yml index 0b9fe433d6c..29d4bf4858b 100644 --- a/.deploy/ssh/with-cloudflare/stage/docker-compose.api.stage.template.yml +++ b/.deploy/ssh/with-cloudflare/stage/docker-compose.api.stage.template.yml @@ -33,6 +33,10 @@ services: SENTRY_HTTP_TRACING_ENABLED: '${SENTRY_HTTP_TRACING_ENABLED}' SENTRY_POSTGRES_TRACKING_ENABLED: '${SENTRY_POSTGRES_TRACKING_ENABLED}' SENTRY_PROFILING_ENABLED: '${SENTRY_PROFILING_ENABLED}' + POSTHOG_KEY: '${POSTHOG_KEY}' + POSTHOG_HOST: '${POSTHOG_HOST}' + POSTHOG_ENABLED: '${POSTHOG_ENABLED}' + POSTHOG_FLUSH_INTERVAL: '${POSTHOG_FLUSH_INTERVAL}' AWS_ACCESS_KEY_ID: '${AWS_ACCESS_KEY_ID}' AWS_SECRET_ACCESS_KEY: '${AWS_SECRET_ACCESS_KEY}' AWS_REGION: '${AWS_REGION}' diff --git a/.deploy/ssh/with-letsencrypt/demo/docker-compose.api.demo.template.yml b/.deploy/ssh/with-letsencrypt/demo/docker-compose.api.demo.template.yml index 7e220108fb6..3a5d3ea2315 100644 --- a/.deploy/ssh/with-letsencrypt/demo/docker-compose.api.demo.template.yml +++ b/.deploy/ssh/with-letsencrypt/demo/docker-compose.api.demo.template.yml @@ -33,6 +33,10 @@ services: SENTRY_HTTP_TRACING_ENABLED: '${SENTRY_HTTP_TRACING_ENABLED:-}' SENTRY_POSTGRES_TRACKING_ENABLED: '${SENTRY_POSTGRES_TRACKING_ENABLED:-}' SENTRY_PROFILING_ENABLED: '${SENTRY_PROFILING_ENABLED:-}' + POSTHOG_KEY: '${POSTHOG_KEY:-}' + POSTHOG_HOST: '${POSTHOG_HOST:-}' + POSTHOG_ENABLED: '${POSTHOG_ENABLED:-}' + POSTHOG_FLUSH_INTERVAL: '${POSTHOG_FLUSH_INTERVAL:-}' AWS_ACCESS_KEY_ID: '${AWS_ACCESS_KEY_ID:-}' AWS_SECRET_ACCESS_KEY: '${AWS_SECRET_ACCESS_KEY:-}' AWS_REGION: '${AWS_REGION:-}' diff --git a/.deploy/ssh/with-letsencrypt/prod/docker-compose.api.prod.template.yml b/.deploy/ssh/with-letsencrypt/prod/docker-compose.api.prod.template.yml index 9fa35660bfa..ca15eb420e6 100644 --- a/.deploy/ssh/with-letsencrypt/prod/docker-compose.api.prod.template.yml +++ b/.deploy/ssh/with-letsencrypt/prod/docker-compose.api.prod.template.yml @@ -33,6 +33,10 @@ services: SENTRY_HTTP_TRACING_ENABLED: '${SENTRY_HTTP_TRACING_ENABLED}' SENTRY_POSTGRES_TRACKING_ENABLED: '${SENTRY_POSTGRES_TRACKING_ENABLED}' SENTRY_PROFILING_ENABLED: '${SENTRY_PROFILING_ENABLED}' + POSTHOG_KEY: '${POSTHOG_KEY}' + POSTHOG_HOST: '${POSTHOG_HOST}' + POSTHOG_ENABLED: '${POSTHOG_ENABLED}' + POSTHOG_FLUSH_INTERVAL: '${POSTHOG_FLUSH_INTERVAL}' AWS_ACCESS_KEY_ID: '${AWS_ACCESS_KEY_ID}' AWS_SECRET_ACCESS_KEY: '${AWS_SECRET_ACCESS_KEY}' AWS_REGION: '${AWS_REGION}' diff --git a/.deploy/ssh/with-letsencrypt/stage/docker-compose.api.stage.template.yml b/.deploy/ssh/with-letsencrypt/stage/docker-compose.api.stage.template.yml index eca8f6b924b..145ea39cd26 100644 --- a/.deploy/ssh/with-letsencrypt/stage/docker-compose.api.stage.template.yml +++ b/.deploy/ssh/with-letsencrypt/stage/docker-compose.api.stage.template.yml @@ -33,6 +33,10 @@ services: SENTRY_HTTP_TRACING_ENABLED: '${SENTRY_HTTP_TRACING_ENABLED}' SENTRY_POSTGRES_TRACKING_ENABLED: '${SENTRY_POSTGRES_TRACKING_ENABLED}' SENTRY_PROFILING_ENABLED: '${SENTRY_PROFILING_ENABLED}' + POSTHOG_KEY: '${POSTHOG_KEY}' + POSTHOG_HOST: '${POSTHOG_HOST}' + POSTHOG_ENABLED: '${POSTHOG_ENABLED}' + POSTHOG_FLUSH_INTERVAL: '${POSTHOG_FLUSH_INTERVAL}' AWS_ACCESS_KEY_ID: '${AWS_ACCESS_KEY_ID}' AWS_SECRET_ACCESS_KEY: '${AWS_SECRET_ACCESS_KEY}' AWS_REGION: '${AWS_REGION}' diff --git a/.env.compose b/.env.compose index a3afc8edcb0..3403769f904 100644 --- a/.env.compose +++ b/.env.compose @@ -250,6 +250,12 @@ SENTRY_POSTGRES_TRACKING_ENABLED=false SENTRY_PROFILING_ENABLED=false SENTRY_TRACES_SAMPLE_RATE=0.1 +# PostHog Configuration +POSTHOG_KEY= +POSTHOG_HOST=https://app.posthog.com +POSTHOG_ENABLED=true +POSTHOG_FLUSH_INTERVAL=10000 + # Default Currency DEFAULT_CURRENCY=USD diff --git a/.env.demo.compose b/.env.demo.compose index f11e5a6ebad..99e9c334fcb 100644 --- a/.env.demo.compose +++ b/.env.demo.compose @@ -257,6 +257,14 @@ SENTRY_POSTGRES_TRACKING_ENABLED=false SENTRY_PROFILING_ENABLED=false SENTRY_TRACES_SAMPLE_RATE=0.1 + +# PostHog Configuration +POSTHOG_KEY= +POSTHOG_HOST=https://app.posthog.com +POSTHOG_ENABLED=true +POSTHOG_FLUSH_INTERVAL=10000 + + # Default Currency DEFAULT_CURRENCY=USD diff --git a/.env.docker b/.env.docker index e3855a7b5aa..c0eb4992352 100644 --- a/.env.docker +++ b/.env.docker @@ -237,6 +237,12 @@ SENTRY_POSTGRES_TRACKING_ENABLED=false SENTRY_PROFILING_ENABLED=false SENTRY_TRACES_SAMPLE_RATE=0.1 +# PostHog Configuration +POSTHOG_KEY= +POSTHOG_HOST=https://app.posthog.com +POSTHOG_ENABLED=true +POSTHOG_FLUSH_INTERVAL=10000 + # Default Currency DEFAULT_CURRENCY=USD diff --git a/.env.local b/.env.local index 71296cf3052..7f681c8b5eb 100644 --- a/.env.local +++ b/.env.local @@ -242,6 +242,12 @@ SENTRY_PROFILING_ENABLED=false SENTRY_TRACES_SAMPLE_RATE=0.01 SENTRY_PROFILE_SAMPLE_RATE=0.01 +# PostHog Configuration +POSTHOG_KEY= +POSTHOG_HOST=https://us.i.posthog.com +POSTHOG_ENABLED=false +POSTHOG_FLUSH_INTERVAL=10000 + # Default Currency DEFAULT_CURRENCY=USD diff --git a/.env.sample b/.env.sample index 2283f735253..b65fecf2f22 100644 --- a/.env.sample +++ b/.env.sample @@ -241,6 +241,12 @@ SENTRY_PROFILING_ENABLED= SENTRY_TRACES_SAMPLE_RATE= SENTRY_PROFILE_SAMPLE_RATE= +# PostHog Configuration +POSTHOG_KEY= +POSTHOG_HOST= +POSTHOG_ENABLED= +POSTHOG_FLUSH_INTERVAL= + # Default Currency DEFAULT_CURRENCY=USD diff --git a/apps/api/package.json b/apps/api/package.json index f06ca257f82..5130bdea2d4 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -55,6 +55,7 @@ "@gauzy/plugin-knowledge-base": "^0.1.0", "@gauzy/plugin-product-reviews": "^0.1.0", "@gauzy/plugin-sentry": "^0.1.0", + "@gauzy/plugin-posthog": "^0.1.0", "@gauzy/plugin-videos": "^0.1.0", "@gauzy/plugin-registry": "^0.1.0", "dotenv": "^16.0.3", diff --git a/apps/api/src/plugin-config.ts b/apps/api/src/plugin-config.ts index b60f9712e3e..b8eab74666c 100644 --- a/apps/api/src/plugin-config.ts +++ b/apps/api/src/plugin-config.ts @@ -9,11 +9,13 @@ import { dbKnexConnectionConfig } from '@gauzy/config'; import { SentryService } from '@gauzy/plugin-sentry'; +import { PosthogService } from '@gauzy/plugin-posthog'; +import { PosthogAnalytics as PosthogPlugin } from './posthog'; import { SentryTracing as SentryPlugin } from './sentry'; import { version } from './../version'; import { plugins } from './plugins'; -const { sentry } = environment; +const { sentry, posthog } = environment; console.log(chalk.magenta(`API Version %s`), version); console.log('Plugin Config -> __dirname: ' + __dirname); @@ -90,6 +92,30 @@ export const pluginConfig: ApplicationPluginConfig = { assetPath: assetPath, assetPublicPath: assetPublicPath }, - ...(sentry?.dsn ? { logger: new SentryService(SentryPlugin.options) } : {}), + logger: (() => { + const loggers = []; + + if (sentry?.dsn) { + loggers.push(new SentryService(SentryPlugin.options)); + } + if (posthog?.posthogEnabled && posthog?.posthogKey) { + loggers.push(new PosthogService(PosthogPlugin.options)); + } + + // Combine both, or return undefined if no logger is configured + if (loggers.length === 0) { + return undefined; + } else if (loggers.length === 1) { + return loggers[0]; + } else { + return { + log: (...args) => loggers.forEach((logger) => logger.log?.(...args)), + error: (...args) => loggers.forEach((logger) => logger.error?.(...args)), + warn: (...args) => loggers.forEach((logger) => logger.warn?.(...args)), + debug: (...args) => loggers.forEach((logger) => logger.debug?.(...args)), + verbose: (...args) => loggers.forEach((logger) => logger.verbose?.(...args)) + }; + } + })(), plugins }; diff --git a/apps/api/src/plugins.ts b/apps/api/src/plugins.ts index 70b1857c46d..19ec8af8c23 100644 --- a/apps/api/src/plugins.ts +++ b/apps/api/src/plugins.ts @@ -17,8 +17,9 @@ import { VideosPlugin } from '@gauzy/plugin-videos'; import { RegistryPlugin } from '@gauzy/plugin-registry'; import { SentryTracing as SentryPlugin } from './sentry'; +import { PosthogAnalytics as PosthogPlugin } from './posthog'; -const { jitsu, sentry } = environment; +const { jitsu, sentry, posthog } = environment; /** * An array of plugins to be included or used in the codebase. @@ -26,6 +27,10 @@ const { jitsu, sentry } = environment; export const plugins = [ // Includes the SentryPlugin based on the presence of Sentry configuration. ...(sentry && sentry.dsn ? [SentryPlugin] : []), + + // Includes the PostHogPlugin based on the presence of PostHog configuration. + ...(posthog?.posthogEnabled && posthog?.posthogKey ? [PosthogPlugin] : []), + // Initializes the Jitsu Analytics Plugin by providing a configuration object. JitsuAnalyticsPlugin.init({ config: { diff --git a/apps/api/src/posthog.ts b/apps/api/src/posthog.ts new file mode 100644 index 00000000000..515d201bc45 --- /dev/null +++ b/apps/api/src/posthog.ts @@ -0,0 +1,31 @@ +import { environment } from '@gauzy/config'; +import { PosthogPlugin } from '@gauzy/plugin-posthog'; + +/** + * Initializes and configures the PostHog plugin for analytics and performance tracking. + * + * This function checks if PostHog tracking is enabled in the environment configuration. + * If enabled, it initializes the PostHog plugin with the specified settings. + * Otherwise, it logs a message indicating that PostHog was not initialized. + * + * @returns {typeof PosthogPlugin | null} The configured PostHog instance, or null if not initialized. + */ +export function initializePosthog(): typeof PosthogPlugin | null { + if (!environment.posthog?.posthogEnabled || !environment.posthog?.posthogKey) { + console.log('PostHog not initialized: Tracking is disabled or API key is missing'); + return null; + } + + // Configure PostHog + return PosthogPlugin.init({ + apiKey: environment.posthog.posthogKey, + apiHost: environment.posthog.posthogHost || 'https://app.posthog.com', + enableErrorTracking: true, + flushInterval: environment.posthog.posthogFlushInterval || 10000, + flushAt: 20, + autocapture: true, + mock: false + }); +} + +export const PosthogAnalytics = initializePosthog(); diff --git a/docker-compose.build.yml b/docker-compose.build.yml index fe7de933196..afafe4a5c43 100644 --- a/docker-compose.build.yml +++ b/docker-compose.build.yml @@ -24,6 +24,10 @@ services: SENTRY_HTTP_TRACING_ENABLED: ${SENTRY_HTTP_TRACING_ENABLED:-} SENTRY_POSTGRES_TRACKING_ENABLED: ${SENTRY_POSTGRES_TRACKING_ENABLED:-} SENTRY_PROFILING_ENABLED: ${SENTRY_PROFILING_ENABLED:-} + POSTHOG_KEY: ${POSTHOG_KEY:-} + POSTHOG_HOST: ${POSTHOG_HOST:-} + POSTHOG_ENABLED: ${POSTHOG_ENABLED:-} + POSTHOG_FLUSH_INTERVAL: ${POSTHOG_FLUSH_INTERVAL:-} JITSU_SERVER_URL: ${JITSU_SERVER_URL:-} OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: ${OTEL_EXPORTER_OTLP_TRACES_ENDPOINT:-} OTEL_EXPORTER_OTLP_HEADERS: ${OTEL_EXPORTER_OTLP_HEADERS:-} diff --git a/docker-compose.demo.yml b/docker-compose.demo.yml index 74eb7894ad7..219c2d2264a 100644 --- a/docker-compose.demo.yml +++ b/docker-compose.demo.yml @@ -35,6 +35,10 @@ services: SENTRY_HTTP_TRACING_ENABLED: ${SENTRY_HTTP_TRACING_ENABLED:-} SENTRY_POSTGRES_TRACKING_ENABLED: ${SENTRY_POSTGRES_TRACKING_ENABLED:-} SENTRY_PROFILING_ENABLED: ${SENTRY_PROFILING_ENABLED:-} + POSTHOG_KEY: ${POSTHOG_KEY:-} + POSTHOG_HOST: ${POSTHOG_HOST:-} + POSTHOG_ENABLED: ${POSTHOG_ENABLED:-} + POSTHOG_FLUSH_INTERVAL: ${POSTHOG_FLUSH_INTERVAL:-} JITSU_SERVER_URL: ${JITSU_SERVER_URL:-} JITSU_SERVER_WRITE_KEY: ${JITSU_SERVER_WRITE_KEY:-} OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: ${OTEL_EXPORTER_OTLP_TRACES_ENDPOINT:-} diff --git a/docker-compose.yml b/docker-compose.yml index 863a4a4b635..6a86c909eef 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,6 +17,10 @@ services: SENTRY_HTTP_TRACING_ENABLED: ${SENTRY_HTTP_TRACING_ENABLED:-} SENTRY_POSTGRES_TRACKING_ENABLED: ${SENTRY_POSTGRES_TRACKING_ENABLED:-} SENTRY_PROFILING_ENABLED: ${SENTRY_PROFILING_ENABLED:-} + POSTHOG_KEY: ${POSTHOG_KEY:-} + POSTHOG_HOST: ${POSTHOG_HOST:-} + POSTHOG_ENABLED: ${POSTHOG_ENABLED:-} + POSTHOG_FLUSH_INTERVAL: ${POSTHOG_FLUSH_INTERVAL:-} JITSU_SERVER_URL: ${JITSU_SERVER_URL:-} OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: ${OTEL_EXPORTER_OTLP_TRACES_ENDPOINT:-} OTEL_EXPORTER_OTLP_HEADERS: ${OTEL_EXPORTER_OTLP_HEADERS:-} diff --git a/package.json b/package.json index 3d6a3580419..10f50ad3cf9 100644 --- a/package.json +++ b/package.json @@ -170,9 +170,9 @@ "build:package:plugins:pre": "yarn run build:package:ui-core && yarn run build:package:ui-auth && yarn run build:package:plugin:onboarding-ui && yarn run build:package:plugin:legal-ui && yarn run build:package:plugin:job-search-ui && yarn run build:package:plugin:job-matching-ui && yarn run build:package:plugin:job-employee-ui && yarn run build:package:plugin:job-proposal-ui && yarn run build:package:plugin:public-layout-ui && yarn run build:package:plugin:maintenance-ui && yarn run build:package:plugin:videos-ui && yarn run build:integration-ui-plugins", "build:package:plugins:pre:prod": "yarn run build:package:ui-core:prod && yarn run build:package:ui-auth:prod && yarn run build:package:plugin:onboarding-ui:prod && yarn run build:package:plugin:legal-ui:prod && yarn run build:package:plugin:job-search-ui:prod && yarn run build:package:plugin:job-matching-ui:prod && yarn run build:package:plugin:job-employee-ui:prod && yarn run build:package:plugin:job-proposal-ui:prod && yarn run build:package:plugin:public-layout-ui:prod && yarn run build:package:plugin:maintenance-ui:prod && yarn run build:package:plugin:videos-ui:prod && yarn run build:integration-ui-plugins:prod", "build:package:plugins:pre:docker": "yarn run build:package:ui-core:docker && yarn run build:package:ui-auth:docker && yarn run build:package:plugin:onboarding-ui:docker && yarn run build:package:plugin:legal-ui:docker && yarn run build:package:plugin:job-search-ui:docker && yarn run build:package:plugin:job-matching-ui:docker && yarn run build:package:plugin:job-employee-ui:docker && yarn run build:package:plugin:job-proposal-ui:docker && yarn run build:package:plugin:public-layout-ui:docker && yarn run build:package:plugin:maintenance-ui:docker && yarn run build:package:plugin:videos-ui:docker && yarn run build:integration-ui-plugins:docker", - "build:package:plugins:post": "yarn run build:package:plugin:integration-jira && yarn run build:package:plugin:integration-ai && yarn run build:package:plugin:sentry && yarn run build:package:plugin:jitsu-analytic && yarn run build:package:plugin:product-reviews && yarn run build:package:plugin:job-search && yarn run build:package:plugin:job-proposal && yarn run build:package:plugin:integration-github && yarn run build:package:plugin:knowledge-base && yarn run build:package:plugin:changelog && yarn run build:package:plugin:integration-hubstaff && yarn run build:package:plugin:integration-upwork && yarn run build:package:plugin:integration-make-com && yarn run build:package:plugin:videos && yarn run build:package:plugin:registry && yarn run build:package:plugin:integration-zapier", - "build:package:plugins:post:prod": "yarn run build:package:plugin:integration-jira:prod && yarn run build:package:plugin:integration-ai:prod && yarn run build:package:plugin:sentry:prod && yarn run build:package:plugin:jitsu-analytic:prod && yarn run build:package:plugin:product-reviews:prod && yarn run build:package:plugin:job-search:prod && yarn run build:package:plugin:job-proposal:prod && yarn run build:package:plugin:integration-github:prod && yarn run build:package:plugin:knowledge-base:prod && yarn run build:package:plugin:changelog:prod && yarn run build:package:plugin:integration-hubstaff:prod && yarn run build:package:plugin:integration-upwork:prod && yarn run build:package:plugin:integration-make-com:prod && yarn run build:package:plugin:videos:prod && yarn run build:package:plugin:registry:prod && yarn run build:package:plugin:integration-zapier:prod", - "build:package:plugins:post:docker": "yarn run build:package:plugin:integration-jira:docker && yarn run build:package:plugin:integration-ai:docker && yarn run build:package:plugin:sentry:docker && yarn run build:package:plugin:jitsu-analytic:docker && yarn run build:package:plugin:product-reviews:docker && yarn run build:package:plugin:job-search:docker && yarn run build:package:plugin:job-proposal:docker && yarn run build:package:plugin:integration-github:docker && yarn run build:package:plugin:knowledge-base:docker && yarn run build:package:plugin:changelog:docker && yarn run build:package:plugin:integration-hubstaff:docker && yarn run build:package:plugin:integration-upwork:docker && yarn run build:package:plugin:integration-make-com:docker && yarn run build:package:plugin:videos:docker && yarn run build:package:plugin:registry:docker && yarn run build:package:plugin:integration-zapier:docker", + "build:package:plugins:post": "yarn run build:package:plugin:integration-jira && yarn run build:package:plugin:integration-ai && yarn run build:package:plugin:sentry && yarn run build:package:plugin:posthog && yarn run build:package:plugin:jitsu-analytic && yarn run build:package:plugin:product-reviews && yarn run build:package:plugin:job-search && yarn run build:package:plugin:job-proposal && yarn run build:package:plugin:integration-github && yarn run build:package:plugin:knowledge-base && yarn run build:package:plugin:changelog && yarn run build:package:plugin:integration-hubstaff && yarn run build:package:plugin:integration-upwork && yarn run build:package:plugin:integration-make-com && yarn run build:package:plugin:videos && yarn run build:package:plugin:registry && yarn run build:package:plugin:integration-zapier", + "build:package:plugins:post:prod": "yarn run build:package:plugin:integration-jira:prod && yarn run build:package:plugin:integration-ai:prod && yarn run build:package:plugin:sentry:prod && yarn run build:package:plugin:posthog:prod && yarn run build:package:plugin:jitsu-analytic:prod && yarn run build:package:plugin:product-reviews:prod && yarn run build:package:plugin:job-search:prod && yarn run build:package:plugin:job-proposal:prod && yarn run build:package:plugin:integration-github:prod && yarn run build:package:plugin:knowledge-base:prod && yarn run build:package:plugin:changelog:prod && yarn run build:package:plugin:integration-hubstaff:prod && yarn run build:package:plugin:integration-upwork:prod && yarn run build:package:plugin:integration-make-com:prod && yarn run build:package:plugin:videos:prod && yarn run build:package:plugin:registry:prod && yarn run build:package:plugin:integration-zapier:prod", + "build:package:plugins:post:docker": "yarn run build:package:plugin:integration-jira:docker && yarn run build:package:plugin:integration-ai:docker && yarn run build:package:plugin:sentry:docker && yarn run build:package:plugin:posthog:docker && yarn run build:package:plugin:jitsu-analytic:docker && yarn run build:package:plugin:product-reviews:docker && yarn run build:package:plugin:job-search:docker && yarn run build:package:plugin:job-proposal:docker && yarn run build:package:plugin:integration-github:docker && yarn run build:package:plugin:knowledge-base:docker && yarn run build:package:plugin:changelog:docker && yarn run build:package:plugin:integration-hubstaff:docker && yarn run build:package:plugin:integration-upwork:docker && yarn run build:package:plugin:integration-make-com:docker && yarn run build:package:plugin:videos:docker && yarn run build:package:plugin:registry:docker && yarn run build:package:plugin:integration-zapier:docker", "build:package:plugin:integration-ai": "cross-env NODE_ENV=development NODE_OPTIONS=--max-old-space-size=12288 yarn nx build plugin-integration-ai", "build:package:plugin:integration-ai:prod": "cross-env NODE_ENV=production NODE_OPTIONS=--max-old-space-size=12288 yarn nx build plugin-integration-ai", "build:package:plugin:integration-ai:docker": "cross-env NODE_ENV=production NODE_OPTIONS=--max-old-space-size=60000 yarn nx build plugin-integration-ai", @@ -197,6 +197,9 @@ "build:package:plugin:sentry": "cross-env NODE_ENV=development NODE_OPTIONS=--max-old-space-size=12288 yarn nx build plugin-sentry", "build:package:plugin:sentry:prod": "cross-env NODE_ENV=production NODE_OPTIONS=--max-old-space-size=12288 yarn nx build plugin-sentry", "build:package:plugin:sentry:docker": "cross-env NODE_ENV=production NODE_OPTIONS=--max-old-space-size=60000 yarn nx build plugin-sentry", + "build:package:plugin:posthog": "cross-env NODE_ENV=development NODE_OPTIONS=--max-old-space-size=12288 yarn nx build plugin-posthog", + "build:package:plugin:posthog:prod": "cross-env NODE_ENV=production NODE_OPTIONS=--max-old-space-size=12288 yarn nx build plugin-posthog", + "build:package:plugin:posthog:docker": "cross-env NODE_ENV=production NODE_OPTIONS=--max-old-space-size=60000 yarn nx build plugin-posthog", "build:package:plugin:jitsu-analytic": "cross-env NODE_ENV=development NODE_OPTIONS=--max-old-space-size=12288 yarn nx build plugin-jitsu-analytics", "build:package:plugin:jitsu-analytic:prod": "cross-env NODE_ENV=production NODE_OPTIONS=--max-old-space-size=12288 yarn nx build plugin-jitsu-analytics", "build:package:plugin:jitsu-analytic:docker": "cross-env NODE_ENV=production NODE_OPTIONS=--max-old-space-size=60000 yarn nx build plugin-jitsu-analytics", diff --git a/packages/common/src/lib/interfaces/IPosthogConfig.ts b/packages/common/src/lib/interfaces/IPosthogConfig.ts new file mode 100644 index 00000000000..b327abcaf26 --- /dev/null +++ b/packages/common/src/lib/interfaces/IPosthogConfig.ts @@ -0,0 +1,24 @@ +/** + * Represents a configuration object for PostHog server settings. + */ +export interface IPosthogConfig { + /** + * The PostHog API key used to authenticate requests. + */ + readonly posthogKey: string; + + /** + * The host URL of the PostHog instance (e.g., https://app.posthog.com). + */ + readonly posthogHost: string; + + /** + * Whether PostHog tracking is enabled. + */ + readonly posthogEnabled: boolean; + + /** + * How often events are flushed (in milliseconds). + */ + readonly posthogFlushInterval: number; +} diff --git a/packages/common/src/lib/interfaces/index.ts b/packages/common/src/lib/interfaces/index.ts index 8e7ef0ab7c6..2fa932689c8 100644 --- a/packages/common/src/lib/interfaces/index.ts +++ b/packages/common/src/lib/interfaces/index.ts @@ -1,25 +1,25 @@ -export * from './IApplicationPluginConfig'; -export * from './IGraphql'; export * from './IAbstractLogger'; - export * from './IAppIntegrationConfig'; +export * from './IApplicationPluginConfig'; export * from './IAuth0Config'; export * from './IAwsConfig'; export * from './ICloudinaryConfig'; +export * from './IDigitalOceanConfig'; export * from './IFacebookConfig'; export * from './IFiverrConfig'; export * from './IGithubConfig'; export * from './IGoogleConfig'; +export * from './IGraphql'; +export * from './IHubstaffConfig'; +export * from './IJiraIntegrationConfig'; +export * from './IJitsuConfig'; export * from './IKeycloakConfig'; export * from './ILinkedinIConfig'; export * from './IMicrosoftConfig'; +export * from './IPosthogConfig'; export * from './ISMTPConfig'; export * from './ITwitterConfig'; export * from './IUnleashConfig'; export * from './IUpworkConfig'; export * from './IWasabiConfig'; -export * from './IDigitalOceanConfig'; -export * from './IHubstaffConfig'; -export * from './IJitsuConfig'; -export * from './IJiraIntegrationConfig'; export * from './IZapierConfig'; diff --git a/packages/config/src/lib/environments/environment.prod.ts b/packages/config/src/lib/environments/environment.prod.ts index 44885dbade0..23978e088b9 100644 --- a/packages/config/src/lib/environments/environment.prod.ts +++ b/packages/config/src/lib/environments/environment.prod.ts @@ -171,6 +171,13 @@ export const environment: IEnvironment = { dsn: process.env.SENTRY_DSN }, + posthog: { + posthogKey: process.env.POSTHOG_KEY, + posthogHost: process.env.POSTHOG_HOST, + posthogEnabled: process.env.POSTHOG_ENABLED === 'true', + posthogFlushInterval: parseInt(process.env.POSTHOG_FLUSH_INTERVAL) || 10000 + }, + defaultIntegratedUserPass: process.env.INTEGRATED_USER_DEFAULT_PASS || '123456', upwork: { diff --git a/packages/config/src/lib/environments/environment.ts b/packages/config/src/lib/environments/environment.ts index 1c6767ed95d..29d93032cf7 100644 --- a/packages/config/src/lib/environments/environment.ts +++ b/packages/config/src/lib/environments/environment.ts @@ -171,6 +171,13 @@ export const environment: IEnvironment = { dsn: process.env.SENTRY_DSN }, + posthog: { + posthogKey: process.env.POSTHOG_KEY, + posthogHost: process.env.POSTHOG_HOST, + posthogEnabled: process.env.POSTHOG_ENABLED === 'true', + posthogFlushInterval: parseInt(process.env.POSTHOG_FLUSH_INTERVAL) || 10000 + }, + defaultIntegratedUserPass: process.env.INTEGRATED_USER_DEFAULT_PASS || '123456', upwork: { diff --git a/packages/config/src/lib/environments/ienvironment.ts b/packages/config/src/lib/environments/ienvironment.ts index edd7d01ce8d..12ed3bc4dd8 100644 --- a/packages/config/src/lib/environments/ienvironment.ts +++ b/packages/config/src/lib/environments/ienvironment.ts @@ -17,7 +17,8 @@ import { IWasabiConfig, IJiraIntegrationConfig, IDigitalOceanConfig, - IZapierConfig + IZapierConfig, + IPosthogConfig } from '@gauzy/common'; import { FileStorageProviderEnum } from '@gauzy/contracts'; @@ -109,6 +110,8 @@ export interface IEnvironment { dsn: string; }; + posthog?: IPosthogConfig; + /** * Default Integrated User Password */ diff --git a/packages/plugins/posthog/.dockerignore b/packages/plugins/posthog/.dockerignore new file mode 100644 index 00000000000..14ea7e1f315 --- /dev/null +++ b/packages/plugins/posthog/.dockerignore @@ -0,0 +1,23 @@ +# Ignore Git related files and directories +.git +.gitignore +.gitmodules + +# Ignore README file +README.md + +# Ignore Docker-related files and directories +docker + +# Ignore Node.js modules +node_modules + +# Ignore temporary files and directories +tmp + +# Ignore build and compilation output +build +dist + +# Ignore environment configuration files +.env diff --git a/packages/plugins/posthog/.gitignore b/packages/plugins/posthog/.gitignore new file mode 100644 index 00000000000..84e698df22c --- /dev/null +++ b/packages/plugins/posthog/.gitignore @@ -0,0 +1,11 @@ + +# dependencies +node_modules/ + +# yarn +yarn-error.log + +# misc +npm-debug.log +dist +build diff --git a/packages/plugins/posthog/.npmignore b/packages/plugins/posthog/.npmignore new file mode 100644 index 00000000000..1eb4beb9572 --- /dev/null +++ b/packages/plugins/posthog/.npmignore @@ -0,0 +1,4 @@ +# .npmignore + +src/ +node_modules/ diff --git a/packages/plugins/posthog/CHANGELOG.md b/packages/plugins/posthog/CHANGELOG.md new file mode 100644 index 00000000000..20832bf33f2 --- /dev/null +++ b/packages/plugins/posthog/CHANGELOG.md @@ -0,0 +1,3 @@ +# Changelog for @gauzy/plugin-posthog + +## [Unreleased] diff --git a/packages/plugins/posthog/README.md b/packages/plugins/posthog/README.md new file mode 100644 index 00000000000..5acedeb68bc --- /dev/null +++ b/packages/plugins/posthog/README.md @@ -0,0 +1,42 @@ +# @gauzy/plugin-posthog + +## Overview + +The PostHog Plugin seamlessly integrates your server with [PostHog](https://posthog.com), an open-source analytics platform. This integration enables you to capture, monitor, and analyze user behavior and application events in real time. + +## Features + +- **Error Tracking**: Automatically report exceptions and errors to PostHog’s new error tracking feature. +- **User Behavior Insights**: Gain comprehensive insights into user interactions within your application. +- **Event Tracking**: Capture custom application events for detailed analysis. + +## Configuration + +The plugin can be configured using the following environment variables: + +- `POSTHOG_KEY`: Your PostHog API key +- `POSTHOG_HOST`: PostHog server URL (default: https://app.posthog.com) +- `POSTHOG_ENABLED`: Enable/disable the plugin (default: false) +- `POSTHOG_FLUSH_INTERVAL`: Interval for flushing events in milliseconds (default: 10000) + +## Building + +Run `yarn nx build plugin-posthog` to build the library. + +## Running unit tests + +Run `yarn nx test plugin-posthog` to execute the unit tests via [Jest](https://jestjs.io). + +## Publishing + +After building your library with `yarn nx build plugin-posthog`, go to the dist folder `dist/packages/plugins/posthog` and run `npm publish`. + +## Installation + +Install the PostHog Plugin using your preferred package manager: + +```bash +npm install @gauzy/plugin-posthog +# or +yarn add @gauzy/plugin-posthog +``` diff --git a/packages/plugins/posthog/eslint.config.js b/packages/plugins/posthog/eslint.config.js new file mode 100644 index 00000000000..ba4f5d8daad --- /dev/null +++ b/packages/plugins/posthog/eslint.config.js @@ -0,0 +1,19 @@ +const baseConfig = require('../../../.eslintrc.json'); + +module.exports = [ + ...baseConfig, + { + files: ['**/*.json'], + rules: { + '@nx/dependency-checks': [ + 'error', + { + ignoredFiles: ['{projectRoot}/eslint.config.{js,cjs,mjs}'] + } + ] + }, + languageOptions: { + parser: require('jsonc-eslint-parser') + } + } +]; diff --git a/packages/plugins/posthog/jest.config.ts b/packages/plugins/posthog/jest.config.ts new file mode 100644 index 00000000000..e54f2ecf36b --- /dev/null +++ b/packages/plugins/posthog/jest.config.ts @@ -0,0 +1,10 @@ +export default { + displayName: 'posthog', + preset: '../../../jest.preset.js', + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }] + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../../coverage/packages/plugins/posthog' +}; diff --git a/packages/plugins/posthog/package.json b/packages/plugins/posthog/package.json new file mode 100644 index 00000000000..0d382349df2 --- /dev/null +++ b/packages/plugins/posthog/package.json @@ -0,0 +1,56 @@ +{ + "name": "@gauzy/plugin-posthog", + "version": "0.1.0", + "description": "Ever Gauzy Platform PostHog Plugin", + "author": { + "name": "Ever Co. LTD", + "email": "ever@ever.co", + "url": "https://ever.co" + }, + "license": "AGPL-3.0", + "repository": { + "type": "git", + "url": "https://github.com/ever-co/ever-gauzy", + "directory": "packages/plugins/posthog" + }, + "bugs": { + "url": "https://github.com/ever-co/ever-gauzy/issues" + }, + "homepage": "https://ever.co", + "private": true, + "type": "commonjs", + "main": "./src/index.js", + "typings": "./src/index.d.ts", + "scripts": { + "lib:build": "yarn nx build plugin-posthog", + "lib:build:prod": "yarn nx build plugin-posthog", + "lib:watch": "yarn nx build plugin-posthog --watch" + }, + "dependencies": { + "@gauzy/config": "^0.1.0", + "@gauzy/core": "^0.1.0", + "@gauzy/plugin": "^0.1.0", + "@nestjs/common": "^10.4.15", + "chalk": "^4.1.0", + "posthog-node": "^4.11.3", + "tslib": "^2.6.2" + }, + "devDependencies": { + "@types/jest": "29.5.14", + "@types/node": "^20.14.9", + "typescript": "5.5.4" + }, + "keywords": [ + "gauzy", + "plugin", + "posthog", + "ever", + "platform", + "error-tracking" + ], + "engines": { + "node": ">=20.11.1", + "yarn": ">=1.22.19" + }, + "sideEffects": false +} diff --git a/packages/plugins/posthog/project.json b/packages/plugins/posthog/project.json new file mode 100644 index 00000000000..b833ce7e2c1 --- /dev/null +++ b/packages/plugins/posthog/project.json @@ -0,0 +1,49 @@ +{ + "name": "plugin-posthog", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/plugins/posthog/src", + "projectType": "library", + "release": { + "version": { + "generatorOptions": { + "packageRoot": "dist/{projectRoot}", + "currentVersionResolver": "git-tag" + } + } + }, + "tags": [], + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": [ + "{options.outputPath}" + ], + "options": { + "outputPath": "dist/packages/plugins/posthog", + "tsConfig": "packages/plugins/posthog/tsconfig.lib.json", + "packageJson": "packages/plugins/posthog/package.json", + "main": "packages/plugins/posthog/src/index.ts", + "assets": [ + "packages/plugins/posthog/*.md" + ] + } + }, + "nx-release-publish": { + "options": { + "packageRoot": "dist/{projectRoot}" + } + }, + "lint": { + "executor": "@nx/eslint:lint" + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": [ + "{workspaceRoot}/coverage/{projectRoot}" + ], + "options": { + "jestConfig": "packages/plugins/posthog/jest.config.ts" + } + } + } +} diff --git a/packages/plugins/posthog/src/index.ts b/packages/plugins/posthog/src/index.ts new file mode 100644 index 00000000000..44e9f14d6b5 --- /dev/null +++ b/packages/plugins/posthog/src/index.ts @@ -0,0 +1,5 @@ +/** + * Public API Surface of @gauzy/plugin-posthog + */ +export * from './lib/posthog.plugin'; +export * from './lib/posthog.service'; diff --git a/packages/plugins/posthog/src/lib/posthog-core.module.ts b/packages/plugins/posthog/src/lib/posthog-core.module.ts new file mode 100644 index 00000000000..a0be1f0b502 --- /dev/null +++ b/packages/plugins/posthog/src/lib/posthog-core.module.ts @@ -0,0 +1,87 @@ +import { DynamicModule, Global, Module, Provider, Type } from '@nestjs/common'; +import { PosthogModuleOptions, PosthogModuleAsyncOptions, PosthogOptionsFactory } from './posthog.interfaces'; +import { POSTHOG_MODULE_OPTIONS, POSTHOG_TOKEN } from './posthog.constants'; +import { PosthogService } from './posthog.service'; +import { createPosthogProviders } from './posthog.providers'; + +@Global() +@Module({}) +export class PosthogCoreModule { + /** + * Synchronous registration of the Posthog module + * @param options - Configuration object for Posthog + * @returns A dynamic module with providers and exports + */ + static forRoot(options: PosthogModuleOptions): DynamicModule { + const provider = createPosthogProviders(options); + + return { + module: PosthogCoreModule, + providers: [provider, PosthogService], + exports: [provider, PosthogService] + }; + } + + /** + * Asynchronous registration of the Posthog module + * Supports useFactory, useClass, or useExisting strategies + * @param options - Async module options including factory or class + * @returns A dynamic module with async providers and exports + */ + static forRootAsync(options: PosthogModuleAsyncOptions): DynamicModule { + const provider: Provider = { + provide: POSTHOG_TOKEN, + useFactory: (options: PosthogModuleOptions) => new PosthogService(options), + inject: [POSTHOG_MODULE_OPTIONS] + }; + + return { + module: PosthogCoreModule, + imports: options.imports || [], + providers: [...this.createAsyncProviders(options), provider, PosthogService], + exports: [provider, PosthogService] + }; + } + + /** + * Creates async providers based on the chosen async strategy + * @param options - Configuration for async provider setup + * @returns An array of providers to be used in the async module + */ + private static createAsyncProviders(options: PosthogModuleAsyncOptions): Provider[] { + // If a factory function is provided directly + if (options.useFactory) { + return [ + { + provide: POSTHOG_MODULE_OPTIONS, + useFactory: options.useFactory, + inject: options.inject || [] + } + ]; + } + + // Dependency injection array for useClass or useExisting + if (!options.useClass && !options.useExisting) { + throw new Error('Either useClass or useExisting must be provided for PosthogModule async configuration'); + } + const inject = [(options.useClass || options.useExisting) as Type]; + + const providers: Provider[] = [ + { + provide: POSTHOG_MODULE_OPTIONS, + useFactory: async (factory: PosthogOptionsFactory) => await factory.createPosthogOptions(), + inject + } + ]; + + // Register useClass as a provider if defined + if (options.useClass) { + providers.push({ + provide: options.useClass, + useClass: options.useClass + }); + } + + return providers; + } +} diff --git a/packages/plugins/posthog/src/lib/posthog-error.interceptor.ts b/packages/plugins/posthog/src/lib/posthog-error.interceptor.ts new file mode 100644 index 00000000000..8922dffced0 --- /dev/null +++ b/packages/plugins/posthog/src/lib/posthog-error.interceptor.ts @@ -0,0 +1,534 @@ +// Nestjs imports +import { + CallHandler, + ExecutionContext, + HttpException, + Inject, + Injectable, + NestInterceptor, + Optional +} from '@nestjs/common'; +import { ContextType, HttpArgumentsHost, RpcArgumentsHost, WsArgumentsHost } from '@nestjs/common/interfaces'; +// Rxjs imports +import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; + +import { PosthogInterceptorOptions, PosthogInterceptorOptionsFilter } from './posthog.interfaces'; +import { PosthogService } from './posthog.service'; +import { SanitizerUtil } from './utils'; +import { POSTHOG_MODULE_OPTIONS } from './posthog.constants'; + +/** + * Interceptor for capturing and tracking exceptions through PostHog analytics. + * Sanitizes sensitive data before sending to PostHog and provides comprehensive error context. + */ +@Injectable() +export class PosthogErrorInterceptor implements NestInterceptor { + /** + * PostHog service instance for tracking events + */ + protected readonly client: PosthogService = PosthogService.PosthogServiceInstance(); + private readonly options?: PosthogInterceptorOptions; + + /** + * @param options - Configuration options for the interceptor + */ + constructor(@Optional() @Inject(POSTHOG_MODULE_OPTIONS) options?: PosthogInterceptorOptions) { + this.options = options; + } + + /** + * Intercepts requests and captures exceptions when they occur + * + * @param context - Execution context of the current request + * @param next - Call handler for the request pipeline + * @returns Observable of the response stream + */ + intercept(context: ExecutionContext, next: CallHandler): Observable { + const requestStartTime = Date.now(); + + return next.handle().pipe( + tap(null, (exception: any) => { + // Calculate error timing metrics + const errorTiming = { + timestamp: new Date().toISOString(), + duration_ms: Date.now() - requestStartTime + }; + + // Report to PostHog if it passes the filter + if (this.shouldReport(exception)) { + this.captureException(context, exception, errorTiming); + } + }) + ); + } + + /** + * Routes exception capture based on context type (HTTP, RPC, WebSocket) + * + * @param context - Execution context of the current request + * @param exception - The thrown exception to be captured + * @param errorTiming - Timing information about when the error occurred + */ + protected captureException( + context: ExecutionContext, + exception: any, + errorTiming: { timestamp: string; duration_ms: number } + ) { + switch (context.getType()) { + case 'http': + return this.captureHttpException(context.switchToHttp(), exception, errorTiming); + case 'rpc': + return this.captureRpcException(context.switchToRpc(), exception, errorTiming); + case 'ws': + return this.captureWsException(context.switchToWs(), exception, errorTiming); + default: + // Fallback for unknown context types + this.captureGenericException(context, exception, errorTiming); + } + } + + /** + * Captures HTTP exceptions with comprehensive request context + * + * @param http - HTTP arguments host containing request data + * @param exception - The thrown HTTP exception + * @param errorTiming - Timing information about when the error occurred + */ + private captureHttpException( + http: HttpArgumentsHost, + exception: HttpException, + errorTiming: { timestamp: string; duration_ms: number } + ): void { + const request = http.getRequest(); + const response = http.getResponse(); + + // Extract user identifier with fallbacks + const userId = this.extractUserIdentifier(request); + + // Extract status code with error-aware fallback + const statusCode = this.getStatusCode(exception); + + // Extract route information + const route = this.extractRouteInfo(request); + + // Create a data object with request details + const data = { + request: { + method: request.method, + url: request.originalUrl || request.url, + path: request.path, + route: route, + headers: SanitizerUtil.sanitizeHeaders(request.headers), + query: SanitizerUtil.sanitizeObject(request.query), + body: SanitizerUtil.sanitizeObject(request.body), + params: SanitizerUtil.sanitizeObject(request.params) + }, + user: { + ip: request.ip, + id: request.user?.id || request.user?.userId, + email: request.user?.email, + roles: request.user?.roles, + agent: request.headers['user-agent'] + }, + response: { + status_code: statusCode, + headers: response?.getHeaders ? SanitizerUtil.sanitizeHeaders(response.getHeaders()) : undefined + }, + error: { + message: exception.message, + name: exception.name, + stack: this.sanitizeStack(exception.stack), + status: statusCode, + response: SanitizerUtil.sanitizeObject(exception.getResponse?.() || null), + cause: exception.cause ? String(exception.cause) : undefined + }, + context: { + handler: this.getHandlerName(http.getRequest()?.__routeInfo), + controller: this.getControllerName(http.getRequest()?.__routeInfo) + }, + environment: { + nodeEnv: process.env['NODE_ENV'], + appVersion: process.env['APP_VERSION'], + serviceName: process.env['SERVICE_NAME'] || 'api', + timestamp: errorTiming.timestamp, + duration_ms: errorTiming.duration_ms + } + }; + + // Track the error with improved data structure + this.client.captureException(exception, userId, { + error_message: exception.message, + error_name: exception.name, + stack: this.sanitizeStack(exception.stack), + status_code: data.response.status_code, + request_data: data.request, + user_data: data.user, + response_data: data.response, + error_data: data.error, + context_data: data.context, + environment_data: data.environment, + $timestamp: errorTiming.timestamp, + duration_ms: errorTiming.duration_ms, + http_method: request.method + }); + + // Add tags for better error categorization in PostHog + if (this.options?.includeTags !== false) { + this.addErrorTags(userId, { + error_type: exception.name, + status_code: String(statusCode), + http_method: request.method, + path: route || request.path, + environment: process.env['NODE_ENV'] || 'development' + }); + } + } + + /** + * Captures RPC exceptions with message context and improved data + * + * @param rpc - RPC arguments host + * @param exception - The thrown exception + * @param errorTiming - Timing information about when the error occurred + */ + private captureRpcException( + rpc: RpcArgumentsHost, + exception: any, + errorTiming: { timestamp: string; duration_ms: number } + ): void { + const data = rpc.getData(); + const ctx = rpc.getContext(); + + // Try to extract pattern information + const pattern = typeof ctx.getPattern === 'function' ? ctx.getPattern() : 'unknown-pattern'; + + // Try to extract client ID or fallback to system + const clientId = this.extractRpcClientId(ctx) || 'system'; + + // Create detailed error context + const errorContext = { + rpc_data: SanitizerUtil.sanitizeObject(data), + rpc_context: SanitizerUtil.sanitizeObject(ctx), + rpc_pattern: pattern, + error: { + message: exception.message, + name: exception.name, + stack: this.sanitizeStack(exception.stack), + cause: exception.cause ? String(exception.cause) : undefined + }, + environment: { + nodeEnv: process.env['NODE_ENV'], + appVersion: process.env['APP_VERSION'], + serviceName: process.env['SERVICE_NAME'] || 'api', + timestamp: errorTiming.timestamp, + duration_ms: errorTiming.duration_ms + } + }; + + this.client.captureException(exception, clientId, { + error_message: exception.message, + error_name: exception.name, + stack: this.sanitizeStack(exception.stack), + rpc_data: errorContext.rpc_data, + rpc_pattern: errorContext.rpc_pattern, + rpc_context: errorContext.rpc_context, + error_data: errorContext.error, + environment_data: errorContext.environment, + $timestamp: errorTiming.timestamp, + duration_ms: errorTiming.duration_ms + }); + + // Add tags for better error categorization + if (this.options?.includeTags !== false) { + this.addErrorTags(clientId, { + error_type: exception.name, + pattern: pattern, + environment: process.env['NODE_ENV'] || 'development', + message_type: 'rpc' + }); + } + } + + /** + * Captures WebSocket exceptions with client context and improved data + * + * @param ws - WebSocket arguments host + * @param exception - The thrown exception + * @param errorTiming - Timing information about when the error occurred + */ + private captureWsException( + ws: WsArgumentsHost, + exception: any, + errorTiming: { timestamp: string; duration_ms: number } + ): void { + const client = ws.getClient(); + const data = ws.getData(); + + // Extract client ID with fallbacks + const clientId = client.id || this.extractWsClientId(client) || 'websocket-client'; + + // Try to extract event type + const eventType = typeof data === 'object' && data.event ? data.event : 'unknown-event'; + + // Create detailed error context + const errorContext = { + ws_client: { + id: client.id, + handshake: SanitizerUtil.sanitizeObject(client.handshake) + }, + ws_data: SanitizerUtil.sanitizeObject(data), + ws_event: eventType, + error: { + message: exception.message, + name: exception.name, + stack: this.sanitizeStack(exception.stack), + cause: exception.cause ? String(exception.cause) : undefined + }, + environment: { + nodeEnv: process.env['NODE_ENV'], + appVersion: process.env['APP_VERSION'], + serviceName: process.env['SERVICE_NAME'] || 'api', + timestamp: errorTiming.timestamp, + duration_ms: errorTiming.duration_ms + } + }; + + this.client.captureException(exception, clientId, { + error_message: exception.message, + error_name: exception.name, + stack: this.sanitizeStack(exception.stack), + ws_client: errorContext.ws_client, + ws_data: errorContext.ws_data, + ws_event: errorContext.ws_event, + error_data: errorContext.error, + environment_data: errorContext.environment, + $timestamp: errorTiming.timestamp, + duration_ms: errorTiming.duration_ms + }); + + // Add tags for better error categorization + if (this.options?.includeTags !== false) { + this.addErrorTags(clientId, { + error_type: exception.name, + event_type: eventType, + environment: process.env['NODE_ENV'] || 'development', + message_type: 'websocket' + }); + } + } + + /** + * Captures exceptions from unknown context types + * + * @param context - Execution context + * @param exception - The thrown exception + * @param errorTiming - Timing information about when the error occurred + */ + private captureGenericException( + context: ExecutionContext, + exception: any, + errorTiming: { timestamp: string; duration_ms: number } + ): void { + // Use a generic system identifier + const contextId = 'system'; + + const errorContext = { + context_type: context.getType(), + handler: this.getHandlerName(context), + error: { + message: exception.message, + name: exception.name, + stack: this.sanitizeStack(exception.stack), + cause: exception.cause ? String(exception.cause) : undefined + }, + environment: { + nodeEnv: process.env['NODE_ENV'], + appVersion: process.env['APP_VERSION'], + serviceName: process.env['SERVICE_NAME'] || 'api', + timestamp: errorTiming.timestamp, + duration_ms: errorTiming.duration_ms + } + }; + + this.client.captureException(exception, contextId, { + error_message: exception.message, + error_name: exception.name, + stack: this.sanitizeStack(exception.stack), + context_data: { + type: errorContext.context_type, + handler: errorContext.handler + }, + error_data: errorContext.error, + environment_data: errorContext.environment, + $timestamp: errorTiming.timestamp, + duration_ms: errorTiming.duration_ms + }); + } + + /** + * Adds error tags for better categorization in PostHog + * + * @param distinctId - User or client identifier + * @param tags - Tags to associate with the error + */ + private addErrorTags(distinctId: string, tags: Record): void { + // Add error tags as a separate event for better filtering in PostHog + this.client.track('error_tags', distinctId, tags); + } + + /** + * Determines if an exception should be reported based on configured filters + * + * @param exception - The thrown exception + * @returns Boolean indicating if the exception should be reported + */ + shouldReport(exception: any): boolean { + // If no options or filters are defined, always report + if (!this.options) return true; + + // If any filter passes, then we do not report + const filters: PosthogInterceptorOptionsFilter[] = this.options.filters || []; + return !filters.some(({ type, filter }) => { + return exception instanceof type && (!filter || filter(exception)); + }); + } + + /** + * Extracts a user identifier from various sources in the request + * + * @param request - The HTTP request + * @returns User identifier string + */ + private extractUserIdentifier(request: any): string { + return ( + request.user?.id || + request.user?.userId || + request.headers['x-user-id'] || + request.cookies?.userId || + request.ip || + 'anonymous' + ); + } + + /** + * Extracts a client ID from RPC context + * + * @param ctx - RPC context + * @returns Client ID string or null + */ + private extractRpcClientId(ctx: any): string | null { + return ctx.clientId ?? ctx.args?.[0]?.user?.id ?? null; + } + + /** + * Extracts a client ID from WebSocket client + * + * @param client - WebSocket client + * @returns Client ID string or null + */ + private extractWsClientId(client: any): string | null { + if (!client) return null; + + // Try to extract user ID from handshake data + if (client.handshake?.query?.userId) { + return client.handshake.query.userId; + } + + // Try to extract from auth data + if (client.handshake?.auth?.userId) { + return client.handshake.auth.userId; + } + + return null; + } + + /** + * Gets the handler name from the execution context + * + * @param context - Execution context or route info + * @returns Handler name string + */ + private getHandlerName(context: ExecutionContext | any): string { + if (!context) return 'unknown'; + + // If passed route info directly + if (context.handler) return context.handler; + + // If passed execution context + if (typeof context.getHandler === 'function') { + const handler = context.getHandler(); + return handler.name || 'unknown'; + } + + return 'unknown'; + } + + /** + * Gets the controller name from route info + * + * @param routeInfo - Route information object + * @returns Controller name string + */ + private getControllerName(routeInfo: any): string { + if (!routeInfo || !routeInfo.controller) return 'unknown'; + return routeInfo.controller; + } + + /** + * Extracts route information from a request + * + * @param request - HTTP request object + * @returns Route path string or undefined + */ + private extractRouteInfo(request: any): string | undefined { + // Try to get route from NestJS route information + if (request.route?.path) { + return request.route.path; + } + + // Fallback to path if available + return request.path; + } + + /** + * Gets the status code from an exception with fallback + * + * @param exception - Exception object + * @returns HTTP status code + */ + private getStatusCode(exception: any): number { + // Try to get status from HttpException + if (exception instanceof HttpException) { + return exception.getStatus(); + } + + // Check for custom status property + if (exception.status && typeof exception.status === 'number') { + return exception.status; + } + + // Default to 500 Internal Server Error + return 500; + } + + /** + * Sanitizes stack traces to remove sensitive information + * + * @param stack - Error stack trace + * @returns Sanitized stack trace + */ + private sanitizeStack(stack?: string): string | undefined { + if (!stack) return undefined; + + // Remove absolute file paths, keep only relative paths + return stack + .split('\n') + .map((line) => { + // Remove absolute paths containing user directories + return line.replace(/\((\/Users\/|\/home\/|[A-Z]:\\Users\\)[^)]+/g, '(project/'); + }) + .join('\n') + .slice(0, 2000); // Limit stack trace length + } +} diff --git a/packages/plugins/posthog/src/lib/posthog-event.interceptor.ts b/packages/plugins/posthog/src/lib/posthog-event.interceptor.ts new file mode 100644 index 00000000000..43c98d0abbe --- /dev/null +++ b/packages/plugins/posthog/src/lib/posthog-event.interceptor.ts @@ -0,0 +1,336 @@ +// Nestjs imports +import { CallHandler, ExecutionContext, Inject, Injectable, NestInterceptor, Optional } from '@nestjs/common'; +import { ContextType, HttpArgumentsHost, RpcArgumentsHost, WsArgumentsHost } from '@nestjs/common/interfaces'; +// Rxjs imports +import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; + +import { PosthogEventInterceptorOptions } from './posthog.interfaces'; +import { PosthogService } from './posthog.service'; +import { SanitizerUtil } from './utils'; +import { POSTHOG_MODULE_OPTIONS } from './posthog.constants'; + +/** + * Interceptor for capturing and tracking events through PostHog analytics. + * Automatically tracks route access and performance metrics. + * Sanitizes sensitive data before sending to PostHog. + */ +@Injectable() +export class PosthogEventInterceptor implements NestInterceptor { + /** + * PostHog service instance for tracking events + */ + protected readonly client: PosthogService = PosthogService.PosthogServiceInstance(); + private readonly options?: PosthogEventInterceptorOptions; + + /** + * @param options - Configuration options for the interceptor + */ + constructor(@Optional() @Inject(POSTHOG_MODULE_OPTIONS) options?: PosthogEventInterceptorOptions) { + this.options = options; + } + + /** + * Intercepts requests and tracks events when they occur + * + * @param context - Execution context of the current request + * @param next - Call handler for the request pipeline + * @returns Observable of the response stream + */ + intercept(context: ExecutionContext, next: CallHandler): Observable { + const startTime = Date.now(); + + // Track the request start event + this.captureRequestStart(context); + + return next.handle().pipe( + tap( + // Success handler + (response) => { + const duration = Date.now() - startTime; + this.captureRequestComplete(context, response, duration); + }, + // Error handler - note that errors are already handled by the PosthogErrorInterceptor + // This is just to track that a request completed with an error + (error) => { + const duration = Date.now() - startTime; + this.captureRequestError(context, error, duration); + } + ) + ); + } + + /** + * Routes event capture based on context type (HTTP, RPC, WebSocket) + * + * @param context - Execution context of the current request + * @param eventName - Name of the event to capture + * @param properties - Additional properties to include with the event + */ + protected captureEvent(context: ExecutionContext, eventName: string, properties: Record = {}) { + switch (context.getType()) { + case 'http': + return this.captureHttpEvent(context.switchToHttp(), eventName, properties); + case 'rpc': + return this.captureRpcEvent(context.switchToRpc(), eventName, properties); + case 'ws': + return this.captureWsEvent(context.switchToWs(), eventName, properties); + default: + console.warn(`Unknown context type encountered while capturing event ${eventName}`); + } + } + /** + * Captures HTTP request events with comprehensive request context + * + * @param http - HTTP arguments host containing request data + * @param eventName - Name of the event to track + * @param additionalProperties - Additional event properties + */ + private captureHttpEvent( + http: HttpArgumentsHost, + eventName: string, + additionalProperties: Record = {} + ): void { + const request = http.getRequest(); + const userId = this.extractUserId(request); + + // Skip tracking if this endpoint should be ignored + if (this.shouldIgnoreEndpoint(request.method, request.originalUrl || request.url)) { + return; + } + + const eventProperties = { + request_method: request.method, + request_url: request.originalUrl || request.url, + request_path: request.path, + request_query: SanitizerUtil.sanitizeObject(request.query), + user_agent: request.headers['user-agent'], + referer: request.headers.referer || request.headers.referrer, + ip_address: request.ip, + user_id: request.user?.id || request.user?.userId, + route: this.extractRouteFromRequest(request), + ...additionalProperties, + $timestamp: new Date().toISOString() + }; + + // Track the event + this.client.track(eventName, userId, eventProperties); + } + + /** + * Captures RPC events with message context + * + * @param rpc - RPC arguments host + * @param eventName - Name of the event to track + * @param additionalProperties - Additional event properties + */ + private captureRpcEvent( + rpc: RpcArgumentsHost, + eventName: string, + additionalProperties: Record = {} + ): void { + const ctx = rpc.getContext(); + const pattern = typeof ctx.getPattern === 'function' ? ctx.getPattern() : 'unknown-pattern'; + + const clientId = this.extractRpcClientId(ctx) || 'system'; + + const eventProperties = { + rpc_pattern: pattern, + rpc_context: SanitizerUtil.sanitizeObject(ctx), + ...additionalProperties, + $timestamp: new Date().toISOString() + }; + + this.client.track(eventName, clientId, eventProperties); + } + + /** + * Captures WebSocket events with client context + * + * @param ws - WebSocket arguments host + * @param eventName - Name of the event to track + * @param additionalProperties - Additional event properties + */ + private captureWsEvent( + ws: WsArgumentsHost, + eventName: string, + additionalProperties: Record = {} + ): void { + const client = ws.getClient(); + const data = ws.getData(); + const eventType = typeof data === 'object' && data.event ? data.event : 'unknown-event'; + + const clientId = client.id || this.extractWsClientId(client) || 'websocket-client'; + + const eventProperties = { + ws_event_type: eventType, + ws_client_id: client.id, + ...additionalProperties, + $timestamp: new Date().toISOString() + }; + + this.client.track(eventName, clientId, eventProperties); + } + + /** + * Captures the start of a request processing pipeline + * + * @param context - Execution context of the current request + */ + protected captureRequestStart(context: ExecutionContext): void { + this.captureEvent(context, 'request_started', { + handler: this.getHandlerName(context), + timestamp_start: Date.now() + }); + } + + /** + * Captures the successful completion of a request + * + * @param context - Execution context of the current request + * @param response - The response being sent back + * @param duration - Duration of the request processing in milliseconds + */ + protected captureRequestComplete(context: ExecutionContext, response: any, duration: number): void { + let statusCode = 200; + let responseSize = 0; + + // Try to extract status code and response size based on context type + if (context.getType() === 'http') { + const httpResponse = context.switchToHttp().getResponse(); + statusCode = httpResponse.statusCode; + + // Estimate response size if possible + if (response && typeof response === 'object') { + try { + responseSize = JSON.stringify(response).length; + } catch (e) { + // Ignore serialization errors + } + } + } + + this.captureEvent(context, 'request_completed', { + handler: this.getHandlerName(context), + duration_ms: duration, + status_code: statusCode, + response_size_bytes: responseSize, + timestamp_end: Date.now() + }); + } + + /** + * Captures error events during request processing + * Note: This complements the PosthogErrorInterceptor by tracking timing metrics + * + * @param context - Execution context of the current request + * @param error - The error that occurred + * @param duration - Duration until the error occurred in milliseconds + */ + protected captureRequestError(context: ExecutionContext, error: any, duration: number): void { + let statusCode = error?.getStatus?.() || 500; + + this.captureEvent(context, error?.name || 'request_error', { + handler: this.getHandlerName(context), + duration_ms: duration, + error_type: error?.name || 'Unknown', + status_code: statusCode, + timestamp_error: Date.now() + }); + } + + /** + * Extracts the handler name from the execution context + * + * @param context - Execution context + * @returns The name of the handler being executed + */ + private getHandlerName(context: ExecutionContext): string { + const handler = context.getHandler(); + const controller = context.getClass(); + return `${controller.name}.${handler.name}`; + } + + /** + * Extracts a route from an HTTP request + * + * @param request - HTTP request object + * @returns Route path with parameter placeholders + */ + private extractRouteFromRequest(request: any): string { + // Try to access NestJS route information if available + if (request.route && request.route.path) { + return request.route.path; + } + + // Fallback to the raw URL + return request.originalUrl || request.url; + } + + /** + * Determines if an endpoint should be ignored for tracking + * + * @param method - HTTP method + * @param url - URL of the request + * @returns Boolean indicating if the endpoint should be ignored + */ + private shouldIgnoreEndpoint(method: string, url: string): boolean { + // Skip tracking for health checks and other monitoring endpoints by default + const defaultIgnoredPaths = ['/health', '/metrics', '/favicon.ico', '/ping']; + + // Use user-configured ignored paths if available + const ignoredPaths = this.options?.ignoredPaths || defaultIgnoredPaths; + + return ignoredPaths.some((path: string) => url.startsWith(path)); + } + + /** + * Extracts a user ID from various possible locations in the request + * + * @param request - HTTP request object + * @returns User ID string or 'anonymous' if not found + */ + private extractUserId(request: any): string { + // Check common user ID locations + return ( + request.user?.id || + request.user?.userId || + request.headers['x-user-id'] || + request.cookies?.userId || + request.ip || + 'anonymous' + ); + } + + /** + * Extracts a client ID from RPC context + * + * @param ctx - RPC context + * @returns Client ID string or null if not found + */ + private extractRpcClientId(ctx: any): string | null { + return ctx.clientId || (ctx.args && ctx.args[0] && ctx.args[0].user && ctx.args[0].user.id) || null; + } + + /** + * Extracts a client ID from WebSocket client + * + * @param client - WebSocket client + * @returns Client ID string or null if not found + */ + private extractWsClientId(client: any): string | null { + if (!client) return null; + + // Try to extract user ID from handshake data + if (client.handshake && client.handshake.query && client.handshake.query.userId) { + return client.handshake.query.userId; + } + + // Try to extract from auth data + if (client.handshake && client.handshake.auth && client.handshake.auth.userId) { + return client.handshake.auth.userId; + } + + return null; + } +} diff --git a/packages/plugins/posthog/src/lib/posthog-request.middleware.ts b/packages/plugins/posthog/src/lib/posthog-request.middleware.ts new file mode 100644 index 00000000000..d7353eba1eb --- /dev/null +++ b/packages/plugins/posthog/src/lib/posthog-request.middleware.ts @@ -0,0 +1,34 @@ +import { Injectable, NestMiddleware } from '@nestjs/common'; +import { Request, Response, NextFunction } from 'express'; +import { PosthogService } from './posthog.service'; + +@Injectable() +export class PosthogRequestMiddleware implements NestMiddleware { + constructor(private readonly posthog: PosthogService) {} + + /** + * Intercepts incoming requests to capture basic request information + * Identifies the user/device and sets initial event properties + * @param req - Incoming HTTP request + * @param res - HTTP response + * @param next - Next middleware function + */ + use(req: Request, res: Response, next: NextFunction) { + // Capture essential request properties as user identification + try { + this.posthog.identify(req.ip || 'anonymous', { + $current_url: req.originalUrl, + $referrer: req.get('referer'), + $user_agent: req.get('user-agent'), + $host: req.get('host'), + $pathname: req.path, + ip: req.ip, + http_method: req.method + }); + } catch (error) { + console.debug('PostHog analytics error:', error); + } + + next(); + } +} diff --git a/packages/plugins/posthog/src/lib/posthog-trace.middleware.ts b/packages/plugins/posthog/src/lib/posthog-trace.middleware.ts new file mode 100644 index 00000000000..63c7418c0e9 --- /dev/null +++ b/packages/plugins/posthog/src/lib/posthog-trace.middleware.ts @@ -0,0 +1,33 @@ +import { Injectable, NestMiddleware } from '@nestjs/common'; +import { Request, Response, NextFunction } from 'express'; +import { PosthogService } from './posthog.service'; + +@Injectable() +export class PosthogTraceMiddleware implements NestMiddleware { + constructor(private readonly posthog: PosthogService) {} + + /** + * Tracks request performance metrics and status codes + * Attaches to response finish event to capture final timing + * @param req - Incoming HTTP request + * @param res - HTTP response + * @param next - Next middleware function + */ + use(req: Request, res: Response, next: NextFunction) { + const start = Date.now(); + const { method, path } = req; + + res.on('finish', () => { + const duration = Date.now() - start; + this.posthog.track('http_request', req.ip || 'unknown', { + method, + path, + status_code: res.statusCode, + duration_ms: duration, + $timestamp: new Date(start).toISOString() + }); + }); + + next(); + } +} diff --git a/packages/plugins/posthog/src/lib/posthog.constants.ts b/packages/plugins/posthog/src/lib/posthog.constants.ts new file mode 100644 index 00000000000..edeab8fec4c --- /dev/null +++ b/packages/plugins/posthog/src/lib/posthog.constants.ts @@ -0,0 +1,5 @@ +/** + * Injection token for PosthogModuleOptions. + */ +export const POSTHOG_MODULE_OPTIONS = 'POSTHOG_MODULE_OPTIONS'; +export const POSTHOG_TOKEN = 'POSTHOG_TOKEN'; diff --git a/packages/plugins/posthog/src/lib/posthog.interfaces.ts b/packages/plugins/posthog/src/lib/posthog.interfaces.ts new file mode 100644 index 00000000000..b3359d13e9b --- /dev/null +++ b/packages/plugins/posthog/src/lib/posthog.interfaces.ts @@ -0,0 +1,167 @@ +import { ModuleMetadata, Type } from '@nestjs/common'; + +/** + * Configuration options for the PostHog module + */ +export interface PosthogModuleOptions { + apiKey: string; + + /** PostHog API host URL (defaults to https://app.posthog.com) */ + apiHost?: string; + + /** Whether to enable error tracking (defaults to true) */ + enableErrorTracking?: boolean; + + /** Number of events to queue before sending (defaults to 20) */ + flushAt?: number; + + /** Maximum milliseconds to wait before sending data (defaults to 10000) */ + flushInterval?: number; + + /** Personal API key for PostHog */ + personalApiKey?: string; + + /** Whether to automatically capture clicks and form submissions (defaults to false) */ + autocapture?: boolean; + + /** Whether to use a mock client for testing (defaults to false) */ + mock?: boolean; +} + +export interface PosthogModuleAsyncOptions extends Pick { + useFactory?: (...args: unknown[]) => Promise | PosthogModuleOptions; + inject?: (string | symbol | Type)[]; + useClass?: Type; + useExisting?: Type; + isGlobal?: boolean; +} + +export interface PosthogOptionsFactory { + createPosthogOptions(): Promise | PosthogModuleOptions; +} + +// Common PostHog property types +export interface PosthogEventProperties { + // Standard PostHog properties (prefixed with $) + $ip?: string; + $timestamp?: string; + $set?: Record; + $set_once?: Record; + // Custom properties + [key: string]: any; +} + +export interface PosthogEvent { + name: string; + distinctId: string; + properties: PosthogEventProperties; +} + +export interface PosthogInterceptorOptions { + /** + * Filters to determine which exceptions should not be reported + */ + filters?: PosthogInterceptorOptionsFilter[]; + + /** + * Indicates if tags should be included for better categorization + * @default true + */ + includeTags?: boolean; + + /** + * Limits the size of captured objects (in characters) + * @default 10000 + */ + maxObjectSize?: number; + + /** + * List of header keys to never capture (for privacy reasons) + */ + sensitiveHeaders?: string[]; + + /** + * Detail level for error capturing + * - basic: error message and status code only + * - standard: basic details plus request context + * - detailed: all available information + * @default 'standard' + */ + detailLevel?: 'basic' | 'standard' | 'detailed'; +} + +export interface PosthogInterceptorOptionsFilter { + type: any; + filter?: (exception: any) => boolean; +} + +/** + * Custom properties that can be added to all events + */ +export interface PosthogCustomProperties { + // Add some common properties your application uses + environment?: string; + version?: string; + userRole?: string; + // Allow additional custom properties + [key: string]: any; +} + +/** + * Configuration options for the PostHogEventInterceptor + */ +export interface PosthogEventInterceptorOptions { + /** + * Paths that should be ignored for event tracking + */ + ignoredPaths?: string[]; + + /** + * Whether to track request performance metrics + */ + trackPerformance?: boolean; + + /** + * Whether to track user information when available + */ + trackUserInfo?: boolean; + + /** + * Custom properties to add to all events + */ + customProperties?: PosthogCustomProperties; +} + +/** + * Parses and validates PostHog options + * @param options Raw PostHog options + * @returns Normalized PostHog options + */ +export const parsePosthogOptions = (options: PosthogModuleOptions): PosthogModuleOptions => { + // Validate required options + if (!options.apiKey) { + throw new Error('PostHog API key is required'); + } + + // Validate numeric values + const flushAt = options.flushAt ?? 20; + if (flushAt <= 0) { + throw new Error('flushAt must be a positive number'); + } + + const flushInterval = options.flushInterval ?? 10000; + if (flushInterval <= 0) { + throw new Error('flushInterval must be a positive number'); + } + + return { + apiKey: options.apiKey, + apiHost: options.apiHost || 'https://app.posthog.com', + enableErrorTracking: options.enableErrorTracking ?? true, + flushAt, + flushInterval, + personalApiKey: options.personalApiKey, + autocapture: options.autocapture ?? false, + mock: options.mock ?? false + }; +}; diff --git a/packages/plugins/posthog/src/lib/posthog.module.ts b/packages/plugins/posthog/src/lib/posthog.module.ts new file mode 100644 index 00000000000..e548df22e47 --- /dev/null +++ b/packages/plugins/posthog/src/lib/posthog.module.ts @@ -0,0 +1,29 @@ +import { Module, DynamicModule } from '@nestjs/common'; +import { PosthogCoreModule } from './posthog-core.module'; +import { PosthogModuleOptions, PosthogModuleAsyncOptions } from './posthog.interfaces'; + +/** + * Entry module for PostHog integration. + */ +@Module({}) +export class PosthogModule { + /** + * Static method to create a dynamic module with synchronous configuration. + */ + public static forRoot(options: PosthogModuleOptions): DynamicModule { + return { + module: PosthogModule, + imports: [PosthogCoreModule.forRoot(options)] + }; + } + + /** + * Static method to create a dynamic module with asynchronous configuration. + */ + public static forRootAsync(options: PosthogModuleAsyncOptions): DynamicModule { + return { + module: PosthogModule, + imports: [PosthogCoreModule.forRootAsync(options)] + }; + } +} diff --git a/packages/plugins/posthog/src/lib/posthog.plugin.ts b/packages/plugins/posthog/src/lib/posthog.plugin.ts new file mode 100644 index 00000000000..546a27013a9 --- /dev/null +++ b/packages/plugins/posthog/src/lib/posthog.plugin.ts @@ -0,0 +1,84 @@ +import { APP_INTERCEPTOR } from '@nestjs/core'; +import { MiddlewareConsumer, NestModule } from '@nestjs/common'; +import { GauzyCorePlugin as Plugin, IOnPluginBootstrap, IOnPluginDestroy } from '@gauzy/plugin'; +import { PosthogModule } from './posthog.module'; +import { parsePosthogOptions, PosthogModuleOptions } from './posthog.interfaces'; +import { PosthogEventInterceptor } from './posthog-event.interceptor'; +import { PosthogErrorInterceptor } from './posthog-error.interceptor'; +import { PosthogRequestMiddleware } from './posthog-request.middleware'; +import { PosthogTraceMiddleware } from './posthog-trace.middleware'; +import { POSTHOG_MODULE_OPTIONS } from './posthog.constants'; + +@Plugin({ + imports: [ + PosthogModule.forRootAsync({ + useFactory: () => parsePosthogOptions(PosthogPlugin.options), + inject: [] + }) + ], + providers: [ + { + provide: POSTHOG_MODULE_OPTIONS, + useValue: PosthogPlugin.options + }, + { + provide: APP_INTERCEPTOR, + useClass: PosthogErrorInterceptor + }, + + { + provide: APP_INTERCEPTOR, + useClass: PosthogEventInterceptor + } + ] +}) +export class PosthogPlugin implements NestModule, IOnPluginBootstrap, IOnPluginDestroy { + private logEnabled = true; + static options: PosthogModuleOptions; + + /** + * Configures PostHog middlewares for all routes + * @param consumer The middleware consumer + */ + configure(consumer: MiddlewareConsumer) { + if (this.shouldEnableTracking()) { + consumer.apply(PosthogRequestMiddleware).forRoutes('*').apply(PosthogTraceMiddleware).forRoutes('*'); + } + } + + /** + * Called when plugin is initialized + */ + onPluginBootstrap(): void { + if (this.logEnabled) { + console.log('🚀 PostHog plugin initialized'); + } + } + + /** + * Called when plugin is destroyed + */ + onPluginDestroy(): void { + if (this.logEnabled) { + console.log('🛑 PostHog plugin destroyed'); + } + } + + /** + * Initialize plugin with options + * @param options PostHog configuration options + * @returns The plugin instance + */ + static init(options: PosthogModuleOptions): typeof PosthogPlugin { + this.options = parsePosthogOptions(options); + return this; + } + + /** + * Determines if tracking should be enabled + * @returns boolean indicating if tracking is enabled + */ + private shouldEnableTracking(): boolean { + return !PosthogPlugin.options.mock && !!PosthogPlugin.options.apiKey; + } +} diff --git a/packages/plugins/posthog/src/lib/posthog.providers.ts b/packages/plugins/posthog/src/lib/posthog.providers.ts new file mode 100644 index 00000000000..82ff00b2639 --- /dev/null +++ b/packages/plugins/posthog/src/lib/posthog.providers.ts @@ -0,0 +1,10 @@ +import { PosthogModuleOptions } from './posthog.interfaces'; +import { POSTHOG_TOKEN } from './posthog.constants'; +import { PosthogService } from './posthog.service'; + +export function createPosthogProviders(options: PosthogModuleOptions) { + return { + provide: POSTHOG_TOKEN, + useFactory: () => new PosthogService(options) + }; +} diff --git a/packages/plugins/posthog/src/lib/posthog.service.ts b/packages/plugins/posthog/src/lib/posthog.service.ts new file mode 100644 index 00000000000..0a69ae455f8 --- /dev/null +++ b/packages/plugins/posthog/src/lib/posthog.service.ts @@ -0,0 +1,143 @@ +import { Inject, Injectable, Logger, OnModuleDestroy } from '@nestjs/common'; +import { PosthogModuleOptions } from './posthog.interfaces'; +import { POSTHOG_MODULE_OPTIONS } from './posthog.constants'; +import { PostHog } from 'posthog-node'; + +/** + * Service that provides an interface to the PostHog analytics platform. + * Follows the singleton pattern to ensure only one instance exists. + * Implements OnModuleDestroy for proper cleanup when the module is destroyed. + */ +@Injectable() +export class PosthogService implements OnModuleDestroy { + private readonly logger = new Logger(PosthogService.name); + private client: PostHog | null = null; + private static instance: PosthogService; + + /** + * Creates a new PosthogService instance. + * Initializes the singleton instance if it doesn't already exist. + * + * @param options - Configuration options for the PostHog client + */ + constructor( + @Inject(POSTHOG_MODULE_OPTIONS) + private readonly options: PosthogModuleOptions + ) { + // Initialize the static instance if it doesn't exist + if (!PosthogService.instance) { + PosthogService.instance = this; + this.initClient(); + } + } + + /** + * Gets the singleton instance of the PosthogService. + * + * @returns The singleton instance of PosthogService + */ + public static PosthogServiceInstance(): PosthogService { + if (!PosthogService.instance) { + throw new Error('PosthogService instance not initialized'); + } + return PosthogService.instance; + } + + /** + * Gets the underlying PostHog client instance. + * + * @returns The PostHog client instance or null if not initialized + */ + public instance(): PostHog | null { + return this.client; + } + + /** + * Initializes the PostHog client with the provided configuration. + * If the API key is missing, analytics will be disabled. + */ + private initClient() { + if (!this.options.apiKey) { + this.logger.warn('PostHog API key is missing. Analytics will be disabled.'); + return; + } + + this.client = new PostHog(this.options.apiKey, { + host: this.options.apiHost || 'https://app.posthog.com', + enableExceptionAutocapture: this.options.enableErrorTracking, + flushAt: this.options.flushAt || 20, + flushInterval: this.options.flushInterval || 10000, + personalApiKey: this.options.personalApiKey + }); + + this.logger.log('PostHog client initialized'); + } + + /** + * Tracks a custom event for a specific user. + * + * @param event - The name of the event to track + * @param distinctId - The unique identifier for the user + * @param properties - Optional additional properties to associate with the event + */ + public track(event: string, distinctId: string, properties?: Record) { + if (!this.client) return; + this.client.capture({ + event, + distinctId, + properties + }); + } + + /** + * Captures an exception or error event for a specific user. + * Uses PostHog's captureException method to track errors. + * + * @param exception - The error or exception to capture + * @param distinctId - The unique identifier for the user + * @param properties - Optional additional properties describing the error + */ + public captureException(exception: any, distinctId: string, properties?: Record) { + if (!this.client) return; + this.client.captureException({ + exception, + distinctId, + properties + }); + } + + /** + * Identifies a user with additional traits or properties. + * + * @param distinctId - The unique identifier for the user + * @param properties - Optional user traits or properties to associate with this user + */ + public identify(distinctId: string, properties?: Record) { + if (!this.client) return; + this.client.identify({ + distinctId, + properties + }); + } + + /** + * Gracefully shuts down the PostHog client, ensuring all queued events are sent. + * + * @returns Promise that resolves when shutdown is complete + */ + public async shutdown(): Promise { + if (!this.client) return; + await this.client.shutdown(); + this.logger.log('PostHog client shutdown complete'); + } + + /** + * Lifecycle hook called when the NestJS module is being destroyed. + * Automatically calls the shutdown method to ensure proper cleanup. + * + * @returns Promise that resolves when shutdown is complete + */ + async onModuleDestroy() { + await this.shutdown(); + } +} diff --git a/packages/plugins/posthog/src/lib/posthog.type.ts b/packages/plugins/posthog/src/lib/posthog.type.ts new file mode 100644 index 00000000000..cf7c571dbcb --- /dev/null +++ b/packages/plugins/posthog/src/lib/posthog.type.ts @@ -0,0 +1,3 @@ +import { PosthogModuleOptions } from './posthog.interfaces'; + +export interface PosthogPluginOptions extends PosthogModuleOptions {} diff --git a/packages/plugins/posthog/src/lib/utils/index.ts b/packages/plugins/posthog/src/lib/utils/index.ts new file mode 100644 index 00000000000..74d0ab002b7 --- /dev/null +++ b/packages/plugins/posthog/src/lib/utils/index.ts @@ -0,0 +1 @@ +export * from './sanitizer.util'; diff --git a/packages/plugins/posthog/src/lib/utils/sanitizer.util.ts b/packages/plugins/posthog/src/lib/utils/sanitizer.util.ts new file mode 100644 index 00000000000..90dddd0eb40 --- /dev/null +++ b/packages/plugins/posthog/src/lib/utils/sanitizer.util.ts @@ -0,0 +1,33 @@ +export class SanitizerUtil { + private static readonly sensitiveFields = ['password', 'token', 'secret', 'credit_card', 'ssn']; + + private static readonly sensitiveHeaders = ['authorization', 'cookie', 'api-key']; + + static sanitizeHeaders(headers: Record): Record { + const sanitized = { ...headers }; + for (const header of this.sensitiveHeaders) { + const headerLower = header.toLowerCase(); + for (const key in sanitized) { + if (key.toLowerCase() === headerLower) { + sanitized[key] = '[REDACTED]'; + } + } + } + return sanitized; + } + + static sanitizeObject(obj: any): any { + if (!obj || typeof obj !== 'object') return obj; + if (Array.isArray(obj)) return obj.map((item) => this.sanitizeObject(item)); + + const result = { ...obj }; + for (const key in result) { + if (this.sensitiveFields.includes(key.toLowerCase())) { + result[key] = '[REDACTED]'; + } else if (typeof result[key] === 'object') { + result[key] = this.sanitizeObject(result[key]); + } + } + return result; + } +} diff --git a/packages/plugins/posthog/tsconfig.json b/packages/plugins/posthog/tsconfig.json new file mode 100644 index 00000000000..fe6559e33fb --- /dev/null +++ b/packages/plugins/posthog/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noPropertyAccessFromIndexSignature": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/packages/plugins/posthog/tsconfig.lib.json b/packages/plugins/posthog/tsconfig.lib.json new file mode 100644 index 00000000000..33f7db4f545 --- /dev/null +++ b/packages/plugins/posthog/tsconfig.lib.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "declaration": true, + "types": ["node"], + "target": "es2021", + "strictNullChecks": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/packages/plugins/posthog/tsconfig.spec.json b/packages/plugins/posthog/tsconfig.spec.json new file mode 100644 index 00000000000..930a5bcfbbc --- /dev/null +++ b/packages/plugins/posthog/tsconfig.spec.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"] +} diff --git a/tsconfig.json b/tsconfig.json index 6392d366058..413378b7c8b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -46,6 +46,7 @@ "@gauzy/plugin-public-layout-ui": ["./packages/plugins/public-layout-ui/src/index.ts"], "@gauzy/plugin-registry": ["./packages/plugins/registry/src/index.ts"], "@gauzy/plugin-sentry": ["./packages/plugins/sentry-tracing/src/index.ts"], + "@gauzy/plugin-posthog": ["./packages/plugins/posthog/src/index.ts"], "@gauzy/plugin-videos": ["./packages/plugins/videos/src/index.ts"], "@gauzy/plugin-videos-ui": ["./packages/plugins/videos-ui/src/index.ts"], "@gauzy/ui-auth": ["./packages/ui-auth/src/index.ts"], diff --git a/yarn.lock b/yarn.lock index 2c855c09eb6..94b21002b8e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -32328,6 +32328,13 @@ postgres-range@^1.1.1: resolved "https://registry.yarnpkg.com/postgres-range/-/postgres-range-1.1.4.tgz#a59c5f9520909bcec5e63e8cf913a92e4c952863" integrity sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w== +posthog-node@^4.11.3: + version "4.11.3" + resolved "https://registry.yarnpkg.com/posthog-node/-/posthog-node-4.11.3.tgz#b01c3b32b524bfdb1c1e2aef256b36933e6b91c0" + integrity sha512-Ye7d9ZJX1reWP9084Gm9+O7NnvlW7LnbU09GzlsSdD0HzGTfxeKTQZsl9h1+CDHhfvpdWJRm8uc91/aDmXzaCA== + dependencies: + axios "^1.8.2" + preact@~10.12.1: version "10.12.1" resolved "https://registry.yarnpkg.com/preact/-/preact-10.12.1.tgz#8f9cb5442f560e532729b7d23d42fd1161354a21"