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:
Paul Rastoin
2026-07-16 15:53:59 +02:00
committed by GitHub
parent a5108d512f
commit 7939cd8684
12 changed files with 377 additions and 45 deletions
@@ -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,
});
}
}
@@ -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,
@@ -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;
@@ -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,
);
}
}
}
@@ -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: [
@@ -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();
@@ -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 },
});
@@ -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,
@@ -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',