feat(server): app lifecycle metrics (install/uninstall/upgrade + marketplace publish) (#22656)
## What Adds product metrics for the app/marketplace lifecycle so they can be graphed in Grafana. No app-lifecycle metrics existed before; the flows only logged. ### New counters (`MetricsKeys`) - `app-install/succeeded` · `app-install/failed` - `app-upgrade/succeeded` · `app-upgrade/failed` - `app-uninstall/succeeded` · `app-uninstall/failed` - `app-registration/created` (new app published) · `app-registration/version-published` (new version available) All carry `universalIdentifier`, `appName`, `sourceType` attributes (plus `version`, and `errorCode` on failures). ### New gauge - `twenty_app_installed_workspaces_total` — observable gauge emitting the top 100 external apps by installed-workspace count (excludes built-in LOCAL apps). Powers a "most installed apps" leaderboard; combine with the 24h install/uninstall event counters for recent activity. ## Where metrics are emitted - **Install / upgrade** (`ApplicationInstallService.doInstallApplication`): success + failure branches, distinguished by the existing `isVersionUpgrade` flag. - **Uninstall** (`ApplicationInstallResolver.uninstallApplication`): at the resolver, deliberately *not* in the sync service, so rollback-triggered internal uninstalls (fired from the install catch block) don't pollute uninstall counts. - **Publish / new version**: `upsertFromCatalog` (npm marketplace sync), `checkForUpdates` (npm version poll), and the tarball CLI publish path. ### Exactly-once version-published Both the catalog-sync and version-check crons converge `latestAvailableVersion`. Each emission point is **change-guarded** (`stored !== incoming`), so whichever cron observes the change first emits, and the other becomes a no-op. No double counting, no race-dependent misses. ## Pipeline Metrics flow through the existing OTel -> ClickHouse path and can be graphed from the `twenty-product-metrics` dashboard (dashboard changes live in infra-twenty, not this PR). ## Test plan - [x] `nx typecheck twenty-server` - [x] oxlint + oxfmt on changed files - [x] `oauth-discovery.controller.spec` (the one existing spec touching these services) passes - [ ] Reviewer: sanity-check metric names/attributes and cardinality choices (no `workspaceId` attribute, LOCAL apps excluded from the gauge) <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22656?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: martmull <martmull@hotmail.fr> Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
+53
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
+4
@@ -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,
|
||||
|
||||
+41
-3
@@ -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;
|
||||
|
||||
+98
-20
@@ -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<boolean> {
|
||||
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<boolean> {
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -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: [
|
||||
|
||||
+79
-9
@@ -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<void> {
|
||||
@@ -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<boolean> {
|
||||
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<ApplicationRegistrationEntity[]> {
|
||||
@@ -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();
|
||||
|
||||
+20
@@ -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 },
|
||||
});
|
||||
|
||||
+5
-7
@@ -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,
|
||||
|
||||
+17
-6
@@ -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<ApplicationRegistrationEntity>,
|
||||
@InjectRepository(ApplicationEntity)
|
||||
private readonly applicationRepository: Repository<ApplicationEntity>,
|
||||
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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user