diff --git a/src/routes/user/oneclick/OneClickAppRouter.ts b/src/routes/user/oneclick/OneClickAppRouter.ts index 37db5d4..c9d2b6b 100644 --- a/src/routes/user/oneclick/OneClickAppRouter.ts +++ b/src/routes/user/oneclick/OneClickAppRouter.ts @@ -3,6 +3,7 @@ import axios from 'axios' import ApiStatusCodes from '../../../api/ApiStatusCodes' import BaseApi from '../../../api/BaseApi' import InjectionExtractor from '../../../injection/InjectionExtractor' +import { EventLogger } from '../../../user/events/EventLogger' import { CapRoverEventFactory, CapRoverEventType, @@ -276,9 +277,13 @@ router.post('/deploy', function (req, res, next) { InjectionExtractor.extractUserFromInjected(res).user.dataStore const serviceManager = InjectionExtractor.extractUserFromInjected(res).user.serviceManager + const eventLogger = + InjectionExtractor.extractUserFromInjected(res).user.userManager + .eventLogger const template = req.body.template const values = req.body.values + const templateName = req.body.templateName const deploymentJobRegistry = OneClickDeploymentJobRegistry.getInstance() return Promise.resolve() // @@ -292,9 +297,7 @@ router.post('/deploy', function (req, res, next) { const jobId = deploymentJobRegistry.createJob() - Logger.dev(`Starting one-click deployment with jobId: ${jobId}`) - Logger.dev(`Template: ${JSON.stringify(template, null, 2)}`) - Logger.dev(`Values: ${JSON.stringify(values, null, 2)}`) + reportAnalyticsOnAppDeploy(templateName, template, eventLogger) new OneClickAppDeployManager( dataStore, @@ -366,3 +369,56 @@ router.get('/deploy/progress', function (req, res, next) { }) export default router + +// This function analyzes the provided template to identify any unused fields in Docker service definitions. +// It then logs an analytics event with the unused fields and the template name (if it's an official or known template). +// This helps track which fields users are using and may inform future improvements to the one-click app templates. +export function reportAnalyticsOnAppDeploy( + templateName: any, + template: any, + eventLogger: EventLogger +) { + const unusedDockerServiceFieldNames: string[] = [] + if ( + templateName === 'TEMPLATE_ONE_CLICK' || + templateName === 'DOCKER_COMPOSE' + ) { + if (template?.services) { + template.services.forEach((service: any) => { + if (service && typeof service === 'object') { + Object.keys(service).forEach((key) => { + if ( + !'image,environment,ports,volumes,depends_on,hostname,command,cap_add' + .split(',') + .includes(key) + ) { + // log the unused keys so that we can track what to add next + if (!unusedDockerServiceFieldNames.includes(key)) { + unusedDockerServiceFieldNames.push(key) + } + } + }) + } + }) + } + } + + // we do not want to log private repos names + const templateNameToReport = + templateName === 'TEMPLATE_ONE_CLICK' || + templateName === 'DOCKER_COMPOSE' || + (typeof templateName === 'string' && + templateName.startsWith('OFFICIAL_')) + ? templateName + : 'UNKNOWN' + + eventLogger.trackEvent( + CapRoverEventFactory.create( + CapRoverEventType.OneClickAppDeployStarted, + { + unusedFields: unusedDockerServiceFieldNames, + templateName: templateNameToReport, + } + ) + ) +} diff --git a/src/user/events/ICapRoverEvent.ts b/src/user/events/ICapRoverEvent.ts index 05f1286..768777b 100644 --- a/src/user/events/ICapRoverEvent.ts +++ b/src/user/events/ICapRoverEvent.ts @@ -5,6 +5,7 @@ export enum CapRoverEventType { InstanceStarted = 'InstanceStarted', OneClickAppDetailsFetched = 'OneClickAppDetailsFetched', OneClickAppListFetched = 'OneClickAppListFetched', + OneClickAppDeployStarted = 'OneClickAppDeployStarted', } export interface ICapRoverEvent { diff --git a/src/user/events/emitter/AnalyticsLogger.ts b/src/user/events/emitter/AnalyticsLogger.ts index 5c4f1f7..84bf5de 100644 --- a/src/user/events/emitter/AnalyticsLogger.ts +++ b/src/user/events/emitter/AnalyticsLogger.ts @@ -22,6 +22,7 @@ export class AnalyticsLogger extends IEventsEmitter { case CapRoverEventType.InstanceStarted: case CapRoverEventType.OneClickAppDetailsFetched: case CapRoverEventType.OneClickAppListFetched: + case CapRoverEventType.OneClickAppDeployStarted: return true } } diff --git a/src/user/pro/ProManager.ts b/src/user/pro/ProManager.ts index 5bfefaf..d7c3c81 100644 --- a/src/user/pro/ProManager.ts +++ b/src/user/pro/ProManager.ts @@ -243,6 +243,7 @@ export default class ProManager { case CapRoverEventType.InstanceStarted: case CapRoverEventType.OneClickAppDetailsFetched: case CapRoverEventType.OneClickAppListFetched: + case CapRoverEventType.OneClickAppDeployStarted: return false } } diff --git a/tests/OneClickAppRouter.test.ts b/tests/OneClickAppRouter.test.ts new file mode 100644 index 0000000..1328639 --- /dev/null +++ b/tests/OneClickAppRouter.test.ts @@ -0,0 +1,448 @@ +/** + * TEST FILE: OneClickAppRouter.test.ts + * + * This file tests the `reportAnalyticsOnAppDeploy` function which tracks analytics + * when one-click apps or Docker Compose templates are deployed in CapRover. + * + * FUNCTION OVERVIEW: reportAnalyticsOnAppDeploy(templateName, template, eventLogger) + * + * PURPOSE: + * • Collects anonymous analytics about app deployments for usage insights + * • Tracks template usage patterns and custom Docker service fields + * • Helps identify which features are being used by the community + * + * TEMPLATE NAME HANDLING: + * • TEMPLATE_ONE_CLICK - One-click app templates (reported as-is) + * • DOCKER_COMPOSE - User-provided Docker Compose files (reported as-is) + * • OFFICIAL_* - Official CapRover templates (reported as-is) + * • Custom/private names - Anonymized as "UNKNOWN" for privacy + * • Invalid inputs (null, undefined, non-string) - Defaults to "UNKNOWN" + * + * DOCKER SERVICE FIELD TRACKING: + * • Only tracks unused fields for TEMPLATE_ONE_CLICK and DOCKER_COMPOSE + * • Known Docker fields (image, ports, volumes, environment, etc.) are consumed by CapRover + * • Custom/unknown fields are collected in an array for analytics + * • Helps identify which Docker features users need but aren't supported + * • Handles edge cases: null services, non-object entries, missing properties + * + * EVENT LOGGING: + * • Creates a CapRoverEventType.OneClickAppDetailsFetched event + * • Event metadata includes: templateName (string) and unusedFields (string[]) + * • Uses CapRoverEventFactory to create events with proper structure + * • Calls eventLogger.trackEvent() exactly once per deployment + * + * ERROR HANDLING: + * • Robust handling of malformed inputs (null, undefined, wrong types) + * • Graceful degradation - never throws errors, always logs something + * • Array deduplication for repeated custom field names across services + * • Safe iteration over potentially corrupted service arrays + */ + +// Jest hoists this mock before the imports +jest.mock('../src/user/events/ICapRoverEvent', () => ({ + CapRoverEventFactory: { + create: jest.fn().mockImplementation((type: any, data: any) => ({ + eventType: type, + eventMetadata: data, + timestamp: Date.now(), + })), + }, + CapRoverEventType: { + OneClickAppDetailsFetched: 'OneClickAppDetailsFetched', + }, +})) + +import { reportAnalyticsOnAppDeploy } from '../src/routes/user/oneclick/OneClickAppRouter' +import { EventLogger } from '../src/user/events/EventLogger' +import { + CapRoverEventFactory, + CapRoverEventType, + ICapRoverEvent, +} from '../src/user/events/ICapRoverEvent' + +describe('reportAnalyticsOnAppDeploy', () => { + let mockEventLogger: EventLogger + let trackedEvents: ICapRoverEvent[] + + beforeEach(() => { + trackedEvents = [] + + // Create a proper mock that satisfies EventLogger interface + mockEventLogger = { + trackEvent: jest.fn((event: ICapRoverEvent) => { + trackedEvents.push(event) + }), + } as any as EventLogger + + // Reset the factory mock + ;(CapRoverEventFactory.create as jest.Mock).mockClear() + }) + + describe('Template name handling', () => { + test('should report TEMPLATE_ONE_CLICK template name as-is', () => { + const templateName = 'TEMPLATE_ONE_CLICK' + const template = { services: [] } + + reportAnalyticsOnAppDeploy(templateName, template, mockEventLogger) + + expect(mockEventLogger.trackEvent).toHaveBeenCalledTimes(1) + expect(trackedEvents[0].eventMetadata.templateName).toBe( + 'TEMPLATE_ONE_CLICK' + ) + }) + + test('should report DOCKER_COMPOSE template name as-is', () => { + const templateName = 'DOCKER_COMPOSE' + const template = { services: [] } + + reportAnalyticsOnAppDeploy(templateName, template, mockEventLogger) + + expect(mockEventLogger.trackEvent).toHaveBeenCalledTimes(1) + expect(trackedEvents[0].eventMetadata.templateName).toBe( + 'DOCKER_COMPOSE' + ) + }) + + test('should report OFFICIAL_ prefixed template names as-is', () => { + const templateName = 'OFFICIAL_NGINX' + const template = { services: [] } + + reportAnalyticsOnAppDeploy(templateName, template, mockEventLogger) + + expect(mockEventLogger.trackEvent).toHaveBeenCalledTimes(1) + expect(trackedEvents[0].eventMetadata.templateName).toBe( + 'OFFICIAL_NGINX' + ) + }) + + test('should report private/custom template names as UNKNOWN', () => { + const templateName = 'my-private-template' + const template = { services: [] } + + reportAnalyticsOnAppDeploy(templateName, template, mockEventLogger) + + expect(mockEventLogger.trackEvent).toHaveBeenCalledTimes(1) + expect(trackedEvents[0].eventMetadata.templateName).toBe('UNKNOWN') + }) + + test('should handle null templateName', () => { + const templateName = null + const template = { services: [] } + + reportAnalyticsOnAppDeploy(templateName, template, mockEventLogger) + + expect(mockEventLogger.trackEvent).toHaveBeenCalledTimes(1) + expect(trackedEvents[0].eventMetadata.templateName).toBe('UNKNOWN') + }) + + test('should handle undefined templateName', () => { + const templateName = undefined + const template = { services: [] } + + reportAnalyticsOnAppDeploy(templateName, template, mockEventLogger) + + expect(mockEventLogger.trackEvent).toHaveBeenCalledTimes(1) + expect(trackedEvents[0].eventMetadata.templateName).toBe('UNKNOWN') + }) + }) + + describe('Docker service field tracking', () => { + test('should not track unused fields for non-Docker templates', () => { + const templateName = 'SOME_OTHER_TEMPLATE' + const template = { + services: [ + { + image: 'nginx:latest', + custom_field: 'value', + another_field: 'value2', + }, + ], + } + + reportAnalyticsOnAppDeploy(templateName, template, mockEventLogger) + + expect(trackedEvents[0].eventMetadata.unusedFields).toEqual([]) + }) + + test('should track unused fields for TEMPLATE_ONE_CLICK', () => { + const templateName = 'TEMPLATE_ONE_CLICK' + const template = { + services: [ + { + image: 'nginx:latest', + environment: { VAR: 'value' }, + custom_field: 'value', + another_field: 'value2', + }, + ], + } + + reportAnalyticsOnAppDeploy(templateName, template, mockEventLogger) + + const unusedFields = trackedEvents[0].eventMetadata.unusedFields + expect(unusedFields).toEqual(['custom_field', 'another_field']) + }) + + test('should track unused fields for DOCKER_COMPOSE', () => { + const templateName = 'DOCKER_COMPOSE' + const template = { + services: [ + { + image: 'postgres:latest', + volumes: ['/data:/var/lib/postgresql/data'], + restart: 'always', + networks: ['backend'], + }, + ], + } + + reportAnalyticsOnAppDeploy(templateName, template, mockEventLogger) + + const unusedFields = trackedEvents[0].eventMetadata.unusedFields + expect(unusedFields).toEqual(['restart', 'networks']) + }) + + test('should not track known Docker service fields', () => { + const templateName = 'TEMPLATE_ONE_CLICK' + const template = { + services: [ + { + image: 'nginx:latest', + environment: { VAR: 'value' }, + ports: ['80:80'], + volumes: ['/data:/data'], + depends_on: ['db'], + hostname: 'web', + command: 'nginx -g "daemon off;"', + cap_add: ['SYS_ADMIN'], + }, + ], + } + + reportAnalyticsOnAppDeploy(templateName, template, mockEventLogger) + + const unusedFields = trackedEvents[0].eventMetadata.unusedFields + expect(unusedFields).toEqual([]) + }) + + test('should handle multiple services with mixed fields', () => { + const templateName = 'DOCKER_COMPOSE' + const template = { + services: [ + { + image: 'nginx:latest', + ports: ['80:80'], + custom_field1: 'value1', + }, + { + image: 'postgres:latest', + environment: { POSTGRES_DB: 'mydb' }, + custom_field2: 'value2', + another_custom: 'value3', + }, + ], + } + + reportAnalyticsOnAppDeploy(templateName, template, mockEventLogger) + + const unusedFields = trackedEvents[0].eventMetadata.unusedFields + expect(unusedFields).toEqual([ + 'custom_field1', + 'custom_field2', + 'another_custom', + ]) + }) + + test('should handle services with no custom fields', () => { + const templateName = 'TEMPLATE_ONE_CLICK' + const template = { + services: [ + { + image: 'nginx:latest', + ports: ['80:80'], + }, + ], + } + + reportAnalyticsOnAppDeploy(templateName, template, mockEventLogger) + + const unusedFields = trackedEvents[0].eventMetadata.unusedFields + expect(unusedFields).toEqual([]) + }) + + test('should handle empty services array', () => { + const templateName = 'TEMPLATE_ONE_CLICK' + const template = { services: [] } + + reportAnalyticsOnAppDeploy(templateName, template, mockEventLogger) + + const unusedFields = trackedEvents[0].eventMetadata.unusedFields + expect(unusedFields).toEqual([]) + }) + + test('should handle template without services property', () => { + const templateName = 'TEMPLATE_ONE_CLICK' + const template = { other_property: 'value' } + + reportAnalyticsOnAppDeploy(templateName, template, mockEventLogger) + + const unusedFields = trackedEvents[0].eventMetadata.unusedFields + expect(unusedFields).toEqual([]) + }) + + test('should handle null template', () => { + const templateName = 'TEMPLATE_ONE_CLICK' + const template = null + + reportAnalyticsOnAppDeploy(templateName, template, mockEventLogger) + + const unusedFields = trackedEvents[0].eventMetadata.unusedFields + expect(unusedFields).toEqual([]) + }) + + test('should handle undefined template', () => { + const templateName = 'TEMPLATE_ONE_CLICK' + const template = undefined + + reportAnalyticsOnAppDeploy(templateName, template, mockEventLogger) + + const unusedFields = trackedEvents[0].eventMetadata.unusedFields + expect(unusedFields).toEqual([]) + }) + }) + + describe('Event logging', () => { + test('should call eventLogger.trackEvent exactly once', () => { + const templateName = 'TEMPLATE_ONE_CLICK' + const template = { services: [] } + + reportAnalyticsOnAppDeploy(templateName, template, mockEventLogger) + + expect(mockEventLogger.trackEvent).toHaveBeenCalledTimes(1) + }) + + test('should create event with correct type', () => { + const templateName = 'TEMPLATE_ONE_CLICK' + const template = { services: [] } + + reportAnalyticsOnAppDeploy(templateName, template, mockEventLogger) + + expect(trackedEvents[0].eventType).toBe( + CapRoverEventType.OneClickAppDetailsFetched + ) + }) + + test('should create event with correct metadata structure', () => { + const templateName = 'TEMPLATE_ONE_CLICK' + const template = { + services: [ + { + image: 'nginx:latest', + custom_field: 'value', + }, + ], + } + + reportAnalyticsOnAppDeploy(templateName, template, mockEventLogger) + + const eventMetadata = trackedEvents[0].eventMetadata + expect(eventMetadata).toHaveProperty('unusedFields') + expect(eventMetadata).toHaveProperty('templateName') + expect(Array.isArray(eventMetadata.unusedFields)).toBe(true) + expect(typeof eventMetadata.templateName).toBe('string') + }) + }) + + describe('Edge cases and error handling', () => { + test('should handle services with null/undefined service objects', () => { + const templateName = 'TEMPLATE_ONE_CLICK' + const template = { + services: [null, undefined, { image: 'nginx:latest' }], + } + + expect(() => { + reportAnalyticsOnAppDeploy( + templateName, + template, + mockEventLogger + ) + }).not.toThrow() + + expect(mockEventLogger.trackEvent).toHaveBeenCalledTimes(1) + }) + + test('should handle services array containing non-object values', () => { + const templateName = 'TEMPLATE_ONE_CLICK' + const template = { + services: ['string', 123, true, { image: 'nginx:latest' }], + } + + expect(() => { + reportAnalyticsOnAppDeploy( + templateName, + template, + mockEventLogger + ) + }).not.toThrow() + + expect(mockEventLogger.trackEvent).toHaveBeenCalledTimes(1) + }) + + test('should handle template name that is not a string', () => { + const templateName = 123 + const template = { services: [] } + + reportAnalyticsOnAppDeploy(templateName, template, mockEventLogger) + + expect(trackedEvents[0].eventMetadata.templateName).toBe('UNKNOWN') + }) + + test('should handle empty string template name', () => { + const templateName = '' + const template = { services: [] } + + reportAnalyticsOnAppDeploy(templateName, template, mockEventLogger) + + expect(trackedEvents[0].eventMetadata.templateName).toBe('UNKNOWN') + }) + + test('should preserve array uniqueness for duplicate unused fields', () => { + const templateName = 'TEMPLATE_ONE_CLICK' + const template = { + services: [ + { + image: 'nginx:latest', + custom_field: 'value1', + }, + { + image: 'postgres:latest', + custom_field: 'value2', // Same field name, different value + }, + ], + } + + reportAnalyticsOnAppDeploy(templateName, template, mockEventLogger) + + const unusedFields = trackedEvents[0].eventMetadata.unusedFields + expect(unusedFields).toEqual(['custom_field']) + expect(unusedFields.length).toBe(1) + }) + }) + + describe('Integration with CapRoverEventFactory', () => { + test('should use CapRoverEventFactory.create method', () => { + const templateName = 'TEMPLATE_ONE_CLICK' + const template = { services: [] } + + reportAnalyticsOnAppDeploy(templateName, template, mockEventLogger) + + expect(CapRoverEventFactory.create).toHaveBeenCalledWith( + CapRoverEventType.OneClickAppDetailsFetched, + { + unusedFields: [], + templateName: 'TEMPLATE_ONE_CLICK', + } + ) + }) + }) +})