Browse Source

feat: SH instance analytics data collection (#3838)

Balu Babu 1 year ago
parent
commit
cf039c482a

+ 3 - 0
packages/hoppscotch-backend/package.json

@@ -34,12 +34,14 @@
     "@nestjs/jwt": "^10.1.1",
     "@nestjs/passport": "^10.0.2",
     "@nestjs/platform-express": "^10.2.6",
+    "@nestjs/schedule": "^4.0.1",
     "@nestjs/throttler": "^5.0.0",
     "@prisma/client": "^5.8.0",
     "argon2": "^0.30.3",
     "bcrypt": "^5.1.0",
     "cookie": "^0.5.0",
     "cookie-parser": "^1.4.6",
+    "cron": "^3.1.6",
     "express": "^4.17.1",
     "express-session": "^1.17.3",
     "fp-ts": "^2.13.1",
@@ -57,6 +59,7 @@
     "passport-jwt": "^4.0.1",
     "passport-local": "^1.0.0",
     "passport-microsoft": "^1.0.0",
+    "posthog-node": "^3.6.3",
     "prisma": "^5.8.0",
     "reflect-metadata": "^0.1.13",
     "rimraf": "^3.0.2",

+ 20 - 0
packages/hoppscotch-backend/src/admin/infra.resolver.ts

@@ -33,6 +33,7 @@ import {
   InfraConfigArgs,
 } from 'src/infra-config/input-args';
 import { InfraConfigEnumForClient } from 'src/types/InfraConfig';
+import { ServiceStatus } from 'src/infra-config/helper';
 
 @UseGuards(GqlThrottlerGuard)
 @Resolver(() => Infra)
@@ -310,6 +311,25 @@ export class InfraResolver {
     return updatedRes.right;
   }
 
+  @Mutation(() => Boolean, {
+    description: 'Enable or disable analytics collection',
+  })
+  @UseGuards(GqlAuthGuard, GqlAdminGuard)
+  async toggleAnalyticsCollection(
+    @Args({
+      name: 'status',
+      type: () => ServiceStatus,
+      description: 'Toggle analytics collection',
+    })
+    analyticsCollectionStatus: ServiceStatus,
+  ) {
+    const res = await this.infraConfigService.toggleAnalyticsCollection(
+      analyticsCollectionStatus,
+    );
+    if (E.isLeft(res)) throwErr(res.left);
+    return res.right;
+  }
+
   @Mutation(() => Boolean, {
     description: 'Reset Infra Configs with default values (.env)',
   })

+ 4 - 0
packages/hoppscotch-backend/src/app.module.ts

@@ -24,6 +24,8 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
 import { InfraConfigModule } from './infra-config/infra-config.module';
 import { loadInfraConfiguration } from './infra-config/helper';
 import { MailerModule } from './mailer/mailer.module';
+import { PosthogModule } from './posthog/posthog.module';
+import { ScheduleModule } from '@nestjs/schedule';
 
 @Module({
   imports: [
@@ -96,6 +98,8 @@ import { MailerModule } from './mailer/mailer.module';
     UserCollectionModule,
     ShortcodeModule,
     InfraConfigModule,
+    PosthogModule,
+    ScheduleModule.forRoot(),
   ],
   providers: [GQLComplexityPlugin],
   controllers: [AppController],

+ 6 - 0
packages/hoppscotch-backend/src/errors.ts

@@ -711,3 +711,9 @@ export const INFRA_CONFIG_SERVICE_NOT_CONFIGURED =
  */
 export const DATABASE_TABLE_NOT_EXIST =
   'Database migration not found. Please check the documentation for assistance: https://docs.hoppscotch.io/documentation/self-host/community-edition/install-and-build#running-migrations';
+
+/**
+ * PostHog client is not initialized
+ * (InfraConfigService)
+ */
+export const POSTHOG_CLIENT_NOT_INITIALIZED = 'posthog/client_not_initialized';

+ 10 - 0
packages/hoppscotch-backend/src/infra-config/helper.ts

@@ -3,6 +3,7 @@ import { AUTH_PROVIDER_NOT_CONFIGURED } from 'src/errors';
 import { PrismaService } from 'src/prisma/prisma.service';
 import { InfraConfigEnum } from 'src/types/InfraConfig';
 import { throwErr } from 'src/utils';
+import { randomBytes } from 'crypto';
 
 export enum ServiceStatus {
   ENABLE = 'ENABLE',
@@ -104,3 +105,12 @@ export function getConfiguredSSOProviders() {
 
   return configuredAuthProviders.join(',');
 }
+
+/**
+ * Generate a hashed valued for analytics
+ * @returns Generated hashed value
+ */
+export function generateAnalyticsUserId() {
+  const hashedUserID = randomBytes(20).toString('hex');
+  return hashedUserID;
+}

+ 30 - 1
packages/hoppscotch-backend/src/infra-config/infra-config.service.ts

@@ -19,7 +19,12 @@ import {
 } from 'src/errors';
 import { throwErr, validateSMTPEmail, validateSMTPUrl } from 'src/utils';
 import { ConfigService } from '@nestjs/config';
-import { ServiceStatus, getConfiguredSSOProviders, stopApp } from './helper';
+import {
+  ServiceStatus,
+  generateAnalyticsUserId,
+  getConfiguredSSOProviders,
+  stopApp,
+} from './helper';
 import { EnableAndDisableSSOArgs, InfraConfigArgs } from './input-args';
 import { AuthProvider } from 'src/auth/helper';
 
@@ -75,6 +80,14 @@ export class InfraConfigService implements OnModuleInit {
         name: InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS,
         value: getConfiguredSSOProviders(),
       },
+      {
+        name: InfraConfigEnum.ALLOW_ANALYTICS_COLLECTION,
+        value: false.toString(),
+      },
+      {
+        name: InfraConfigEnum.ANALYTICS_USER_ID,
+        value: generateAnalyticsUserId(),
+      },
       {
         name: InfraConfigEnum.IS_FIRST_TIME_INFRA_SETUP,
         value: (await this.prisma.infraConfig.count()) === 0 ? 'true' : 'false',
@@ -231,6 +244,22 @@ export class InfraConfigService implements OnModuleInit {
     }
   }
 
+  /**
+   * Enable or Disable Analytics Collection
+   *
+   * @param status Status to enable or disable
+   * @returns Boolean of status of analytics collection
+   */
+  async toggleAnalyticsCollection(status: ServiceStatus) {
+    const isUpdated = await this.update(
+      InfraConfigEnum.ALLOW_ANALYTICS_COLLECTION,
+      status === ServiceStatus.ENABLE ? 'true' : 'false',
+    );
+
+    if (E.isLeft(isUpdated)) return E.left(isUpdated.left);
+    return E.right(isUpdated.right.value === 'true');
+  }
+
   /**
    * Enable or Disable SSO for login/signup
    * @param provider Auth Provider to enable or disable

+ 9 - 0
packages/hoppscotch-backend/src/posthog/posthog.module.ts

@@ -0,0 +1,9 @@
+import { Module } from '@nestjs/common';
+import { PosthogService } from './posthog.service';
+import { PrismaModule } from 'src/prisma/prisma.module';
+
+@Module({
+  imports: [PrismaModule],
+  providers: [PosthogService],
+})
+export class PosthogModule {}

+ 58 - 0
packages/hoppscotch-backend/src/posthog/posthog.service.ts

@@ -0,0 +1,58 @@
+import { Injectable } from '@nestjs/common';
+import { PostHog } from 'posthog-node';
+import { Cron, CronExpression, SchedulerRegistry } from '@nestjs/schedule';
+import { ConfigService } from '@nestjs/config';
+import { PrismaService } from 'src/prisma/prisma.service';
+import { CronJob } from 'cron';
+import { POSTHOG_CLIENT_NOT_INITIALIZED } from 'src/errors';
+import { throwErr } from 'src/utils';
+@Injectable()
+export class PosthogService {
+  private postHogClient: PostHog;
+  private POSTHOG_API_KEY = 'phc_9CipPajQC22mSkk2wxe2TXsUA0Ysyupe8dt5KQQELqx';
+
+  constructor(
+    private readonly configService: ConfigService,
+    private readonly prismaService: PrismaService,
+    private schedulerRegistry: SchedulerRegistry,
+  ) {}
+
+  async onModuleInit() {
+    if (this.configService.get('INFRA.ALLOW_ANALYTICS_COLLECTION') === 'true') {
+      console.log('Initializing PostHog');
+      this.postHogClient = new PostHog(this.POSTHOG_API_KEY, {
+        host: 'https://eu.posthog.com',
+      });
+
+      // Schedule the cron job only if analytics collection is allowed
+      this.scheduleCronJob();
+    }
+  }
+
+  private scheduleCronJob() {
+    const job = new CronJob(CronExpression.EVERY_WEEK, async () => {
+      await this.capture();
+    });
+
+    this.schedulerRegistry.addCronJob('captureAnalytics', job);
+    job.start();
+  }
+
+  async capture() {
+    if (!this.postHogClient) {
+      throwErr(POSTHOG_CLIENT_NOT_INITIALIZED);
+    }
+
+    this.postHogClient.capture({
+      distinctId: this.configService.get('INFRA.ANALYTICS_USER_ID'),
+      event: 'sh_instance',
+      properties: {
+        type: 'COMMUNITY',
+        total_user_count: await this.prismaService.user.count(),
+        total_workspace_count: await this.prismaService.team.count(),
+        version: this.configService.get('npm_package_version'),
+      },
+    });
+    console.log('Sent event to PostHog');
+  }
+}

+ 3 - 0
packages/hoppscotch-backend/src/types/InfraConfig.ts

@@ -13,6 +13,8 @@ export enum InfraConfigEnum {
 
   VITE_ALLOWED_AUTH_PROVIDERS = 'VITE_ALLOWED_AUTH_PROVIDERS',
 
+  ALLOW_ANALYTICS_COLLECTION = 'ALLOW_ANALYTICS_COLLECTION',
+  ANALYTICS_USER_ID = 'ANALYTICS_USER_ID',
   IS_FIRST_TIME_INFRA_SETUP = 'IS_FIRST_TIME_INFRA_SETUP',
 }
 
@@ -29,5 +31,6 @@ export enum InfraConfigEnumForClient {
   MICROSOFT_CLIENT_ID = 'MICROSOFT_CLIENT_ID',
   MICROSOFT_CLIENT_SECRET = 'MICROSOFT_CLIENT_SECRET',
 
+  ALLOW_ANALYTICS_COLLECTION = 'ALLOW_ANALYTICS_COLLECTION',
   IS_FIRST_TIME_INFRA_SETUP = 'IS_FIRST_TIME_INFRA_SETUP',
 }

File diff suppressed because it is too large
+ 198 - 189
pnpm-lock.yaml


Some files were not shown because too many files changed in this diff