diff --git a/packages/twenty-server/src/engine/core-modules/application/application-gauge.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-gauge.service.ts new file mode 100644 index 0000000000..e3ed8ea416 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-gauge.service.ts @@ -0,0 +1,53 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; + +import { ApplicationService } from 'src/engine/core-modules/application/application.service'; +import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service'; + +const TOP_INSTALLED_APPS_LIMIT = 100; + +@Injectable() +export class ApplicationGaugeService implements OnModuleInit { + private readonly logger = new Logger(ApplicationGaugeService.name); + + constructor( + private readonly metricsService: MetricsService, + private readonly applicationService: ApplicationService, + ) {} + + onModuleInit() { + this.metricsService.createMultiObservableGauge({ + metricName: 'twenty_app_installed_workspaces_total', + options: { + description: + 'Number of workspaces each application is installed in (top 100 external apps by install count)', + }, + callback: async () => { + try { + const installedApps = + await this.applicationService.countInstalledWorkspacesByApplication( + { + limit: TOP_INSTALLED_APPS_LIMIT, + }, + ); + + return installedApps.map((installedApp) => ({ + value: installedApp.installedWorkspaceCount, + attributes: { + universal_identifier: installedApp.universalIdentifier, + app_name: installedApp.name, + source_type: installedApp.sourceType, + }, + })); + } catch (error) { + this.logger.error( + 'Failed to collect installed application counts for gauge', + error, + ); + + return []; + } + }, + cacheValue: true, + }); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/application/application-install/application-install.module.ts b/packages/twenty-server/src/engine/core-modules/application/application-install/application-install.module.ts index 1a91add8f3..98186a4763 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-install/application-install.module.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-install/application-install.module.ts @@ -13,6 +13,8 @@ import { ApplicationInstallResolver } from 'src/engine/core-modules/application/ import { ApplicationInstallService } from 'src/engine/core-modules/application/application-install/application-install.service'; import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module'; import { LogicFunctionModule } from 'src/engine/core-modules/logic-function/logic-function.module'; +import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module'; +import { SdkClientModule } from 'src/engine/core-modules/sdk-client/sdk-client.module'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module'; @@ -27,6 +29,8 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache CacheLockModule, FeatureFlagModule, LogicFunctionModule, + MetricsModule, + SdkClientModule, PermissionsModule, FileStorageModule, WorkspaceCacheModule, diff --git a/packages/twenty-server/src/engine/core-modules/application/application-install/application-install.resolver.ts b/packages/twenty-server/src/engine/core-modules/application/application-install/application-install.resolver.ts index c8a55fa297..280eb534f7 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-install/application-install.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-install/application-install.resolver.ts @@ -14,10 +14,13 @@ import { ApplicationInstallService } from 'src/engine/core-modules/application/a import { ApplicationSyncService } from 'src/engine/core-modules/application/application-manifest/application-sync.service'; import { UninstallApplicationInput } from 'src/engine/core-modules/application/application-manifest/dtos/uninstall-application.input'; import { MarketplaceQueryService } from 'src/engine/core-modules/application/application-marketplace/marketplace-query.service'; +import { ApplicationException } from 'src/engine/core-modules/application/application.exception'; import { ApplicationRegistrationExceptionFilter } from 'src/engine/core-modules/application/application-registration/application-registration-exception-filter'; import { ApplicationService } from 'src/engine/core-modules/application/application.service'; import { ApplicationDTO } from 'src/engine/core-modules/application/dtos/application.dto'; import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter'; +import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service'; +import { MetricsKeys } from 'src/engine/core-modules/metrics/types/metrics-keys.type'; import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe'; import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; @@ -40,6 +43,7 @@ export class ApplicationInstallResolver { private readonly applicationInstallService: ApplicationInstallService, private readonly applicationSyncService: ApplicationSyncService, private readonly marketplaceQueryService: MarketplaceQueryService, + private readonly metricsService: MetricsService, ) {} @Query(() => [ApplicationDTO]) @@ -130,9 +134,43 @@ export class ApplicationInstallResolver { @Args() { universalIdentifier }: UninstallApplicationInput, @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, ) { - await this.applicationSyncService.uninstallApplication({ - applicationUniversalIdentifier: universalIdentifier, - workspaceId, + const application = await this.applicationService.findByUniversalIdentifier( + { + universalIdentifier, + workspaceId, + }, + ); + + const attributes = { + universal_identifier: universalIdentifier, + app_name: application?.name ?? 'unknown', + source_type: application?.sourceType ?? 'unknown', + version: application?.version ?? 'unknown', + }; + + try { + await this.applicationSyncService.uninstallApplication({ + applicationUniversalIdentifier: universalIdentifier, + workspaceId, + }); + } catch (error) { + this.metricsService.incrementCounterBy({ + key: MetricsKeys.AppUninstallFailed, + amount: 1, + attributes: { + ...attributes, + error_code: + error instanceof ApplicationException ? error.code : 'UNKNOWN', + }, + }); + + throw error; + } + + this.metricsService.incrementCounterBy({ + key: MetricsKeys.AppUninstallSucceeded, + amount: 1, + attributes, }); return true; diff --git a/packages/twenty-server/src/engine/core-modules/application/application-install/application-install.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-install/application-install.service.ts index 61c1cdfcb3..3cf6d72977 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-install/application-install.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-install/application-install.service.ts @@ -19,13 +19,17 @@ import { ApplicationRegistrationEntity } from 'src/engine/core-modules/applicati import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum'; import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; import { ApplicationService } from 'src/engine/core-modules/application/application.service'; -import { ApplicationPackageFetcherService } from 'src/engine/core-modules/application/application-package/application-package-fetcher.service'; +import { + type ResolvedPackage, + ApplicationPackageFetcherService, +} from 'src/engine/core-modules/application/application-package/application-package-fetcher.service'; import { ApplicationVersionValidationService } from 'src/engine/core-modules/application/application-package/application-version-validation.service'; import { VERSION_REASON_TO_APPLICATION_EXCEPTION_CODE } from 'src/engine/core-modules/application/application-package/constants/version-reason-to-exception-code.constant'; import { ApplicationManifestApplyService } from 'src/engine/core-modules/application/application-manifest/application-manifest-apply.service'; import { ApplicationSyncService } from 'src/engine/core-modules/application/application-manifest/application-sync.service'; import { CacheLockService } from 'src/engine/core-modules/cache-lock/cache-lock.service'; import { FileStorageService } from 'src/engine/core-modules/file-storage/services/file-storage.service'; +import { LogicFunctionExecutorService } from 'src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service'; import { LogicFunctionTriggerJob, type LogicFunctionTriggerJobData, @@ -33,8 +37,9 @@ import { import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator'; import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service'; +import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service'; +import { MetricsKeys } from 'src/engine/core-modules/metrics/types/metrics-keys.type'; import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; -import { LogicFunctionExecutorService } from 'src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service'; @Injectable() export class ApplicationInstallService { @@ -54,6 +59,7 @@ export class ApplicationInstallService { @InjectMessageQueue(MessageQueue.logicFunctionQueue) private readonly messageQueueService: MessageQueueService, private readonly workspaceCacheService: WorkspaceCacheService, + private readonly metricsService: MetricsService, ) {} async installApplication(params: { @@ -120,6 +126,93 @@ export class ApplicationInstallService { return true; } + try { + const existingApplication = + await this.applicationService.findByUniversalIdentifier({ + universalIdentifier: appRegistration.universalIdentifier, + workspaceId: params.workspaceId, + }); + + return await this.runInstallWithMetrics({ + appRegistration, + params, + resolvedPackage, + existingApplication, + }); + } finally { + await this.applicationPackageFetcherService.cleanupExtractedDir( + resolvedPackage.cleanupDir, + ); + } + } + + private async runInstallWithMetrics({ + appRegistration, + params, + resolvedPackage, + existingApplication, + }: { + appRegistration: ApplicationRegistrationEntity; + params: { version?: string; workspaceId: string }; + resolvedPackage: ResolvedPackage; + existingApplication: ApplicationEntity | null; + }): Promise { + const isVersionUpgrade = isDefined(existingApplication); + + const attributes = { + universal_identifier: appRegistration.universalIdentifier, + app_name: resolvedPackage.manifest.application.displayName, + source_type: appRegistration.sourceType, + version: resolvedPackage.packageJson.version ?? 'unknown', + }; + + try { + const result = await this.runInstall({ + appRegistration, + params, + resolvedPackage, + existingApplication, + }); + + this.metricsService.incrementCounterBy({ + key: isVersionUpgrade + ? MetricsKeys.AppUpgradeSucceeded + : MetricsKeys.AppInstallSucceeded, + amount: 1, + attributes, + }); + + return result; + } catch (error) { + this.metricsService.incrementCounterBy({ + key: isVersionUpgrade + ? MetricsKeys.AppUpgradeFailed + : MetricsKeys.AppInstallFailed, + amount: 1, + attributes: { + ...attributes, + error_code: + error instanceof ApplicationException ? error.code : 'UNKNOWN', + }, + }); + + throw error; + } + } + + private async runInstall({ + appRegistration, + params, + resolvedPackage, + existingApplication, + }: { + appRegistration: ApplicationRegistrationEntity; + params: { version?: string; workspaceId: string }; + resolvedPackage: ResolvedPackage; + existingApplication: ApplicationEntity | null; + }): Promise { + const universalIdentifier = appRegistration.universalIdentifier; + const requiredServerVersion = resolvedPackage.packageJson.engines?.['twenty']; @@ -132,24 +225,12 @@ export class ApplicationInstallService { ); if (!versionValidation.compatible) { - await this.applicationPackageFetcherService.cleanupExtractedDir( - resolvedPackage.cleanupDir, - ); - throw new ApplicationException( versionValidation.message, VERSION_REASON_TO_APPLICATION_EXCEPTION_CODE[versionValidation.reason], ); } - const universalIdentifier = appRegistration.universalIdentifier; - - const existingApplication = - await this.applicationService.findByUniversalIdentifier({ - universalIdentifier, - workspaceId: params.workspaceId, - }); - const isVersionUpgrade = isDefined(existingApplication); const previousVersion = existingApplication?.version ?? undefined; @@ -175,6 +256,9 @@ export class ApplicationInstallService { const incomingVersion = resolvedPackage.packageJson.version; + // Rollback is scoped to the work after the application row exists: reaching + // this catch means creation succeeded, so a fresh install (not an upgrade) + // is the only case that needs uninstalling. try { if ( isVersionUpgrade && @@ -279,12 +363,6 @@ export class ApplicationInstallService { } throw error; - } finally { - if (resolvedPackage) { - await this.applicationPackageFetcherService.cleanupExtractedDir( - resolvedPackage.cleanupDir, - ); - } } } diff --git a/packages/twenty-server/src/engine/core-modules/application/application-registration/application-registration.module.ts b/packages/twenty-server/src/engine/core-modules/application/application-registration/application-registration.module.ts index d128a40f9c..3624b1e170 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-registration/application-registration.module.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-registration/application-registration.module.ts @@ -18,6 +18,7 @@ import { DomainServerConfigModule } from 'src/engine/core-modules/domain/domain- import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module'; import { FileUrlModule } from 'src/engine/core-modules/file/file-url/file-url.module'; +import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module'; @@ -39,6 +40,7 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/ PermissionsModule, FileStorageModule, FileUrlModule, + MetricsModule, WorkspaceCacheStorageModule, ], providers: [ diff --git a/packages/twenty-server/src/engine/core-modules/application/application-registration/application-registration.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-registration/application-registration.service.ts index 9a82b5ce30..feddb9fbc6 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-registration/application-registration.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-registration/application-registration.service.ts @@ -9,21 +9,22 @@ import { isDefined } from 'twenty-shared/utils'; import { ILike, IsNull, type FindOptionsWhere, type Repository } from 'typeorm'; import { v4 } from 'uuid'; -import { ALL_OAUTH_SCOPES } from 'src/engine/core-modules/application/application-oauth/constants/oauth-scopes'; +import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.service'; import { shouldRefreshApplicationRegistrationOnInstall } from 'src/engine/core-modules/application/application-install/utils/should-refresh-application-registration-on-install.util'; +import { MARKETPLACE_CATALOG_CACHE_ENTITY_ID } from 'src/engine/core-modules/application/application-marketplace/constants/marketplace-apps-cache.constant'; +import { MARKETPLACE_VETTED_APPLICATIONS } from 'src/engine/core-modules/application/application-marketplace/constants/marketplace-vetted-applications.constant'; +import { ALL_OAUTH_SCOPES } from 'src/engine/core-modules/application/application-oauth/constants/oauth-scopes'; +import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.service'; import { ApplicationRegistrationAssetUrlService } from 'src/engine/core-modules/application/application-registration/application-registration-asset-url.service'; -import { CacheLockService } from 'src/engine/core-modules/cache-lock/cache-lock.service'; -import { ServerFileStorageService } from 'src/engine/core-modules/file-storage/services/server-file-storage.service'; import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity'; -import { TWENTY_CLI_APPLICATION_REGISTRATION } from 'src/engine/workspace-manager/twenty-standard-application/constants/twenty-cli-application-registration.constant'; import { ApplicationRegistrationException, ApplicationRegistrationExceptionCode, } from 'src/engine/core-modules/application/application-registration/application-registration.exception'; import { type ApplicationRegistrationInstalledWorkspacesDTO } from 'src/engine/core-modules/application/application-registration/dtos/application-registration-installed-workspaces.dto'; -import { type PaginatedApplicationRegistrationsDTO } from 'src/engine/core-modules/application/application-registration/dtos/paginated-application-registrations.dto'; import { type ApplicationRegistrationStatsDTO } from 'src/engine/core-modules/application/application-registration/dtos/application-registration-stats.dto'; import { type CreateApplicationRegistrationInput } from 'src/engine/core-modules/application/application-registration/dtos/create-application-registration.input'; +import { type PaginatedApplicationRegistrationsDTO } from 'src/engine/core-modules/application/application-registration/dtos/paginated-application-registrations.dto'; import { type PublicApplicationRegistrationDTO } from 'src/engine/core-modules/application/application-registration/dtos/public-application-registration.dto'; import { type UpdateApplicationRegistrationInput, @@ -33,11 +34,12 @@ import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/appli import { fromManifestApplicationToDisplayFields } from 'src/engine/core-modules/application/application-registration/utils/from-manifest-application-to-display-fields.util'; import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; import { validateRedirectUri } from 'src/engine/core-modules/auth/utils/validate-redirect-uri.util'; +import { CacheLockService } from 'src/engine/core-modules/cache-lock/cache-lock.service'; +import { ServerFileStorageService } from 'src/engine/core-modules/file-storage/services/server-file-storage.service'; +import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service'; +import { MetricsKeys } from 'src/engine/core-modules/metrics/types/metrics-keys.type'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; -import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.service'; -import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.service'; -import { MARKETPLACE_CATALOG_CACHE_ENTITY_ID } from 'src/engine/core-modules/application/application-marketplace/constants/marketplace-apps-cache.constant'; -import { MARKETPLACE_VETTED_APPLICATIONS } from 'src/engine/core-modules/application/application-marketplace/constants/marketplace-vetted-applications.constant'; +import { TWENTY_CLI_APPLICATION_REGISTRATION } from 'src/engine/workspace-manager/twenty-standard-application/constants/twenty-cli-application-registration.constant'; const BCRYPT_SALT_ROUNDS = 10; @@ -112,6 +114,7 @@ export class ApplicationRegistrationService { private readonly serverFileStorageService: ServerFileStorageService, private readonly cacheLockService: CacheLockService, private readonly coreEntityCacheService: CoreEntityCacheService, + private readonly metricsService: MetricsService, ) {} private async invalidateMarketplaceAppsCache(): Promise { @@ -125,6 +128,50 @@ export class ApplicationRegistrationService { } } + emitRegistrationPublishMetric({ + isNewRegistration, + universalIdentifier, + name, + sourceType, + version, + }: { + isNewRegistration: boolean; + universalIdentifier: string; + name: string; + sourceType: string; + version?: string | null; + }): void { + this.metricsService.incrementCounterBy({ + key: isNewRegistration + ? MetricsKeys.AppRegistrationCreated + : MetricsKeys.AppRegistrationVersionPublished, + amount: 1, + attributes: { + universal_identifier: universalIdentifier, + app_name: name, + source_type: sourceType, + version: version ?? 'unknown', + }, + }); + } + + async setLatestAvailableVersionIfChanged( + applicationRegistrationId: string, + newVersion: string | null, + ): Promise { + const result = await this.applicationRegistrationRepository + .createQueryBuilder() + .update(ApplicationRegistrationEntity) + .set({ latestAvailableVersion: newVersion }) + .where('id = :id', { id: applicationRegistrationId }) + .andWhere('"latestAvailableVersion" IS DISTINCT FROM :newVersion', { + newVersion, + }) + .execute(); + + return (result.affected ?? 0) > 0; + } + async findMany( ownerWorkspaceId: string, ): Promise { @@ -552,6 +599,11 @@ export class ApplicationRegistrationService { ]), ); + const isNewVersion = await this.setLatestAvailableVersionIfChanged( + existing.id, + params.latestAvailableVersion ?? null, + ); + await this.applicationRegistrationRepository.save({ ...existing, name: params.name, @@ -566,6 +618,16 @@ export class ApplicationRegistrationService { fileId: existingFileIdByPath.get(galleryImage.path) ?? null, })), }); + + if (isNewVersion) { + this.emitRegistrationPublishMetric({ + isNewRegistration: false, + universalIdentifier: params.universalIdentifier, + name: params.name, + sourceType: params.sourceType, + version: params.latestAvailableVersion, + }); + } } else { const registration = this.applicationRegistrationRepository.create({ universalIdentifier: params.universalIdentifier, @@ -584,6 +646,14 @@ export class ApplicationRegistrationService { }); await this.applicationRegistrationRepository.save(registration); + + this.emitRegistrationPublishMetric({ + isNewRegistration: true, + universalIdentifier: params.universalIdentifier, + name: params.name, + sourceType: params.sourceType, + version: params.latestAvailableVersion, + }); } await this.invalidateMarketplaceAppsCache(); diff --git a/packages/twenty-server/src/engine/core-modules/application/application-registration/application-tarball.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-registration/application-tarball.service.ts index b77ef2bce9..55a6e93d42 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-registration/application-tarball.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-registration/application-tarball.service.ts @@ -24,6 +24,7 @@ import { ApplicationRegistrationException, ApplicationRegistrationExceptionCode, } from 'src/engine/core-modules/application/application-registration/application-registration.exception'; +import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service'; import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum'; import { fromManifestApplicationToDisplayFields } from 'src/engine/core-modules/application/application-registration/utils/from-manifest-application-to-display-fields.util'; import { ApplicationService } from 'src/engine/core-modules/application/application.service'; @@ -42,6 +43,7 @@ export class ApplicationTarballService { private readonly applicationService: ApplicationService, private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService, private readonly applicationVersionValidationService: ApplicationVersionValidationService, + private readonly applicationRegistrationService: ApplicationRegistrationService, ) {} async uploadTarball(params: { @@ -114,6 +116,10 @@ export class ApplicationTarballService { }, }); + const isNewRegistration = !isDefined(appRegistration); + const previousLatestAvailableVersion = + appRegistration?.latestAvailableVersion ?? null; + if (isDefined(appRegistration)) { if ( appRegistration.sourceType !== @@ -221,6 +227,20 @@ export class ApplicationTarballService { `Tarball uploaded for app ${universalIdentifier} (registration ${appRegistration.id})`, ); + const incomingVersion = packageJson?.version ?? null; + if ( + isNewRegistration || + previousLatestAvailableVersion !== incomingVersion + ) { + this.applicationRegistrationService.emitRegistrationPublishMetric({ + isNewRegistration, + universalIdentifier, + name: manifest.application?.displayName ?? 'Unknown App', + sourceType: ApplicationRegistrationSourceType.TARBALL, + version: incomingVersion, + }); + } + return this.appRegistrationRepository.findOneOrFail({ where: { id: appRegistration.id }, }); diff --git a/packages/twenty-server/src/engine/core-modules/application/application-upgrade/application-upgrade.module.ts b/packages/twenty-server/src/engine/core-modules/application/application-upgrade/application-upgrade.module.ts index 0627170e7a..07dc3debbf 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-upgrade/application-upgrade.module.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-upgrade/application-upgrade.module.ts @@ -2,23 +2,21 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ApplicationInstallModule } from 'src/engine/core-modules/application/application-install/application-install.module'; +import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity'; +import { ApplicationRegistrationModule } from 'src/engine/core-modules/application/application-registration/application-registration.module'; +import { ApplicationUpgradeResolver } from 'src/engine/core-modules/application/application-upgrade/application-upgrade.resolver'; import { ApplicationUpgradeService } from 'src/engine/core-modules/application/application-upgrade/application-upgrade.service'; import { ApplicationVersionCheckCronJob } from 'src/engine/core-modules/application/application-upgrade/crons/application-version-check.cron.job'; import { ApplicationVersionCheckCronCommand } from 'src/engine/core-modules/application/application-upgrade/crons/commands/application-version-check.cron.command'; -import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity'; -import { ApplicationUpgradeResolver } from 'src/engine/core-modules/application/application-upgrade/application-upgrade.resolver'; -import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; @Module({ imports: [ - TypeOrmModule.forFeature([ - ApplicationRegistrationEntity, - ApplicationEntity, - ]), + TypeOrmModule.forFeature([ApplicationRegistrationEntity]), ApplicationInstallModule, + ApplicationRegistrationModule, FeatureFlagModule, PermissionsModule, TwentyConfigModule, diff --git a/packages/twenty-server/src/engine/core-modules/application/application-upgrade/application-upgrade.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-upgrade/application-upgrade.service.ts index 6e7cce3702..9344384691 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-upgrade/application-upgrade.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-upgrade/application-upgrade.service.ts @@ -7,8 +7,8 @@ import { z } from 'zod'; import { ApplicationInstallService } from 'src/engine/core-modules/application/application-install/application-install.service'; import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity'; +import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service'; import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum'; -import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; import { ApplicationException, ApplicationExceptionCode, @@ -26,9 +26,8 @@ export class ApplicationUpgradeService { constructor( @InjectRepository(ApplicationRegistrationEntity) private readonly appRegistrationRepository: Repository, - @InjectRepository(ApplicationEntity) - private readonly applicationRepository: Repository, private readonly applicationInstallService: ApplicationInstallService, + private readonly applicationRegistrationService: ApplicationRegistrationService, private readonly twentyConfigService: TwentyConfigService, ) {} @@ -66,9 +65,21 @@ export class ApplicationUpgradeService { return null; } - await this.appRegistrationRepository.update(appRegistration.id, { - latestAvailableVersion: parsed.data.version, - }); + const isNewVersion = + await this.applicationRegistrationService.setLatestAvailableVersionIfChanged( + appRegistration.id, + parsed.data.version, + ); + + if (isNewVersion) { + this.applicationRegistrationService.emitRegistrationPublishMetric({ + isNewRegistration: false, + universalIdentifier: appRegistration.universalIdentifier, + name: appRegistration.name, + sourceType: appRegistration.sourceType, + version: parsed.data.version, + }); + } return parsed.data.version; } catch (error) { diff --git a/packages/twenty-server/src/engine/core-modules/application/application.module.ts b/packages/twenty-server/src/engine/core-modules/application/application.module.ts index 4e5497cafa..2dd23180fc 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application.module.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application.module.ts @@ -2,12 +2,14 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity'; +import { ApplicationGaugeService } from 'src/engine/core-modules/application/application-gauge.service'; import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; import { ApplicationResolver } from 'src/engine/core-modules/application/application.resolver'; import { ApplicationService } from 'src/engine/core-modules/application/application.service'; import { WorkspaceFlatApplicationMapCacheService } from 'src/engine/core-modules/application/workspace-flat-application-map-cache.service'; import { ApplicationVariableEntity } from 'src/engine/core-modules/application/application-variable/application-variable.entity'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; +import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module'; import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity'; @@ -36,11 +38,13 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache WorkspaceCacheModule, TwentyConfigModule, FeatureFlagModule, + MetricsModule, ], exports: [ApplicationService, WorkspaceFlatApplicationMapCacheService], providers: [ ApplicationResolver, ApplicationService, + ApplicationGaugeService, WorkspaceFlatApplicationMapCacheService, provideWorkspaceScopedRepository(AgentEntity), provideWorkspaceScopedRepository(CommandMenuItemEntity), diff --git a/packages/twenty-server/src/engine/core-modules/application/application.service.ts b/packages/twenty-server/src/engine/core-modules/application/application.service.ts index 929ad92b89..d74c1142c3 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application.service.ts @@ -298,6 +298,52 @@ export class ApplicationService { }); } + // Number of workspaces each external (non-LOCAL) application is installed in, + // ranked by install count. Powers the "most installed apps" gauge/leaderboard. + // LOCAL apps (built-in Standard/Custom) exist in every workspace and are not + // marketplace installs, so they are excluded to keep the ranking meaningful. + async countInstalledWorkspacesByApplication({ + limit = 100, + }: { + limit?: number; + } = {}): Promise< + Array<{ + universalIdentifier: string; + name: string; + sourceType: string; + installedWorkspaceCount: number; + }> + > { + const rows = await this.applicationRepository + .createQueryBuilder('application') + .select('application.universalIdentifier', 'universalIdentifier') + .addSelect('MAX(application.name)', 'name') + .addSelect('MAX(application.sourceType)', 'sourceType') + .addSelect('COUNT(*)', 'count') + .innerJoin('application.workspace', 'workspace') + .where('application.deletedAt IS NULL') + .andWhere('workspace.deletedAt IS NULL') + .andWhere('application.sourceType != :localSourceType', { + localSourceType: ApplicationRegistrationSourceType.LOCAL, + }) + .groupBy('application.universalIdentifier') + .orderBy('count', 'DESC') + .limit(limit) + .getRawMany<{ + universalIdentifier: string; + name: string; + sourceType: string; + count: string; + }>(); + + return rows.map((row) => ({ + universalIdentifier: row.universalIdentifier, + name: row.name, + sourceType: row.sourceType, + installedWorkspaceCount: Number(row.count), + })); + } + async findTwentyStandardApplicationOrThrow(workspaceId: string): Promise<{ application: ApplicationEntity; workspace: WorkspaceEntity; diff --git a/packages/twenty-server/src/engine/core-modules/metrics/types/metrics-keys.type.ts b/packages/twenty-server/src/engine/core-modules/metrics/types/metrics-keys.type.ts index eac434bf4a..d1a12227dc 100644 --- a/packages/twenty-server/src/engine/core-modules/metrics/types/metrics-keys.type.ts +++ b/packages/twenty-server/src/engine/core-modules/metrics/types/metrics-keys.type.ts @@ -38,6 +38,14 @@ export enum MetricsKeys { AiChatCacheWriteTokens = 'ai-chat/cache-write-tokens', SchemaVersionMismatch = 'schema-version/mismatch', AppVersionMismatch = 'app-version/mismatch', + AppInstallSucceeded = 'app-install/succeeded', + AppInstallFailed = 'app-install/failed', + AppUpgradeSucceeded = 'app-upgrade/succeeded', + AppUpgradeFailed = 'app-upgrade/failed', + AppUninstallSucceeded = 'app-uninstall/succeeded', + AppUninstallFailed = 'app-uninstall/failed', + AppRegistrationCreated = 'app-registration/created', + AppRegistrationVersionPublished = 'app-registration/version-published', CronJobDeletedWorkspace = 'cron-job/deleted-workspace', JobWebhookCallCompleted = 'job/webhook-call-completed', SignUpSuccess = 'sign-up/success',