Migrate metrics to prometheus (#17810)
<img width="1316" height="611" alt="image" src="https://github.com/user-attachments/assets/277a63ed-2a8b-41ff-be78-281de8891579" />
This commit is contained in:
+7
-7
@@ -8,13 +8,13 @@ import { HEALTH_INDICATORS } from 'src/engine/core-modules/admin-panel/constants
|
||||
import { type SystemHealthDTO } from 'src/engine/core-modules/admin-panel/dtos/system-health.dto';
|
||||
import { AdminPanelHealthServiceStatus } from 'src/engine/core-modules/admin-panel/enums/admin-panel-health-service-status.enum';
|
||||
import { QueueMetricsTimeRange } from 'src/engine/core-modules/admin-panel/enums/queue-metrics-time-range.enum';
|
||||
import { HEALTH_ERROR_MESSAGES } from 'src/engine/core-modules/health/constants/health-error-messages.constants';
|
||||
import { HealthIndicatorId } from 'src/engine/core-modules/health/enums/health-indicator-id.enum';
|
||||
import { AppHealthIndicator } from 'src/engine/core-modules/health/indicators/app.health';
|
||||
import { ConnectedAccountHealth } from 'src/engine/core-modules/health/indicators/connected-account.health';
|
||||
import { DatabaseHealthIndicator } from 'src/engine/core-modules/health/indicators/database.health';
|
||||
import { RedisHealthIndicator } from 'src/engine/core-modules/health/indicators/redis.health';
|
||||
import { WorkerHealthIndicator } from 'src/engine/core-modules/health/indicators/worker.health';
|
||||
import { HEALTH_ERROR_MESSAGES } from 'src/engine/core-modules/admin-panel/constants/health-error-messages.constants';
|
||||
import { HealthIndicatorId } from 'src/engine/core-modules/admin-panel/enums/health-indicator-id.enum';
|
||||
import { AppHealthIndicator } from 'src/engine/core-modules/admin-panel/indicators/app.health';
|
||||
import { ConnectedAccountHealth } from 'src/engine/core-modules/admin-panel/indicators/connected-account.health';
|
||||
import { DatabaseHealthIndicator } from 'src/engine/core-modules/admin-panel/indicators/database.health';
|
||||
import { RedisHealthIndicator } from 'src/engine/core-modules/admin-panel/indicators/redis.health';
|
||||
import { WorkerHealthIndicator } from 'src/engine/core-modules/admin-panel/indicators/worker.health';
|
||||
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||
import { RedisClientService } from 'src/engine/core-modules/redis-client/redis-client.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import { type Repository } from 'typeorm';
|
||||
|
||||
import { AppHealthIndicator } from 'src/engine/core-modules/health/indicators/app.health';
|
||||
import { AppHealthIndicator } from 'src/engine/core-modules/admin-panel/indicators/app.health';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
describe('AppHealthIndicator', () => {
|
||||
+4
-4
@@ -1,10 +1,10 @@
|
||||
import { HealthIndicatorService } from '@nestjs/terminus';
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { HEALTH_ERROR_MESSAGES } from 'src/engine/core-modules/health/constants/health-error-messages.constants';
|
||||
import { HEALTH_INDICATORS_TIMEOUT } from 'src/engine/core-modules/health/constants/health-indicators-timeout.conts';
|
||||
import { METRICS_FAILURE_RATE_THRESHOLD } from 'src/engine/core-modules/health/constants/metrics-failure-rate-threshold.const';
|
||||
import { ConnectedAccountHealth } from 'src/engine/core-modules/health/indicators/connected-account.health';
|
||||
import { HEALTH_ERROR_MESSAGES } from 'src/engine/core-modules/admin-panel/constants/health-error-messages.constants';
|
||||
import { HEALTH_INDICATORS_TIMEOUT } from 'src/engine/core-modules/admin-panel/constants/health-indicators-timeout.conts';
|
||||
import { METRICS_FAILURE_RATE_THRESHOLD } from 'src/engine/core-modules/admin-panel/constants/metrics-failure-rate-threshold.const';
|
||||
import { ConnectedAccountHealth } from 'src/engine/core-modules/admin-panel/indicators/connected-account.health';
|
||||
import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service';
|
||||
import { CalendarChannelSyncStatus } from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity';
|
||||
import { MessageChannelSyncStatus } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
|
||||
+3
-3
@@ -4,9 +4,9 @@ import { getDataSourceToken } from '@nestjs/typeorm';
|
||||
|
||||
import { type DataSource } from 'typeorm';
|
||||
|
||||
import { HEALTH_ERROR_MESSAGES } from 'src/engine/core-modules/health/constants/health-error-messages.constants';
|
||||
import { HEALTH_INDICATORS_TIMEOUT } from 'src/engine/core-modules/health/constants/health-indicators-timeout.conts';
|
||||
import { DatabaseHealthIndicator } from 'src/engine/core-modules/health/indicators/database.health';
|
||||
import { HEALTH_ERROR_MESSAGES } from 'src/engine/core-modules/admin-panel/constants/health-error-messages.constants';
|
||||
import { HEALTH_INDICATORS_TIMEOUT } from 'src/engine/core-modules/admin-panel/constants/health-indicators-timeout.conts';
|
||||
import { DatabaseHealthIndicator } from 'src/engine/core-modules/admin-panel/indicators/database.health';
|
||||
|
||||
describe('DatabaseHealthIndicator', () => {
|
||||
let service: DatabaseHealthIndicator;
|
||||
+3
-3
@@ -3,9 +3,9 @@ import { Test, type TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { type Redis } from 'ioredis';
|
||||
|
||||
import { HEALTH_ERROR_MESSAGES } from 'src/engine/core-modules/health/constants/health-error-messages.constants';
|
||||
import { HEALTH_INDICATORS_TIMEOUT } from 'src/engine/core-modules/health/constants/health-indicators-timeout.conts';
|
||||
import { RedisHealthIndicator } from 'src/engine/core-modules/health/indicators/redis.health';
|
||||
import { HEALTH_ERROR_MESSAGES } from 'src/engine/core-modules/admin-panel/constants/health-error-messages.constants';
|
||||
import { HEALTH_INDICATORS_TIMEOUT } from 'src/engine/core-modules/admin-panel/constants/health-indicators-timeout.conts';
|
||||
import { RedisHealthIndicator } from 'src/engine/core-modules/admin-panel/indicators/redis.health';
|
||||
import { RedisClientService } from 'src/engine/core-modules/redis-client/redis-client.service';
|
||||
|
||||
describe('RedisHealthIndicator', () => {
|
||||
+3
-3
@@ -3,9 +3,9 @@ import { Test, type TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { type Redis } from 'ioredis';
|
||||
|
||||
import { HEALTH_ERROR_MESSAGES } from 'src/engine/core-modules/health/constants/health-error-messages.constants';
|
||||
import { HEALTH_INDICATORS_TIMEOUT } from 'src/engine/core-modules/health/constants/health-indicators-timeout.conts';
|
||||
import { WorkerHealthIndicator } from 'src/engine/core-modules/health/indicators/worker.health';
|
||||
import { HEALTH_ERROR_MESSAGES } from 'src/engine/core-modules/admin-panel/constants/health-error-messages.constants';
|
||||
import { HEALTH_INDICATORS_TIMEOUT } from 'src/engine/core-modules/admin-panel/constants/health-indicators-timeout.conts';
|
||||
import { WorkerHealthIndicator } from 'src/engine/core-modules/admin-panel/indicators/worker.health';
|
||||
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||
import { RedisClientService } from 'src/engine/core-modules/redis-client/redis-client.service';
|
||||
|
||||
+7
-7
@@ -12,13 +12,13 @@ import { type QueueMetricsDataDTO } from 'src/engine/core-modules/admin-panel/dt
|
||||
import { type SystemHealthDTO } from 'src/engine/core-modules/admin-panel/dtos/system-health.dto';
|
||||
import { AdminPanelHealthServiceStatus } from 'src/engine/core-modules/admin-panel/enums/admin-panel-health-service-status.enum';
|
||||
import { QueueMetricsTimeRange } from 'src/engine/core-modules/admin-panel/enums/queue-metrics-time-range.enum';
|
||||
import { HealthIndicatorId } from 'src/engine/core-modules/health/enums/health-indicator-id.enum';
|
||||
import { AppHealthIndicator } from 'src/engine/core-modules/health/indicators/app.health';
|
||||
import { ConnectedAccountHealth } from 'src/engine/core-modules/health/indicators/connected-account.health';
|
||||
import { DatabaseHealthIndicator } from 'src/engine/core-modules/health/indicators/database.health';
|
||||
import { RedisHealthIndicator } from 'src/engine/core-modules/health/indicators/redis.health';
|
||||
import { WorkerHealthIndicator } from 'src/engine/core-modules/health/indicators/worker.health';
|
||||
import { type WorkerQueueHealth } from 'src/engine/core-modules/health/types/worker-queue-health.type';
|
||||
import { HealthIndicatorId } from 'src/engine/core-modules/admin-panel/enums/health-indicator-id.enum';
|
||||
import { AppHealthIndicator } from 'src/engine/core-modules/admin-panel/indicators/app.health';
|
||||
import { ConnectedAccountHealth } from 'src/engine/core-modules/admin-panel/indicators/connected-account.health';
|
||||
import { DatabaseHealthIndicator } from 'src/engine/core-modules/admin-panel/indicators/database.health';
|
||||
import { RedisHealthIndicator } from 'src/engine/core-modules/admin-panel/indicators/redis.health';
|
||||
import { WorkerHealthIndicator } from 'src/engine/core-modules/admin-panel/indicators/worker.health';
|
||||
import { type WorkerQueueHealth } from 'src/engine/core-modules/admin-panel/types/worker-queue-health.type';
|
||||
import { type MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||
import { RedisClientService } from 'src/engine/core-modules/redis-client/redis-client.service';
|
||||
|
||||
|
||||
@@ -12,22 +12,28 @@ import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
|
||||
import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { FileModule } from 'src/engine/core-modules/file/file.module';
|
||||
import { HealthModule } from 'src/engine/core-modules/health/health.module';
|
||||
import { AppHealthIndicator } from 'src/engine/core-modules/admin-panel/indicators/app.health';
|
||||
import { ConnectedAccountHealth } from 'src/engine/core-modules/admin-panel/indicators/connected-account.health';
|
||||
import { DatabaseHealthIndicator } from 'src/engine/core-modules/admin-panel/indicators/database.health';
|
||||
import { RedisHealthIndicator } from 'src/engine/core-modules/admin-panel/indicators/redis.health';
|
||||
import { WorkerHealthIndicator } from 'src/engine/core-modules/admin-panel/indicators/worker.health';
|
||||
import { ImpersonationModule } from 'src/engine/core-modules/impersonation/impersonation.module';
|
||||
import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module';
|
||||
import { RedisClientModule } from 'src/engine/core-modules/redis-client/redis-client.module';
|
||||
import { TelemetryModule } from 'src/engine/core-modules/telemetry/telemetry.module';
|
||||
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([UserEntity]),
|
||||
TypeOrmModule.forFeature([UserEntity, WorkspaceEntity]),
|
||||
AuthModule,
|
||||
FileModule,
|
||||
WorkspaceDomainsModule,
|
||||
HealthModule,
|
||||
RedisClientModule,
|
||||
TerminusModule,
|
||||
MetricsModule,
|
||||
FeatureFlagModule,
|
||||
AuditModule,
|
||||
TelemetryModule,
|
||||
@@ -40,6 +46,11 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
|
||||
AdminPanelHealthService,
|
||||
AdminPanelQueueService,
|
||||
SecureHttpClientService,
|
||||
DatabaseHealthIndicator,
|
||||
RedisHealthIndicator,
|
||||
WorkerHealthIndicator,
|
||||
ConnectedAccountHealth,
|
||||
AppHealthIndicator,
|
||||
],
|
||||
exports: [AdminPanelService],
|
||||
})
|
||||
|
||||
@@ -25,7 +25,7 @@ import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/service
|
||||
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
|
||||
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
|
||||
import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
||||
import { HealthIndicatorId } from 'src/engine/core-modules/health/enums/health-indicator-id.enum';
|
||||
import { HealthIndicatorId } from 'src/engine/core-modules/admin-panel/enums/health-indicator-id.enum';
|
||||
import { type MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||
import { type ConfigVariables } from 'src/engine/core-modules/twenty-config/config-variables';
|
||||
import { ConfigVariableGraphqlApiExceptionFilter } from 'src/engine/core-modules/twenty-config/filters/config-variable-graphql-api-exception.filter';
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { HealthIndicatorId } from 'src/engine/core-modules/health/enums/health-indicator-id.enum';
|
||||
import { HealthIndicatorId } from 'src/engine/core-modules/admin-panel/enums/health-indicator-id.enum';
|
||||
|
||||
type HealthIndicatorInfo = {
|
||||
id: HealthIndicatorId;
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { Field } from '@nestjs/graphql';
|
||||
|
||||
import { HealthIndicatorId } from 'src/engine/core-modules/health/enums/health-indicator-id.enum';
|
||||
import { HealthIndicatorId } from 'src/engine/core-modules/admin-panel/enums/health-indicator-id.enum';
|
||||
|
||||
export class HealthIndicatorInput {
|
||||
@Field(() => HealthIndicatorId)
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { AdminPanelWorkerQueueHealthDTO } from 'src/engine/core-modules/admin-panel/dtos/admin-panel-worker-queue-health.dto';
|
||||
import { AdminPanelHealthServiceStatus } from 'src/engine/core-modules/admin-panel/enums/admin-panel-health-service-status.enum';
|
||||
import { HealthIndicatorId } from 'src/engine/core-modules/health/enums/health-indicator-id.enum';
|
||||
import { HealthIndicatorId } from 'src/engine/core-modules/admin-panel/enums/health-indicator-id.enum';
|
||||
|
||||
@ObjectType('AdminPanelHealthServiceData')
|
||||
export class AdminPanelHealthServiceDataDTO {
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { QueueMetricsSeriesDTO } from 'src/engine/core-modules/admin-panel/dtos/queue-metrics-series.dto';
|
||||
import { QueueMetricsTimeRange } from 'src/engine/core-modules/admin-panel/enums/queue-metrics-time-range.enum';
|
||||
import { WorkerQueueMetrics } from 'src/engine/core-modules/health/types/worker-queue-metrics.type';
|
||||
import { WorkerQueueMetrics } from 'src/engine/core-modules/admin-panel/types/worker-queue-metrics.type';
|
||||
|
||||
@ObjectType('QueueMetricsData')
|
||||
export class QueueMetricsDataDTO {
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { AdminPanelHealthServiceStatus } from 'src/engine/core-modules/admin-panel/enums/admin-panel-health-service-status.enum';
|
||||
import { HealthIndicatorId } from 'src/engine/core-modules/health/enums/health-indicator-id.enum';
|
||||
import { HealthIndicatorId } from 'src/engine/core-modules/admin-panel/enums/health-indicator-id.enum';
|
||||
|
||||
@ObjectType('SystemHealthService')
|
||||
export class SystemHealthServiceDTO {
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { HealthStateManager } from 'src/engine/core-modules/health/utils/health-state-manager.util';
|
||||
import { HealthStateManager } from 'src/engine/core-modules/admin-panel/utils/health-state-manager.util';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
@Injectable()
|
||||
+3
-3
@@ -4,9 +4,9 @@ import {
|
||||
HealthIndicatorService,
|
||||
} from '@nestjs/terminus';
|
||||
|
||||
import { HEALTH_ERROR_MESSAGES } from 'src/engine/core-modules/health/constants/health-error-messages.constants';
|
||||
import { METRICS_FAILURE_RATE_THRESHOLD } from 'src/engine/core-modules/health/constants/metrics-failure-rate-threshold.const';
|
||||
import { withHealthCheckTimeout } from 'src/engine/core-modules/health/utils/health-check-timeout.util';
|
||||
import { HEALTH_ERROR_MESSAGES } from 'src/engine/core-modules/admin-panel/constants/health-error-messages.constants';
|
||||
import { METRICS_FAILURE_RATE_THRESHOLD } from 'src/engine/core-modules/admin-panel/constants/metrics-failure-rate-threshold.const';
|
||||
import { withHealthCheckTimeout } from 'src/engine/core-modules/admin-panel/utils/health-check-timeout.util';
|
||||
import {
|
||||
CALENDAR_SYNC_METRICS_BY_STATUS,
|
||||
MESSAGE_SYNC_METRICS_BY_STATUS,
|
||||
+3
-3
@@ -7,9 +7,9 @@ import { InjectDataSource } from '@nestjs/typeorm';
|
||||
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { HEALTH_ERROR_MESSAGES } from 'src/engine/core-modules/health/constants/health-error-messages.constants';
|
||||
import { withHealthCheckTimeout } from 'src/engine/core-modules/health/utils/health-check-timeout.util';
|
||||
import { HealthStateManager } from 'src/engine/core-modules/health/utils/health-state-manager.util';
|
||||
import { HEALTH_ERROR_MESSAGES } from 'src/engine/core-modules/admin-panel/constants/health-error-messages.constants';
|
||||
import { withHealthCheckTimeout } from 'src/engine/core-modules/admin-panel/utils/health-check-timeout.util';
|
||||
import { HealthStateManager } from 'src/engine/core-modules/admin-panel/utils/health-state-manager.util';
|
||||
|
||||
@Injectable()
|
||||
export class DatabaseHealthIndicator {
|
||||
+3
-3
@@ -4,9 +4,9 @@ import {
|
||||
HealthIndicatorService,
|
||||
} from '@nestjs/terminus';
|
||||
|
||||
import { HEALTH_ERROR_MESSAGES } from 'src/engine/core-modules/health/constants/health-error-messages.constants';
|
||||
import { withHealthCheckTimeout } from 'src/engine/core-modules/health/utils/health-check-timeout.util';
|
||||
import { HealthStateManager } from 'src/engine/core-modules/health/utils/health-state-manager.util';
|
||||
import { HEALTH_ERROR_MESSAGES } from 'src/engine/core-modules/admin-panel/constants/health-error-messages.constants';
|
||||
import { withHealthCheckTimeout } from 'src/engine/core-modules/admin-panel/utils/health-check-timeout.util';
|
||||
import { HealthStateManager } from 'src/engine/core-modules/admin-panel/utils/health-state-manager.util';
|
||||
import { RedisClientService } from 'src/engine/core-modules/redis-client/redis-client.service';
|
||||
|
||||
@Injectable()
|
||||
+4
-4
@@ -6,10 +6,10 @@ import {
|
||||
|
||||
import { Queue } from 'bullmq';
|
||||
|
||||
import { HEALTH_ERROR_MESSAGES } from 'src/engine/core-modules/health/constants/health-error-messages.constants';
|
||||
import { METRICS_FAILURE_RATE_THRESHOLD } from 'src/engine/core-modules/health/constants/metrics-failure-rate-threshold.const';
|
||||
import { type WorkerQueueHealth } from 'src/engine/core-modules/health/types/worker-queue-health.type';
|
||||
import { withHealthCheckTimeout } from 'src/engine/core-modules/health/utils/health-check-timeout.util';
|
||||
import { HEALTH_ERROR_MESSAGES } from 'src/engine/core-modules/admin-panel/constants/health-error-messages.constants';
|
||||
import { METRICS_FAILURE_RATE_THRESHOLD } from 'src/engine/core-modules/admin-panel/constants/metrics-failure-rate-threshold.const';
|
||||
import { type WorkerQueueHealth } from 'src/engine/core-modules/admin-panel/types/worker-queue-health.type';
|
||||
import { withHealthCheckTimeout } from 'src/engine/core-modules/admin-panel/utils/health-check-timeout.util';
|
||||
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||
import { RedisClientService } from 'src/engine/core-modules/redis-client/redis-client.service';
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { WorkerQueueMetrics } from 'src/engine/core-modules/health/types/worker-queue-metrics.type';
|
||||
import { WorkerQueueMetrics } from 'src/engine/core-modules/admin-panel/types/worker-queue-metrics.type';
|
||||
|
||||
@ObjectType()
|
||||
export class WorkerQueueHealth {
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { HEALTH_INDICATORS_TIMEOUT } from 'src/engine/core-modules/health/constants/health-indicators-timeout.conts';
|
||||
import { HEALTH_INDICATORS_TIMEOUT } from 'src/engine/core-modules/admin-panel/constants/health-indicators-timeout.conts';
|
||||
|
||||
export const withHealthCheckTimeout = async <T>(
|
||||
promise: Promise<T>,
|
||||
@@ -0,0 +1,117 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { IsNull, LessThan, Repository } from 'typeorm';
|
||||
|
||||
import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
|
||||
import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
// Workspaces created less than 1 minute ago are excluded from the check
|
||||
const WORKSPACE_AGE_THRESHOLD_MS = 60 * 1000;
|
||||
|
||||
@Injectable()
|
||||
export class BillingGaugeService implements OnModuleInit {
|
||||
private readonly logger = new Logger(BillingGaugeService.name);
|
||||
|
||||
constructor(
|
||||
private readonly metricsService: MetricsService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
@InjectRepository(WorkspaceEntity)
|
||||
private readonly workspaceRepository: Repository<WorkspaceEntity>,
|
||||
@InjectRepository(BillingSubscriptionEntity)
|
||||
private readonly billingSubscriptionRepository: Repository<BillingSubscriptionEntity>,
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
this.metricsService.createObservableGauge({
|
||||
metricName: 'twenty_billing_subscribed_workspaces_total',
|
||||
options: {
|
||||
description: 'Total number of workspaces having an active subscription',
|
||||
},
|
||||
callback: async () => {
|
||||
return this.getSubscribedWorkspacesCount();
|
||||
},
|
||||
cacheValue: true,
|
||||
});
|
||||
|
||||
this.metricsService.createObservableGauge({
|
||||
metricName: 'twenty_billing_last_workspace_has_subscription',
|
||||
options: {
|
||||
description:
|
||||
'Whether the last workspace (older than 1 min) has a subscription (1 = yes, 0 = no)',
|
||||
},
|
||||
callback: async () => {
|
||||
return this.lastWorkspaceHasSubscription();
|
||||
},
|
||||
cacheValue: true,
|
||||
});
|
||||
}
|
||||
|
||||
private async getSubscribedWorkspacesCount(): Promise<number> {
|
||||
const isBillingEnabled = this.twentyConfigService.get('IS_BILLING_ENABLED');
|
||||
|
||||
if (!isBillingEnabled) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
return this.billingSubscriptionRepository.count({
|
||||
where: { deletedAt: IsNull() },
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to count subscribed workspaces', error);
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private async lastWorkspaceHasSubscription(): Promise<number> {
|
||||
const isBillingEnabled = this.twentyConfigService.get('IS_BILLING_ENABLED');
|
||||
|
||||
if (!isBillingEnabled) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
try {
|
||||
const ageThreshold = new Date(Date.now() - WORKSPACE_AGE_THRESHOLD_MS);
|
||||
|
||||
// Find the most recently created workspace that is older than 1 minute
|
||||
const lastWorkspace = await this.workspaceRepository.findOne({
|
||||
where: {
|
||||
deletedAt: IsNull(),
|
||||
createdAt: LessThan(ageThreshold),
|
||||
},
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
|
||||
if (!lastWorkspace) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const subscription = await this.billingSubscriptionRepository.findOne({
|
||||
where: {
|
||||
workspaceId: lastWorkspace.id,
|
||||
deletedAt: IsNull(),
|
||||
},
|
||||
});
|
||||
|
||||
if (!subscription) {
|
||||
this.logger.warn(
|
||||
`Billing issue: workspace ${lastWorkspace.id} has no subscription`,
|
||||
);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
return 1;
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to check last workspace subscription', error);
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { BillingGaugeService } from 'src/engine/core-modules/billing/billing-gauge.service';
|
||||
import { BillingResolver } from 'src/engine/core-modules/billing/billing.resolver';
|
||||
import { BillingSyncCustomerDataCommand } from 'src/engine/core-modules/billing/commands/billing-sync-customer-data.command';
|
||||
import { BillingSyncPlansDataCommand } from 'src/engine/core-modules/billing/commands/billing-sync-plans-data.command';
|
||||
@@ -34,6 +35,7 @@ import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace
|
||||
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { MessageQueueModule } from 'src/engine/core-modules/message-queue/message-queue.module';
|
||||
import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AiBillingModule } from 'src/engine/metadata-modules/ai/ai-billing/ai-billing.module';
|
||||
@@ -63,6 +65,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
|
||||
FeatureFlagEntity,
|
||||
]),
|
||||
DataSourceModule,
|
||||
MetricsModule,
|
||||
],
|
||||
providers: [
|
||||
BillingSubscriptionService,
|
||||
@@ -84,6 +87,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
|
||||
BillingPriceService,
|
||||
BillingCreditRolloverService,
|
||||
MeteredCreditService,
|
||||
BillingGaugeService,
|
||||
],
|
||||
exports: [
|
||||
BillingSubscriptionService,
|
||||
|
||||
+1
@@ -5,5 +5,6 @@ export enum CacheStorageNamespace {
|
||||
EngineWorkspace = 'engine:workspace',
|
||||
EngineLock = 'engine:lock',
|
||||
EngineHealth = 'engine:health',
|
||||
EngineMetrics = 'engine:metrics',
|
||||
EngineSubscriptions = 'engine:subscriptions',
|
||||
}
|
||||
|
||||
-25
@@ -2,11 +2,6 @@ import { HealthCheckService } from '@nestjs/terminus';
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { HealthController } from 'src/engine/core-modules/health/controllers/health.controller';
|
||||
import { AppHealthIndicator } from 'src/engine/core-modules/health/indicators/app.health';
|
||||
import { ConnectedAccountHealth } from 'src/engine/core-modules/health/indicators/connected-account.health';
|
||||
import { DatabaseHealthIndicator } from 'src/engine/core-modules/health/indicators/database.health';
|
||||
import { RedisHealthIndicator } from 'src/engine/core-modules/health/indicators/redis.health';
|
||||
import { WorkerHealthIndicator } from 'src/engine/core-modules/health/indicators/worker.health';
|
||||
|
||||
describe('HealthController', () => {
|
||||
let healthController: HealthController;
|
||||
@@ -19,26 +14,6 @@ describe('HealthController', () => {
|
||||
provide: HealthCheckService,
|
||||
useValue: { check: jest.fn() },
|
||||
},
|
||||
{
|
||||
provide: DatabaseHealthIndicator,
|
||||
useValue: { isHealthy: jest.fn() },
|
||||
},
|
||||
{
|
||||
provide: RedisHealthIndicator,
|
||||
useValue: { isHealthy: jest.fn() },
|
||||
},
|
||||
{
|
||||
provide: WorkerHealthIndicator,
|
||||
useValue: { isHealthy: jest.fn() },
|
||||
},
|
||||
{
|
||||
provide: ConnectedAccountHealth,
|
||||
useValue: { isHealthy: jest.fn() },
|
||||
},
|
||||
{
|
||||
provide: AppHealthIndicator,
|
||||
useValue: { isHealthy: jest.fn() },
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
|
||||
+2
-41
@@ -1,31 +1,12 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||
import { HealthCheck, HealthCheckService } from '@nestjs/terminus';
|
||||
|
||||
import { HealthIndicatorId } from 'src/engine/core-modules/health/enums/health-indicator-id.enum';
|
||||
import { AppHealthIndicator } from 'src/engine/core-modules/health/indicators/app.health';
|
||||
import { ConnectedAccountHealth } from 'src/engine/core-modules/health/indicators/connected-account.health';
|
||||
import { DatabaseHealthIndicator } from 'src/engine/core-modules/health/indicators/database.health';
|
||||
import { RedisHealthIndicator } from 'src/engine/core-modules/health/indicators/redis.health';
|
||||
import { WorkerHealthIndicator } from 'src/engine/core-modules/health/indicators/worker.health';
|
||||
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
|
||||
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
|
||||
|
||||
@Controller('healthz')
|
||||
export class HealthController {
|
||||
constructor(
|
||||
private readonly health: HealthCheckService,
|
||||
private readonly databaseHealth: DatabaseHealthIndicator,
|
||||
private readonly redisHealth: RedisHealthIndicator,
|
||||
private readonly workerHealth: WorkerHealthIndicator,
|
||||
private readonly connectedAccountHealth: ConnectedAccountHealth,
|
||||
private readonly appHealth: AppHealthIndicator,
|
||||
) {}
|
||||
constructor(private readonly health: HealthCheckService) {}
|
||||
|
||||
@Get()
|
||||
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
|
||||
@@ -33,24 +14,4 @@ export class HealthController {
|
||||
check() {
|
||||
return this.health.check([]);
|
||||
}
|
||||
|
||||
@Get(':indicatorId')
|
||||
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
|
||||
@HealthCheck()
|
||||
checkService(@Param('indicatorId') indicatorId: HealthIndicatorId) {
|
||||
const checks = {
|
||||
[HealthIndicatorId.database]: () => this.databaseHealth.isHealthy(),
|
||||
[HealthIndicatorId.redis]: () => this.redisHealth.isHealthy(),
|
||||
[HealthIndicatorId.worker]: () => this.workerHealth.isHealthy(),
|
||||
[HealthIndicatorId.connectedAccount]: () =>
|
||||
this.connectedAccountHealth.isHealthy(),
|
||||
[HealthIndicatorId.app]: () => this.appHealth.isHealthy(),
|
||||
};
|
||||
|
||||
if (!(indicatorId in checks)) {
|
||||
throw new BadRequestException(`Invalid indicatorId: ${indicatorId}`);
|
||||
}
|
||||
|
||||
return this.health.check([checks[indicatorId]]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TerminusModule } from '@nestjs/terminus';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { HealthController } from 'src/engine/core-modules/health/controllers/health.controller';
|
||||
import { AppHealthIndicator } from 'src/engine/core-modules/health/indicators/app.health';
|
||||
import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module';
|
||||
import { RedisClientModule } from 'src/engine/core-modules/redis-client/redis-client.module';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
import { ConnectedAccountHealth } from './indicators/connected-account.health';
|
||||
import { DatabaseHealthIndicator } from './indicators/database.health';
|
||||
import { RedisHealthIndicator } from './indicators/redis.health';
|
||||
import { WorkerHealthIndicator } from './indicators/worker.health';
|
||||
@Module({
|
||||
imports: [
|
||||
TerminusModule,
|
||||
RedisClientModule,
|
||||
TypeOrmModule.forFeature([WorkspaceEntity]),
|
||||
MetricsModule,
|
||||
],
|
||||
imports: [TerminusModule],
|
||||
controllers: [HealthController],
|
||||
providers: [
|
||||
DatabaseHealthIndicator,
|
||||
RedisHealthIndicator,
|
||||
WorkerHealthIndicator,
|
||||
ConnectedAccountHealth,
|
||||
AppHealthIndicator,
|
||||
],
|
||||
exports: [
|
||||
DatabaseHealthIndicator,
|
||||
RedisHealthIndicator,
|
||||
WorkerHealthIndicator,
|
||||
ConnectedAccountHealth,
|
||||
AppHealthIndicator,
|
||||
],
|
||||
})
|
||||
export class HealthModule {}
|
||||
|
||||
+10
-6
@@ -52,15 +52,17 @@ export class BullMQDriver
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
this.metricsService.createObservableGauge(
|
||||
'twenty_queue_jobs_waiting_total',
|
||||
{ description: 'Current number of jobs waiting in queue' },
|
||||
async (observableResult) => {
|
||||
this.metricsService.createObservableGauge({
|
||||
metricName: 'twenty_queue_jobs_waiting_total',
|
||||
options: { description: 'Current number of jobs waiting in queue' },
|
||||
callback: async () => {
|
||||
let totalWaiting = 0;
|
||||
|
||||
for (const [queueName, queue] of Object.entries(this.queueMap)) {
|
||||
try {
|
||||
const waitingCount = await queue.count();
|
||||
|
||||
observableResult.observe(waitingCount, { queue: queueName });
|
||||
totalWaiting += waitingCount;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to collect waiting jobs metrics for queue ${queueName}`,
|
||||
@@ -68,8 +70,10 @@ export class BullMQDriver
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return totalWaiting;
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
register(queueName: MessageQueue): void {
|
||||
|
||||
@@ -14,7 +14,7 @@ export class MetricsCacheService {
|
||||
private readonly healthCacheTtl: number;
|
||||
|
||||
constructor(
|
||||
@InjectCacheStorage(CacheStorageNamespace.EngineHealth)
|
||||
@InjectCacheStorage(CacheStorageNamespace.EngineMetrics)
|
||||
private readonly cacheStorage: CacheStorageService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
metrics,
|
||||
@@ -6,30 +6,76 @@ import {
|
||||
type Meter,
|
||||
type MetricOptions,
|
||||
type ObservableGauge,
|
||||
type ObservableResult,
|
||||
} from '@opentelemetry/api';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { InjectCacheStorage } from 'src/engine/core-modules/cache-storage/decorators/cache-storage.decorator';
|
||||
import { CacheStorageService } from 'src/engine/core-modules/cache-storage/services/cache-storage.service';
|
||||
import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum';
|
||||
import { MetricsCacheService } from 'src/engine/core-modules/metrics/metrics-cache.service';
|
||||
import { type MetricsKeys } from 'src/engine/core-modules/metrics/types/metrics-keys.type';
|
||||
|
||||
const METER_NAME = 'twenty-server';
|
||||
const METRICS_CACHE_TTL = 60 * 1000; // 1 minute
|
||||
|
||||
@Injectable()
|
||||
export class MetricsService {
|
||||
constructor(private readonly metricsCacheService: MetricsCacheService) {}
|
||||
private readonly logger = new Logger(MetricsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly metricsCacheService: MetricsCacheService,
|
||||
@InjectCacheStorage(CacheStorageNamespace.EngineHealth)
|
||||
private readonly healthCacheStorage: CacheStorageService,
|
||||
) {}
|
||||
|
||||
getMeter(): Meter {
|
||||
return metrics.getMeter(METER_NAME);
|
||||
}
|
||||
|
||||
createObservableGauge(
|
||||
name: string,
|
||||
options: MetricOptions,
|
||||
callback: (observableResult: ObservableResult) => void | Promise<void>,
|
||||
): ObservableGauge {
|
||||
const gauge = this.getMeter().createObservableGauge(name, options);
|
||||
createObservableGauge({
|
||||
metricName,
|
||||
options,
|
||||
callback,
|
||||
cacheValue = false,
|
||||
}: {
|
||||
metricName: string;
|
||||
options: MetricOptions;
|
||||
callback: () => number | Promise<number>;
|
||||
cacheValue?: boolean;
|
||||
}): ObservableGauge {
|
||||
const gauge = this.getMeter().createObservableGauge(metricName, options);
|
||||
|
||||
gauge.addCallback(callback);
|
||||
gauge.addCallback(async (observableResult) => {
|
||||
if (cacheValue) {
|
||||
const cachedResult =
|
||||
await this.healthCacheStorage.get<number>(metricName);
|
||||
|
||||
if (isDefined(cachedResult)) {
|
||||
observableResult.observe(cachedResult);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await callback();
|
||||
|
||||
observableResult.observe(result);
|
||||
|
||||
if (cacheValue) {
|
||||
await this.healthCacheStorage.set(
|
||||
metricName,
|
||||
result,
|
||||
METRICS_CACHE_TTL,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to collect gauge metric ${metricName}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return gauge;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { IsNull, Not, Repository } from 'typeorm';
|
||||
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
|
||||
|
||||
import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceGaugeService implements OnModuleInit {
|
||||
private readonly logger = new Logger(WorkspaceGaugeService.name);
|
||||
|
||||
constructor(
|
||||
private readonly metricsService: MetricsService,
|
||||
@InjectRepository(WorkspaceEntity)
|
||||
private readonly workspaceRepository: Repository<WorkspaceEntity>,
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
for (const status of Object.values(WorkspaceActivationStatus)) {
|
||||
this.metricsService.createObservableGauge({
|
||||
metricName: `twenty_workspaces_by_status_${status.toLowerCase()}`,
|
||||
options: {
|
||||
description: `Number of workspaces with activation status ${status}`,
|
||||
},
|
||||
callback: async () => {
|
||||
return this.getWorkspaceCountByStatus(status);
|
||||
},
|
||||
cacheValue: true,
|
||||
});
|
||||
}
|
||||
|
||||
this.metricsService.createObservableGauge({
|
||||
metricName: 'twenty_workspaces_deleted_total',
|
||||
options: {
|
||||
description: 'Total number of soft-deleted workspaces',
|
||||
},
|
||||
callback: async () => {
|
||||
return this.getDeletedWorkspacesCount();
|
||||
},
|
||||
cacheValue: true,
|
||||
});
|
||||
}
|
||||
|
||||
private async getWorkspaceCountByStatus(
|
||||
status: WorkspaceActivationStatus,
|
||||
): Promise<number> {
|
||||
try {
|
||||
return this.workspaceRepository.count({
|
||||
where: {
|
||||
activationStatus: status,
|
||||
deletedAt: IsNull(),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to count workspaces with status ${status}`,
|
||||
error,
|
||||
);
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private async getDeletedWorkspacesCount(): Promise<number> {
|
||||
try {
|
||||
return this.workspaceRepository.count({
|
||||
where: { deletedAt: Not(IsNull()) },
|
||||
withDeleted: true,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to count deleted workspaces', error);
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
|
||||
|
||||
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
|
||||
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
|
||||
import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module';
|
||||
import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
|
||||
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
|
||||
import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
|
||||
@@ -27,6 +28,7 @@ import { CheckCustomDomainValidRecordsCronJob } from 'src/engine/core-modules/wo
|
||||
import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service';
|
||||
import { workspaceAutoResolverOpts } from 'src/engine/core-modules/workspace/workspace.auto-resolver-opts';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { WorkspaceGaugeService } from 'src/engine/core-modules/workspace/workspace-gauge.service';
|
||||
import { WorkspaceResolver } from 'src/engine/core-modules/workspace/workspace.resolver';
|
||||
import { AiAgentModule } from 'src/engine/metadata-modules/ai/ai-agent/ai-agent.module';
|
||||
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
|
||||
@@ -42,7 +44,8 @@ import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-m
|
||||
@Module({
|
||||
imports: [
|
||||
TypeORMModule,
|
||||
TypeOrmModule.forFeature([BillingSubscriptionEntity]),
|
||||
TypeOrmModule.forFeature([BillingSubscriptionEntity, WorkspaceEntity]),
|
||||
MetricsModule,
|
||||
NestjsQueryGraphQLModule.forFeature({
|
||||
imports: [
|
||||
AuditModule,
|
||||
@@ -84,6 +87,7 @@ import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-m
|
||||
providers: [
|
||||
WorkspaceResolver,
|
||||
WorkspaceService,
|
||||
WorkspaceGaugeService,
|
||||
CheckCustomDomainValidRecordsCronCommand,
|
||||
CheckCustomDomainValidRecordsCronJob,
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user