set up metrics collecting with open telemetry (#11236)
Done : - move metrics and health cache services from health module to metrics module - refactor metrics counter from specific method to set up from enum keys - add OpenTelemetry (Otel) instrumentation for metrics - set up Otel SDK to send metrics to Otel collector To do later : - implement Otel instrumentation for traces + plug Sentry on top
This commit is contained in:
@ -21,10 +21,6 @@ export const SettingsAdminHealthAccountSyncCountersTable = ({
|
||||
}
|
||||
|
||||
const items = [
|
||||
{
|
||||
label: 'Not Synced',
|
||||
value: details.counters.NOT_SYNCED,
|
||||
},
|
||||
{
|
||||
label: 'Active Sync',
|
||||
value: details.counters.ACTIVE,
|
||||
|
||||
@ -40,6 +40,7 @@ FRONTEND_URL=http://localhost:3001
|
||||
# LOGGER_DRIVER=console
|
||||
# LOGGER_IS_BUFFER_ENABLED=true
|
||||
# EXCEPTION_HANDLER_DRIVER=sentry
|
||||
# METER_DRIVER=opentelemetry,console
|
||||
# SENTRY_ENVIRONMENT=main
|
||||
# SENTRY_RELEASE=latest
|
||||
# SENTRY_DSN=https://xxx@xxx.ingest.sentry.io/xxx
|
||||
|
||||
@ -25,6 +25,9 @@
|
||||
"@nestjs/devtools-integration": "^0.1.6",
|
||||
"@nestjs/graphql": "patch:@nestjs/graphql@12.1.1#./patches/@nestjs+graphql+12.1.1.patch",
|
||||
"@node-saml/passport-saml": "^5.0.0",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/exporter-metrics-otlp-http": "^0.200.0",
|
||||
"@opentelemetry/sdk-metrics": "^2.0.0",
|
||||
"@ptc-org/nestjs-query-graphql": "patch:@ptc-org/nestjs-query-graphql@4.2.0#./patches/@ptc-org+nestjs-query-graphql+4.2.0.patch",
|
||||
"@revertdotdev/revert-react": "^0.0.21",
|
||||
"@sentry/nestjs": "^8.30.0",
|
||||
|
||||
@ -30,9 +30,9 @@ import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.e
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module';
|
||||
import { GuardRedirectModule } from 'src/engine/core-modules/guard-redirect/guard-redirect.module';
|
||||
import { HealthModule } from 'src/engine/core-modules/health/health.module';
|
||||
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
|
||||
import { KeyValuePair } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
|
||||
import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module';
|
||||
import { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.module';
|
||||
import { WorkspaceSSOModule } from 'src/engine/core-modules/sso/sso.module';
|
||||
import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
|
||||
@ -90,7 +90,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
|
||||
WorkspaceInvitationModule,
|
||||
EmailVerificationModule,
|
||||
GuardRedirectModule,
|
||||
HealthModule,
|
||||
MetricsModule,
|
||||
PermissionsModule,
|
||||
UserRoleModule,
|
||||
],
|
||||
|
||||
@ -7,13 +7,14 @@ import {
|
||||
import { GqlExecutionContext } from '@nestjs/graphql';
|
||||
|
||||
import { CaptchaService } from 'src/engine/core-modules/captcha/captcha.service';
|
||||
import { HealthCacheService } from 'src/engine/core-modules/health/health-cache.service';
|
||||
import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service';
|
||||
import { MetricsKeys } from 'src/engine/core-modules/metrics/types/metrics-keys.type';
|
||||
|
||||
@Injectable()
|
||||
export class CaptchaGuard implements CanActivate {
|
||||
constructor(
|
||||
private captchaService: CaptchaService,
|
||||
private healthCacheService: HealthCacheService,
|
||||
private metricsService: MetricsService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
@ -26,7 +27,10 @@ export class CaptchaGuard implements CanActivate {
|
||||
if (result.success) {
|
||||
return true;
|
||||
} else {
|
||||
await this.healthCacheService.updateInvalidCaptchaCache(token);
|
||||
await this.metricsService.incrementCounter({
|
||||
key: MetricsKeys.InvalidCaptcha,
|
||||
eventId: token,
|
||||
});
|
||||
|
||||
throw new BadRequestException(
|
||||
'Invalid Captcha, please try another device',
|
||||
|
||||
@ -54,61 +54,67 @@ export const ENVIRONMENT_VARIABLES_GROUP_METADATA: Record<
|
||||
'By default, exceptions are sent to the logs. This should be enough for most self-hosting use-cases. For our cloud app we use Sentry.',
|
||||
isHiddenOnLoad: true,
|
||||
},
|
||||
[EnvironmentVariablesGroup.Other]: {
|
||||
[EnvironmentVariablesGroup.Metering]: {
|
||||
position: 900,
|
||||
description:
|
||||
'By default, metrics are sent to the console. OpenTelemetry collector can be set up for self-hosting use-cases.',
|
||||
isHiddenOnLoad: true,
|
||||
},
|
||||
[EnvironmentVariablesGroup.Other]: {
|
||||
position: 1000,
|
||||
description:
|
||||
"The variables in this section are mostly used for internal purposes (running our Cloud offering), but shouldn't usually be required for a simple self-hosted instance",
|
||||
isHiddenOnLoad: true,
|
||||
},
|
||||
[EnvironmentVariablesGroup.BillingConfig]: {
|
||||
position: 1000,
|
||||
position: 1100,
|
||||
description:
|
||||
'We use Stripe in our Cloud app to charge customers. Not relevant to Self-hosters.',
|
||||
isHiddenOnLoad: true,
|
||||
},
|
||||
[EnvironmentVariablesGroup.CaptchaConfig]: {
|
||||
position: 1100,
|
||||
position: 1200,
|
||||
description:
|
||||
'This protects critical endpoints like login and signup with a captcha to prevent bot attacks. Likely unnecessary for self-hosting scenarios.',
|
||||
isHiddenOnLoad: true,
|
||||
},
|
||||
[EnvironmentVariablesGroup.CloudflareConfig]: {
|
||||
position: 1200,
|
||||
position: 1300,
|
||||
description: '',
|
||||
isHiddenOnLoad: true,
|
||||
},
|
||||
[EnvironmentVariablesGroup.LLM]: {
|
||||
position: 1300,
|
||||
position: 1400,
|
||||
description:
|
||||
'Configure the LLM provider and model to use for the app. This is experimental and not linked to any public feature.',
|
||||
isHiddenOnLoad: true,
|
||||
},
|
||||
[EnvironmentVariablesGroup.ServerlessConfig]: {
|
||||
position: 1400,
|
||||
position: 1500,
|
||||
description:
|
||||
'In our multi-tenant cloud app, we offload untrusted custom code from workflows to a serverless system (Lambda) for enhanced security and scalability. Self-hosters with a single tenant can typically ignore this configuration.',
|
||||
isHiddenOnLoad: true,
|
||||
},
|
||||
[EnvironmentVariablesGroup.SSL]: {
|
||||
position: 1500,
|
||||
position: 1600,
|
||||
description:
|
||||
'Configure this if you want to setup SSL on your server or full end-to-end encryption. If you just want basic HTTPS, a simple setup like Cloudflare in flexible mode might be easier.',
|
||||
isHiddenOnLoad: true,
|
||||
},
|
||||
[EnvironmentVariablesGroup.SupportChatConfig]: {
|
||||
position: 1600,
|
||||
position: 1700,
|
||||
description:
|
||||
'We use this to setup a small support chat on the bottom left. Currently powered by Front.',
|
||||
isHiddenOnLoad: true,
|
||||
},
|
||||
[EnvironmentVariablesGroup.AnalyticsConfig]: {
|
||||
position: 1700,
|
||||
position: 1800,
|
||||
description:
|
||||
'We’re running a test to perform analytics within the app. This will evolve.',
|
||||
isHiddenOnLoad: true,
|
||||
},
|
||||
[EnvironmentVariablesGroup.TokensDuration]: {
|
||||
position: 1800,
|
||||
position: 1900,
|
||||
description:
|
||||
'These have been set to sensible default so you probably don’t need to change them unless you have a specific use-case.',
|
||||
isHiddenOnLoad: true,
|
||||
|
||||
@ -0,0 +1,21 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
|
||||
import { MeterDriver } from 'src/engine/core-modules/metrics/types/meter-driver.type';
|
||||
|
||||
export const CastToMeterDriverArray = () =>
|
||||
Transform(({ value }: { value: string }) => toMeterDriverArray(value));
|
||||
|
||||
const toMeterDriverArray = (value: string | undefined) => {
|
||||
if (typeof value === 'string') {
|
||||
const rawMeterDrivers = value.split(',').map((driver) => driver.trim());
|
||||
const isInvalid = rawMeterDrivers.some(
|
||||
(driver) => !Object.values(MeterDriver).includes(driver as MeterDriver),
|
||||
);
|
||||
|
||||
if (!isInvalid) {
|
||||
return rawMeterDrivers;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
@ -6,6 +6,7 @@ export enum EnvironmentVariablesGroup {
|
||||
MicrosoftAuth = 'microsoft-auth',
|
||||
EmailSettings = 'email-settings',
|
||||
Logging = 'logging',
|
||||
Metering = 'metering',
|
||||
ExceptionHandler = 'exception-handler',
|
||||
Other = 'other',
|
||||
BillingConfig = 'billing-config',
|
||||
|
||||
@ -25,6 +25,7 @@ import { LLMTracingDriver } from 'src/engine/core-modules/llm-tracing/interfaces
|
||||
import { CaptchaDriverType } from 'src/engine/core-modules/captcha/interfaces';
|
||||
import { CastToBoolean } from 'src/engine/core-modules/environment/decorators/cast-to-boolean.decorator';
|
||||
import { CastToLogLevelArray } from 'src/engine/core-modules/environment/decorators/cast-to-log-level-array.decorator';
|
||||
import { CastToMeterDriverArray } from 'src/engine/core-modules/environment/decorators/cast-to-meter-driver.decorator';
|
||||
import { CastToPositiveNumber } from 'src/engine/core-modules/environment/decorators/cast-to-positive-number.decorator';
|
||||
import { EnvironmentVariablesMetadata } from 'src/engine/core-modules/environment/decorators/environment-variables-metadata.decorator';
|
||||
import { IsAWSRegion } from 'src/engine/core-modules/environment/decorators/is-aws-region.decorator';
|
||||
@ -36,6 +37,7 @@ import { EnvironmentVariablesGroup } from 'src/engine/core-modules/environment/e
|
||||
import { ExceptionHandlerDriver } from 'src/engine/core-modules/exception-handler/interfaces';
|
||||
import { StorageDriverType } from 'src/engine/core-modules/file-storage/interfaces';
|
||||
import { LoggerDriverType } from 'src/engine/core-modules/logger/interfaces';
|
||||
import { MeterDriver } from 'src/engine/core-modules/metrics/types/meter-driver.type';
|
||||
import { ServerlessDriverType } from 'src/engine/core-modules/serverless/serverless.interface';
|
||||
|
||||
export class EnvironmentVariables {
|
||||
@ -585,6 +587,22 @@ export class EnvironmentVariables {
|
||||
@IsOptional()
|
||||
LOG_LEVELS: LogLevel[] = ['log', 'error', 'warn'];
|
||||
|
||||
@EnvironmentVariablesMetadata({
|
||||
group: EnvironmentVariablesGroup.Metering,
|
||||
description: 'Driver used for collect metrics (OpenTelemetry or Console)',
|
||||
})
|
||||
@CastToMeterDriverArray()
|
||||
@IsOptional()
|
||||
METER_DRIVER: MeterDriver[] = [MeterDriver.Console];
|
||||
|
||||
@EnvironmentVariablesMetadata({
|
||||
group: EnvironmentVariablesGroup.Metering,
|
||||
description: 'Endpoint URL for the OpenTelemetry collector',
|
||||
})
|
||||
@ValidateIf((env) => env.METER_DRIVER.includes(MeterDriver.OpenTelemetry))
|
||||
@IsOptional()
|
||||
OTLP_COLLECTOR_ENDPOINT_URL: string;
|
||||
|
||||
@EnvironmentVariablesMetadata({
|
||||
group: EnvironmentVariablesGroup.ExceptionHandler,
|
||||
description: 'Driver used for logging (only console for now)',
|
||||
|
||||
@ -1,30 +0,0 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { MetricsController } from 'src/engine/core-modules/health/controllers/metrics.controller';
|
||||
import { HealthCacheService } from 'src/engine/core-modules/health/health-cache.service';
|
||||
|
||||
describe('MetricsController', () => {
|
||||
let metricsController: MetricsController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const testingModule: TestingModule = await Test.createTestingModule({
|
||||
controllers: [MetricsController],
|
||||
providers: [
|
||||
{
|
||||
provide: HealthCacheService,
|
||||
useValue: {
|
||||
getMessageChannelSyncJobByStatusCounter: jest.fn(),
|
||||
getCalendarChannelSyncJobByStatusCounter: jest.fn(),
|
||||
getInvalidCaptchaCounter: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
metricsController = testingModule.get<MetricsController>(MetricsController);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(metricsController).toBeDefined();
|
||||
});
|
||||
});
|
||||
@ -1,28 +0,0 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
|
||||
import { HealthCacheService } from 'src/engine/core-modules/health/health-cache.service';
|
||||
import { HealthCounterCacheKeys } from 'src/engine/core-modules/health/types/health-counter-cache-keys.type';
|
||||
|
||||
@Controller('metrics')
|
||||
export class MetricsController {
|
||||
constructor(private readonly healthCacheService: HealthCacheService) {}
|
||||
|
||||
@Get('/message-channel-sync-job-by-status-counter')
|
||||
getMessageChannelSyncJobByStatusCounter() {
|
||||
return this.healthCacheService.countChannelSyncJobByStatus(
|
||||
HealthCounterCacheKeys.MessageChannelSyncJobByStatus,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('/invalid-captcha-counter')
|
||||
getInvalidCaptchaCounter() {
|
||||
return this.healthCacheService.getInvalidCaptchaCounter();
|
||||
}
|
||||
|
||||
@Get('/calendar-channel-sync-job-by-status-counter')
|
||||
getCalendarChannelSyncJobByStatusCounter() {
|
||||
return this.healthCacheService.countChannelSyncJobByStatus(
|
||||
HealthCounterCacheKeys.CalendarEventSyncJobByStatus,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,139 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
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 { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { AccountSyncJobByStatusCounter } from 'src/engine/core-modules/health/types/account-sync-metrics.types';
|
||||
import { HealthCounterCacheKeys } from 'src/engine/core-modules/health/types/health-counter-cache-keys.type';
|
||||
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';
|
||||
|
||||
const CACHE_BUCKET_DURATION_MS = 15000; // 15 seconds window for each cache bucket
|
||||
|
||||
@Injectable()
|
||||
export class HealthCacheService {
|
||||
private readonly healthMetricsTimeWindowInMinutes: number;
|
||||
private readonly healthCacheTtl: number;
|
||||
|
||||
constructor(
|
||||
@InjectCacheStorage(CacheStorageNamespace.EngineHealth)
|
||||
private readonly cacheStorage: CacheStorageService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
) {
|
||||
this.healthMetricsTimeWindowInMinutes = this.environmentService.get(
|
||||
'HEALTH_METRICS_TIME_WINDOW_IN_MINUTES',
|
||||
);
|
||||
this.healthCacheTtl = this.healthMetricsTimeWindowInMinutes * 60000 * 2;
|
||||
}
|
||||
|
||||
private getCacheBucketStartTimestamp(timestamp: number): number {
|
||||
return (
|
||||
Math.floor(timestamp / CACHE_BUCKET_DURATION_MS) *
|
||||
CACHE_BUCKET_DURATION_MS
|
||||
);
|
||||
}
|
||||
|
||||
private getCacheKeyWithTimestamp(key: string, timestamp?: number): string {
|
||||
const currentIntervalTimestamp =
|
||||
timestamp ?? this.getCacheBucketStartTimestamp(Date.now());
|
||||
|
||||
return `${key}:${currentIntervalTimestamp}`;
|
||||
}
|
||||
|
||||
private getLastCacheBucketStartTimestampsFromDate(
|
||||
cacheBucketsCount: number,
|
||||
date: number = Date.now(),
|
||||
): number[] {
|
||||
const currentIntervalTimestamp = this.getCacheBucketStartTimestamp(date);
|
||||
|
||||
return Array.from(
|
||||
{ length: cacheBucketsCount },
|
||||
(_, i) => currentIntervalTimestamp - i * CACHE_BUCKET_DURATION_MS,
|
||||
);
|
||||
}
|
||||
|
||||
async updateMessageOrCalendarChannelSyncJobByStatusCache(
|
||||
key: HealthCounterCacheKeys,
|
||||
status: MessageChannelSyncStatus | CalendarChannelSyncStatus,
|
||||
messageChannelIds: string[],
|
||||
) {
|
||||
return await this.cacheStorage.setAdd(
|
||||
this.getCacheKeyWithTimestamp(`${key}:${status}`),
|
||||
messageChannelIds,
|
||||
this.healthCacheTtl,
|
||||
);
|
||||
}
|
||||
|
||||
async countChannelSyncJobByStatus(
|
||||
key:
|
||||
| HealthCounterCacheKeys.MessageChannelSyncJobByStatus
|
||||
| HealthCounterCacheKeys.CalendarEventSyncJobByStatus,
|
||||
timeWindowInSeconds: number = this.healthMetricsTimeWindowInMinutes * 60,
|
||||
): Promise<AccountSyncJobByStatusCounter> {
|
||||
if ((timeWindowInSeconds * 1000) % CACHE_BUCKET_DURATION_MS !== 0) {
|
||||
throw new Error(
|
||||
`Time window must be divisible by ${CACHE_BUCKET_DURATION_MS}`,
|
||||
);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const countByStatus = {} as AccountSyncJobByStatusCounter;
|
||||
const statuses =
|
||||
key === HealthCounterCacheKeys.MessageChannelSyncJobByStatus
|
||||
? Object.values(MessageChannelSyncStatus).filter(
|
||||
(status) => status !== MessageChannelSyncStatus.ONGOING,
|
||||
)
|
||||
: Object.values(CalendarChannelSyncStatus).filter(
|
||||
(status) => status !== CalendarChannelSyncStatus.ONGOING,
|
||||
);
|
||||
|
||||
const cacheBuckets =
|
||||
timeWindowInSeconds / (CACHE_BUCKET_DURATION_MS / 1000);
|
||||
|
||||
for (const status of statuses) {
|
||||
const cacheKeys = this.computeTimeStampedCacheKeys(
|
||||
`${key}:${status}`,
|
||||
cacheBuckets,
|
||||
now,
|
||||
);
|
||||
|
||||
const channelIdsCount =
|
||||
await this.cacheStorage.countAllSetMembers(cacheKeys);
|
||||
|
||||
countByStatus[status] = channelIdsCount;
|
||||
}
|
||||
|
||||
return countByStatus;
|
||||
}
|
||||
|
||||
computeTimeStampedCacheKeys(
|
||||
key: string,
|
||||
cacheBucketsCount: number,
|
||||
date: number = Date.now(),
|
||||
) {
|
||||
return this.getLastCacheBucketStartTimestampsFromDate(
|
||||
cacheBucketsCount,
|
||||
date,
|
||||
).map((timestamp) => this.getCacheKeyWithTimestamp(key, timestamp));
|
||||
}
|
||||
|
||||
async updateInvalidCaptchaCache(captchaToken: string) {
|
||||
return await this.cacheStorage.setAdd(
|
||||
this.getCacheKeyWithTimestamp(HealthCounterCacheKeys.InvalidCaptcha),
|
||||
[captchaToken],
|
||||
this.healthCacheTtl,
|
||||
);
|
||||
}
|
||||
|
||||
async getInvalidCaptchaCounter(
|
||||
timeWindowInSeconds: number = this.healthMetricsTimeWindowInMinutes * 60,
|
||||
) {
|
||||
return await this.cacheStorage.countAllSetMembers(
|
||||
this.computeTimeStampedCacheKeys(
|
||||
HealthCounterCacheKeys.InvalidCaptcha,
|
||||
timeWindowInSeconds / (CACHE_BUCKET_DURATION_MS / 1000),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -3,14 +3,12 @@ import { TerminusModule } from '@nestjs/terminus';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { HealthController } from 'src/engine/core-modules/health/controllers/health.controller';
|
||||
import { MetricsController } from 'src/engine/core-modules/health/controllers/metrics.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 { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { WorkspaceMigrationModule } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.module';
|
||||
|
||||
import { HealthCacheService } from './health-cache.service';
|
||||
|
||||
import { ConnectedAccountHealth } from './indicators/connected-account.health';
|
||||
import { DatabaseHealthIndicator } from './indicators/database.health';
|
||||
import { RedisHealthIndicator } from './indicators/redis.health';
|
||||
@ -21,10 +19,10 @@ import { WorkerHealthIndicator } from './indicators/worker.health';
|
||||
RedisClientModule,
|
||||
WorkspaceMigrationModule,
|
||||
TypeOrmModule.forFeature([Workspace], 'core'),
|
||||
MetricsModule,
|
||||
],
|
||||
controllers: [HealthController, MetricsController],
|
||||
controllers: [HealthController],
|
||||
providers: [
|
||||
HealthCacheService,
|
||||
DatabaseHealthIndicator,
|
||||
RedisHealthIndicator,
|
||||
WorkerHealthIndicator,
|
||||
@ -32,7 +30,6 @@ import { WorkerHealthIndicator } from './indicators/worker.health';
|
||||
AppHealthIndicator,
|
||||
],
|
||||
exports: [
|
||||
HealthCacheService,
|
||||
DatabaseHealthIndicator,
|
||||
RedisHealthIndicator,
|
||||
WorkerHealthIndicator,
|
||||
|
||||
@ -4,19 +4,19 @@ import { Test, 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 { HealthCacheService } from 'src/engine/core-modules/health/health-cache.service';
|
||||
import { ConnectedAccountHealth } from 'src/engine/core-modules/health/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';
|
||||
|
||||
describe('ConnectedAccountHealth', () => {
|
||||
let service: ConnectedAccountHealth;
|
||||
let healthCacheService: jest.Mocked<HealthCacheService>;
|
||||
let metricsService: jest.Mocked<MetricsService>;
|
||||
let healthIndicatorService: jest.Mocked<HealthIndicatorService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
healthCacheService = {
|
||||
countChannelSyncJobByStatus: jest.fn(),
|
||||
metricsService = {
|
||||
groupMetrics: jest.fn(),
|
||||
} as any;
|
||||
|
||||
healthIndicatorService = {
|
||||
@ -41,8 +41,8 @@ describe('ConnectedAccountHealth', () => {
|
||||
providers: [
|
||||
ConnectedAccountHealth,
|
||||
{
|
||||
provide: HealthCacheService,
|
||||
useValue: healthCacheService,
|
||||
provide: MetricsService,
|
||||
useValue: metricsService,
|
||||
},
|
||||
{
|
||||
provide: HealthIndicatorService,
|
||||
@ -64,7 +64,7 @@ describe('ConnectedAccountHealth', () => {
|
||||
|
||||
describe('message sync health', () => {
|
||||
it('should return up status when no message sync jobs are present', async () => {
|
||||
healthCacheService.countChannelSyncJobByStatus
|
||||
metricsService.groupMetrics
|
||||
.mockResolvedValueOnce({
|
||||
[MessageChannelSyncStatus.NOT_SYNCED]: 0,
|
||||
[MessageChannelSyncStatus.ACTIVE]: 0,
|
||||
@ -92,7 +92,7 @@ describe('ConnectedAccountHealth', () => {
|
||||
});
|
||||
|
||||
it(`should return down status when message sync failure rate is above ${METRICS_FAILURE_RATE_THRESHOLD}%`, async () => {
|
||||
healthCacheService.countChannelSyncJobByStatus
|
||||
metricsService.groupMetrics
|
||||
.mockResolvedValueOnce({
|
||||
[MessageChannelSyncStatus.NOT_SYNCED]: 0,
|
||||
[MessageChannelSyncStatus.ACTIVE]: 1,
|
||||
@ -122,7 +122,7 @@ describe('ConnectedAccountHealth', () => {
|
||||
|
||||
describe('calendar sync health', () => {
|
||||
it('should return up status when no calendar sync jobs are present', async () => {
|
||||
healthCacheService.countChannelSyncJobByStatus
|
||||
metricsService.groupMetrics
|
||||
.mockResolvedValueOnce({
|
||||
[MessageChannelSyncStatus.NOT_SYNCED]: 0,
|
||||
[MessageChannelSyncStatus.ACTIVE]: 0,
|
||||
@ -150,7 +150,7 @@ describe('ConnectedAccountHealth', () => {
|
||||
});
|
||||
|
||||
it(`should return down status when calendar sync failure rate is above ${METRICS_FAILURE_RATE_THRESHOLD}%`, async () => {
|
||||
healthCacheService.countChannelSyncJobByStatus
|
||||
metricsService.groupMetrics
|
||||
.mockResolvedValueOnce({
|
||||
[MessageChannelSyncStatus.NOT_SYNCED]: 0,
|
||||
[MessageChannelSyncStatus.ACTIVE]: 1,
|
||||
@ -180,7 +180,7 @@ describe('ConnectedAccountHealth', () => {
|
||||
|
||||
describe('timeout handling', () => {
|
||||
it('should handle message sync timeout', async () => {
|
||||
healthCacheService.countChannelSyncJobByStatus
|
||||
metricsService.groupMetrics
|
||||
.mockResolvedValueOnce(
|
||||
new Promise((resolve) =>
|
||||
setTimeout(resolve, HEALTH_INDICATORS_TIMEOUT + 100),
|
||||
@ -207,7 +207,7 @@ describe('ConnectedAccountHealth', () => {
|
||||
});
|
||||
|
||||
it('should handle calendar sync timeout', async () => {
|
||||
healthCacheService.countChannelSyncJobByStatus
|
||||
metricsService.groupMetrics
|
||||
.mockResolvedValueOnce({
|
||||
[MessageChannelSyncStatus.NOT_SYNCED]: 0,
|
||||
[MessageChannelSyncStatus.ACTIVE]: 1,
|
||||
@ -236,7 +236,7 @@ describe('ConnectedAccountHealth', () => {
|
||||
|
||||
describe('combined health check', () => {
|
||||
it('should return combined status with both checks healthy', async () => {
|
||||
healthCacheService.countChannelSyncJobByStatus
|
||||
metricsService.groupMetrics
|
||||
.mockResolvedValueOnce({
|
||||
[MessageChannelSyncStatus.NOT_SYNCED]: 0,
|
||||
[MessageChannelSyncStatus.ACTIVE]: 8,
|
||||
@ -256,7 +256,7 @@ describe('ConnectedAccountHealth', () => {
|
||||
});
|
||||
|
||||
it('should return down status when both syncs fail', async () => {
|
||||
healthCacheService.countChannelSyncJobByStatus
|
||||
metricsService.groupMetrics
|
||||
.mockResolvedValueOnce({
|
||||
[MessageChannelSyncStatus.NOT_SYNCED]: 0,
|
||||
[MessageChannelSyncStatus.ACTIVE]: 1,
|
||||
|
||||
@ -6,15 +6,17 @@ import {
|
||||
|
||||
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 { HealthCacheService } from 'src/engine/core-modules/health/health-cache.service';
|
||||
import { HealthCounterCacheKeys } from 'src/engine/core-modules/health/types/health-counter-cache-keys.type';
|
||||
import { withHealthCheckTimeout } from 'src/engine/core-modules/health/utils/health-check-timeout.util';
|
||||
|
||||
import {
|
||||
CALENDAR_SYNC_METRICS_BY_STATUS,
|
||||
MESSAGE_SYNC_METRICS_BY_STATUS,
|
||||
} from 'src/engine/core-modules/metrics/constants/account-sync-metrics-by-status.constant';
|
||||
import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service';
|
||||
@Injectable()
|
||||
export class ConnectedAccountHealth {
|
||||
constructor(
|
||||
private readonly healthIndicatorService: HealthIndicatorService,
|
||||
private readonly healthCacheService: HealthCacheService,
|
||||
private readonly metricsService: MetricsService,
|
||||
) {}
|
||||
|
||||
private async checkMessageSyncHealth(): Promise<HealthIndicatorResult> {
|
||||
@ -22,9 +24,7 @@ export class ConnectedAccountHealth {
|
||||
|
||||
try {
|
||||
const counters = await withHealthCheckTimeout(
|
||||
this.healthCacheService.countChannelSyncJobByStatus(
|
||||
HealthCounterCacheKeys.MessageChannelSyncJobByStatus,
|
||||
),
|
||||
this.metricsService.groupMetrics(MESSAGE_SYNC_METRICS_BY_STATUS),
|
||||
HEALTH_ERROR_MESSAGES.MESSAGE_SYNC_TIMEOUT,
|
||||
);
|
||||
|
||||
@ -73,9 +73,7 @@ export class ConnectedAccountHealth {
|
||||
|
||||
try {
|
||||
const counters = await withHealthCheckTimeout(
|
||||
this.healthCacheService.countChannelSyncJobByStatus(
|
||||
HealthCounterCacheKeys.CalendarEventSyncJobByStatus,
|
||||
),
|
||||
this.metricsService.groupMetrics(CALENDAR_SYNC_METRICS_BY_STATUS),
|
||||
HEALTH_ERROR_MESSAGES.CALENDAR_SYNC_TIMEOUT,
|
||||
);
|
||||
|
||||
|
||||
@ -1,5 +0,0 @@
|
||||
export enum HealthCounterCacheKeys {
|
||||
MessageChannelSyncJobByStatus = 'message-channel-sync-job-by-status',
|
||||
InvalidCaptcha = 'invalid-captcha',
|
||||
CalendarEventSyncJobByStatus = 'calendar-event-sync-job-by-status',
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
import { MetricsKeys } from 'src/engine/core-modules/metrics/types/metrics-keys.type';
|
||||
|
||||
export const MESSAGE_SYNC_METRICS_BY_STATUS = [
|
||||
{
|
||||
name: 'ACTIVE',
|
||||
cacheKey: MetricsKeys.MessageChannelSyncJobActive,
|
||||
},
|
||||
{
|
||||
name: 'FAILED_UNKNOWN',
|
||||
cacheKey: MetricsKeys.MessageChannelSyncJobFailedUnknown,
|
||||
},
|
||||
{
|
||||
name: 'FAILED_INSUFFICIENT_PERMISSIONS',
|
||||
cacheKey: MetricsKeys.MessageChannelSyncJobFailedInsufficientPermissions,
|
||||
},
|
||||
];
|
||||
|
||||
export const CALENDAR_SYNC_METRICS_BY_STATUS = [
|
||||
{
|
||||
name: 'ACTIVE',
|
||||
cacheKey: MetricsKeys.CalendarEventSyncJobActive,
|
||||
},
|
||||
{
|
||||
name: 'FAILED_UNKNOWN',
|
||||
cacheKey: MetricsKeys.CalendarEventSyncJobFailedUnknown,
|
||||
},
|
||||
{
|
||||
name: 'FAILED_INSUFFICIENT_PERMISSIONS',
|
||||
cacheKey: MetricsKeys.CalendarEventSyncJobFailedInsufficientPermissions,
|
||||
},
|
||||
];
|
||||
@ -0,0 +1,94 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
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 { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { MetricsKeys } from 'src/engine/core-modules/metrics/types/metrics-keys.type';
|
||||
|
||||
const CACHE_BUCKET_DURATION_MS = 15000; // 15 seconds window for each cache bucket
|
||||
|
||||
@Injectable()
|
||||
export class MetricsCacheService {
|
||||
private readonly healthMetricsTimeWindowInMinutes: number;
|
||||
private readonly healthCacheTtl: number;
|
||||
|
||||
constructor(
|
||||
@InjectCacheStorage(CacheStorageNamespace.EngineHealth)
|
||||
private readonly cacheStorage: CacheStorageService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
) {
|
||||
this.healthMetricsTimeWindowInMinutes = this.environmentService.get(
|
||||
'HEALTH_METRICS_TIME_WINDOW_IN_MINUTES',
|
||||
);
|
||||
this.healthCacheTtl = this.healthMetricsTimeWindowInMinutes * 60000 * 2;
|
||||
}
|
||||
|
||||
private getCacheBucketStartTimestamp(timestamp: number): number {
|
||||
return (
|
||||
Math.floor(timestamp / CACHE_BUCKET_DURATION_MS) *
|
||||
CACHE_BUCKET_DURATION_MS
|
||||
);
|
||||
}
|
||||
|
||||
private getCacheKeyWithTimestamp(key: string, timestamp?: number): string {
|
||||
const currentIntervalTimestamp =
|
||||
timestamp ?? this.getCacheBucketStartTimestamp(Date.now());
|
||||
|
||||
return `${key}:${currentIntervalTimestamp}`;
|
||||
}
|
||||
|
||||
private getLastCacheBucketStartTimestampsFromDate(
|
||||
cacheBucketsCount: number,
|
||||
date: number,
|
||||
): number[] {
|
||||
const currentIntervalTimestamp = this.getCacheBucketStartTimestamp(date);
|
||||
|
||||
return Array.from(
|
||||
{ length: cacheBucketsCount },
|
||||
(_, i) => currentIntervalTimestamp - i * CACHE_BUCKET_DURATION_MS,
|
||||
);
|
||||
}
|
||||
|
||||
async updateCounter(key: MetricsKeys, items: string[]) {
|
||||
return await this.cacheStorage.setAdd(
|
||||
this.getCacheKeyWithTimestamp(key),
|
||||
items,
|
||||
this.healthCacheTtl,
|
||||
);
|
||||
}
|
||||
|
||||
async computeCount({
|
||||
key,
|
||||
timeWindowInSeconds = this.healthMetricsTimeWindowInMinutes * 60,
|
||||
date = Date.now(),
|
||||
}: {
|
||||
key: MetricsKeys;
|
||||
timeWindowInSeconds?: number;
|
||||
date?: number;
|
||||
}): Promise<number> {
|
||||
if ((timeWindowInSeconds * 1000) % CACHE_BUCKET_DURATION_MS !== 0) {
|
||||
throw new Error(
|
||||
`Time window must be divisible by ${CACHE_BUCKET_DURATION_MS}`,
|
||||
);
|
||||
}
|
||||
|
||||
const cacheBuckets =
|
||||
timeWindowInSeconds / (CACHE_BUCKET_DURATION_MS / 1000);
|
||||
|
||||
const cacheKeys = this.computeTimeStampedCacheKeys(key, cacheBuckets, date);
|
||||
|
||||
return await this.cacheStorage.countAllSetMembers(cacheKeys);
|
||||
}
|
||||
|
||||
computeTimeStampedCacheKeys(
|
||||
key: string,
|
||||
cacheBucketsCount: number,
|
||||
date: number,
|
||||
) {
|
||||
return this.getLastCacheBucketStartTimestampsFromDate(
|
||||
cacheBucketsCount,
|
||||
date,
|
||||
).map((timestamp) => this.getCacheKeyWithTimestamp(key, timestamp));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { MetricsCacheService } from 'src/engine/core-modules/metrics/metrics-cache.service';
|
||||
import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service';
|
||||
|
||||
@Module({
|
||||
providers: [MetricsService, MetricsCacheService],
|
||||
exports: [MetricsService, MetricsCacheService],
|
||||
})
|
||||
export class MetricsModule {}
|
||||
@ -0,0 +1,70 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { metrics } from '@opentelemetry/api';
|
||||
|
||||
import { MetricsCacheService } from 'src/engine/core-modules/metrics/metrics-cache.service';
|
||||
import { MetricsKeys } from 'src/engine/core-modules/metrics/types/metrics-keys.type';
|
||||
|
||||
@Injectable()
|
||||
export class MetricsService {
|
||||
constructor(private readonly metricsCacheService: MetricsCacheService) {}
|
||||
|
||||
async incrementCounter({
|
||||
key,
|
||||
eventId,
|
||||
shouldStoreInCache = true,
|
||||
}: {
|
||||
key: MetricsKeys;
|
||||
eventId: string;
|
||||
shouldStoreInCache?: boolean;
|
||||
}) {
|
||||
//TODO : Define meter name usage in monitoring
|
||||
const meter = metrics.getMeter('twenty-server');
|
||||
const counter = meter.createCounter(key);
|
||||
|
||||
counter.add(1);
|
||||
|
||||
if (shouldStoreInCache) {
|
||||
this.metricsCacheService.updateCounter(key, [eventId]);
|
||||
}
|
||||
}
|
||||
|
||||
async batchIncrementCounter({
|
||||
key,
|
||||
eventIds,
|
||||
shouldStoreInCache = true,
|
||||
}: {
|
||||
key: MetricsKeys;
|
||||
eventIds: string[];
|
||||
shouldStoreInCache?: boolean;
|
||||
}) {
|
||||
//TODO : Define meter name usage in monitoring
|
||||
const meter = metrics.getMeter('twenty-server');
|
||||
const counter = meter.createCounter(key);
|
||||
|
||||
counter.add(eventIds.length);
|
||||
|
||||
if (shouldStoreInCache) {
|
||||
this.metricsCacheService.updateCounter(key, eventIds);
|
||||
}
|
||||
}
|
||||
|
||||
async groupMetrics(
|
||||
metrics: { name: string; cacheKey: MetricsKeys }[],
|
||||
): Promise<Record<string, number>> {
|
||||
const groupedMetrics: Record<string, number> = {};
|
||||
|
||||
const date = Date.now();
|
||||
|
||||
for (const metric of metrics) {
|
||||
const metricValue = await this.metricsCacheService.computeCount({
|
||||
key: metric.cacheKey,
|
||||
date,
|
||||
});
|
||||
|
||||
groupedMetrics[metric.name] = metricValue;
|
||||
}
|
||||
|
||||
return groupedMetrics;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
export enum MeterDriver {
|
||||
OpenTelemetry = 'opentelemetry',
|
||||
Console = 'console',
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
export enum MetricsKeys {
|
||||
MessageChannelSyncJobActive = 'message-channel-sync-job/active',
|
||||
MessageChannelSyncJobFailedInsufficientPermissions = 'message-channel-sync-job/failed-insufficient-permissions',
|
||||
MessageChannelSyncJobFailedUnknown = 'message-channel-sync-job/failed-unknown',
|
||||
CalendarEventSyncJobActive = 'calendar-event-sync-job/active',
|
||||
CalendarEventSyncJobFailedInsufficientPermissions = 'calendar-event-sync-job/failed-insufficient-permissions',
|
||||
CalendarEventSyncJobFailedUnknown = 'calendar-event-sync-job/failed-unknown',
|
||||
InvalidCaptcha = 'invalid-captcha',
|
||||
}
|
||||
@ -1,10 +1,27 @@
|
||||
import * as Sentry from '@sentry/nestjs';
|
||||
import process from 'process';
|
||||
|
||||
import opentelemetry from '@opentelemetry/api';
|
||||
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http';
|
||||
import {
|
||||
ConsoleMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from '@opentelemetry/sdk-metrics';
|
||||
import * as Sentry from '@sentry/node';
|
||||
import { nodeProfilingIntegration } from '@sentry/profiling-node';
|
||||
|
||||
import { NodeEnvironment } from 'src/engine/core-modules/environment/interfaces/node-environment.interface';
|
||||
|
||||
import { ExceptionHandlerDriver } from 'src/engine/core-modules/exception-handler/interfaces';
|
||||
import { MeterDriver } from 'src/engine/core-modules/metrics/types/meter-driver.type';
|
||||
import { WorkspaceCacheKeys } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
|
||||
import { parseArrayEnvVar } from 'src/utils/parse-array-env-var';
|
||||
|
||||
const meterDrivers = parseArrayEnvVar(
|
||||
process.env.METER_DRIVER,
|
||||
Object.values(MeterDriver),
|
||||
[MeterDriver.Console],
|
||||
);
|
||||
|
||||
if (process.env.EXCEPTION_HANDLER_DRIVER === ExceptionHandlerDriver.Sentry) {
|
||||
Sentry.init({
|
||||
@ -29,3 +46,34 @@ if (process.env.EXCEPTION_HANDLER_DRIVER === ExceptionHandlerDriver.Sentry) {
|
||||
debug: process.env.NODE_ENV === NodeEnvironment.development,
|
||||
});
|
||||
}
|
||||
|
||||
// Meter setup
|
||||
|
||||
const meterProvider = new MeterProvider({
|
||||
readers: [
|
||||
...(meterDrivers.includes(MeterDriver.Console)
|
||||
? [
|
||||
new PeriodicExportingMetricReader({
|
||||
exporter: new ConsoleMetricExporter(),
|
||||
exportIntervalMillis: 10000,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
...(meterDrivers.includes(MeterDriver.OpenTelemetry)
|
||||
? [
|
||||
new PeriodicExportingMetricReader({
|
||||
exporter: new OTLPMetricExporter({
|
||||
url: process.env.OTLP_COLLECTOR_METRICS_ENDPOINT_URL,
|
||||
}),
|
||||
exportIntervalMillis: 10000,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
|
||||
opentelemetry.metrics.setGlobalMeterProvider(meterProvider);
|
||||
|
||||
process.on('SIGTERM', async () => {
|
||||
await meterProvider.shutdown();
|
||||
});
|
||||
|
||||
@ -3,7 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
|
||||
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
|
||||
import { HealthModule } from 'src/engine/core-modules/health/health.module';
|
||||
import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { DataSourceEntity } from 'src/engine/metadata-modules/data-source/data-source.entity';
|
||||
import { ObjectMetadataRepositoryModule } from 'src/engine/object-metadata-repository/object-metadata-repository.module';
|
||||
@ -50,7 +50,7 @@ import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/sta
|
||||
RefreshTokensManagerModule,
|
||||
ConnectedAccountModule,
|
||||
CalendarCommonModule,
|
||||
HealthModule,
|
||||
MetricsModule,
|
||||
],
|
||||
providers: [
|
||||
CalendarChannelSyncStatusService,
|
||||
|
||||
@ -2,7 +2,7 @@ import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
|
||||
import { HealthModule } from 'src/engine/core-modules/health/health.module';
|
||||
import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module';
|
||||
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
|
||||
import { CalendarChannelSyncStatusService } from 'src/modules/calendar/common/services/calendar-channel-sync-status.service';
|
||||
import { ConnectedAccountModule } from 'src/modules/connected-account/connected-account.module';
|
||||
@ -12,7 +12,7 @@ import { ConnectedAccountModule } from 'src/modules/connected-account/connected-
|
||||
WorkspaceDataSourceModule,
|
||||
TypeOrmModule.forFeature([FeatureFlag], 'core'),
|
||||
ConnectedAccountModule,
|
||||
HealthModule,
|
||||
MetricsModule,
|
||||
],
|
||||
providers: [CalendarChannelSyncStatusService],
|
||||
exports: [CalendarChannelSyncStatusService],
|
||||
|
||||
@ -5,8 +5,8 @@ import { Any } from 'typeorm';
|
||||
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 { HealthCacheService } from 'src/engine/core-modules/health/health-cache.service';
|
||||
import { HealthCounterCacheKeys } from 'src/engine/core-modules/health/types/health-counter-cache-keys.type';
|
||||
import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service';
|
||||
import { MetricsKeys } from 'src/engine/core-modules/metrics/types/metrics-keys.type';
|
||||
import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
|
||||
import {
|
||||
CalendarChannelSyncStage,
|
||||
@ -24,7 +24,7 @@ export class CalendarChannelSyncStatusService {
|
||||
@InjectCacheStorage(CacheStorageNamespace.ModuleCalendar)
|
||||
private readonly cacheStorage: CacheStorageService,
|
||||
private readonly accountsToReconnectService: AccountsToReconnectService,
|
||||
private readonly healthCacheService: HealthCacheService,
|
||||
private readonly metricsService: MetricsService,
|
||||
) {}
|
||||
|
||||
public async scheduleFullCalendarEventListFetch(
|
||||
@ -179,11 +179,10 @@ export class CalendarChannelSyncStatusService {
|
||||
|
||||
await this.schedulePartialCalendarEventListFetch(calendarChannelIds);
|
||||
|
||||
await this.healthCacheService.updateMessageOrCalendarChannelSyncJobByStatusCache(
|
||||
HealthCounterCacheKeys.CalendarEventSyncJobByStatus,
|
||||
CalendarChannelSyncStatus.ACTIVE,
|
||||
calendarChannelIds,
|
||||
);
|
||||
await this.metricsService.batchIncrementCounter({
|
||||
key: MetricsKeys.CalendarEventSyncJobActive,
|
||||
eventIds: calendarChannelIds,
|
||||
});
|
||||
}
|
||||
|
||||
public async markAsFailedUnknownAndFlushCalendarEventsToImport(
|
||||
@ -210,11 +209,10 @@ export class CalendarChannelSyncStatusService {
|
||||
syncStage: CalendarChannelSyncStage.FAILED,
|
||||
});
|
||||
|
||||
await this.healthCacheService.updateMessageOrCalendarChannelSyncJobByStatusCache(
|
||||
HealthCounterCacheKeys.CalendarEventSyncJobByStatus,
|
||||
CalendarChannelSyncStatus.FAILED_UNKNOWN,
|
||||
calendarChannelIds,
|
||||
);
|
||||
await this.metricsService.batchIncrementCounter({
|
||||
key: MetricsKeys.CalendarEventSyncJobFailedUnknown,
|
||||
eventIds: calendarChannelIds,
|
||||
});
|
||||
}
|
||||
|
||||
public async markAsFailedInsufficientPermissionsAndFlushCalendarEventsToImport(
|
||||
@ -266,11 +264,10 @@ export class CalendarChannelSyncStatusService {
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
await this.healthCacheService.updateMessageOrCalendarChannelSyncJobByStatusCache(
|
||||
HealthCounterCacheKeys.CalendarEventSyncJobByStatus,
|
||||
CalendarChannelSyncStatus.FAILED_INSUFFICIENT_PERMISSIONS,
|
||||
calendarChannelIds,
|
||||
);
|
||||
await this.metricsService.batchIncrementCounter({
|
||||
key: MetricsKeys.CalendarEventSyncJobFailedInsufficientPermissions,
|
||||
eventIds: calendarChannelIds,
|
||||
});
|
||||
}
|
||||
|
||||
private async addToAccountsToReconnect(
|
||||
|
||||
@ -2,7 +2,7 @@ import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
|
||||
import { HealthModule } from 'src/engine/core-modules/health/health.module';
|
||||
import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module';
|
||||
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
|
||||
import { ConnectedAccountModule } from 'src/modules/connected-account/connected-account.module';
|
||||
import { MessageChannelSyncStatusService } from 'src/modules/messaging/common/services/message-channel-sync-status.service';
|
||||
@ -12,7 +12,7 @@ import { MessageChannelSyncStatusService } from 'src/modules/messaging/common/se
|
||||
WorkspaceDataSourceModule,
|
||||
TypeOrmModule.forFeature([FeatureFlag], 'core'),
|
||||
ConnectedAccountModule,
|
||||
HealthModule,
|
||||
MetricsModule,
|
||||
],
|
||||
providers: [MessageChannelSyncStatusService],
|
||||
exports: [MessageChannelSyncStatusService],
|
||||
|
||||
@ -5,8 +5,8 @@ import { Any } from 'typeorm';
|
||||
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 { HealthCacheService } from 'src/engine/core-modules/health/health-cache.service';
|
||||
import { HealthCounterCacheKeys } from 'src/engine/core-modules/health/types/health-counter-cache-keys.type';
|
||||
import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service';
|
||||
import { MetricsKeys } from 'src/engine/core-modules/metrics/types/metrics-keys.type';
|
||||
import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
|
||||
import { AccountsToReconnectService } from 'src/modules/connected-account/services/accounts-to-reconnect.service';
|
||||
import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
|
||||
@ -24,7 +24,7 @@ export class MessageChannelSyncStatusService {
|
||||
private readonly cacheStorage: CacheStorageService,
|
||||
private readonly twentyORMManager: TwentyORMManager,
|
||||
private readonly accountsToReconnectService: AccountsToReconnectService,
|
||||
private readonly healthCacheService: HealthCacheService,
|
||||
private readonly metricsService: MetricsService,
|
||||
) {}
|
||||
|
||||
public async scheduleFullMessageListFetch(messageChannelIds: string[]) {
|
||||
@ -152,11 +152,10 @@ export class MessageChannelSyncStatusService {
|
||||
syncedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
await this.healthCacheService.updateMessageOrCalendarChannelSyncJobByStatusCache(
|
||||
HealthCounterCacheKeys.MessageChannelSyncJobByStatus,
|
||||
MessageChannelSyncStatus.ACTIVE,
|
||||
messageChannelIds,
|
||||
);
|
||||
await this.metricsService.batchIncrementCounter({
|
||||
key: MetricsKeys.MessageChannelSyncJobActive,
|
||||
eventIds: messageChannelIds,
|
||||
});
|
||||
}
|
||||
|
||||
public async markAsMessagesImportOngoing(messageChannelIds: string[]) {
|
||||
@ -199,11 +198,10 @@ export class MessageChannelSyncStatusService {
|
||||
syncStatus: MessageChannelSyncStatus.FAILED_UNKNOWN,
|
||||
});
|
||||
|
||||
await this.healthCacheService.updateMessageOrCalendarChannelSyncJobByStatusCache(
|
||||
HealthCounterCacheKeys.MessageChannelSyncJobByStatus,
|
||||
MessageChannelSyncStatus.FAILED_UNKNOWN,
|
||||
messageChannelIds,
|
||||
);
|
||||
await this.metricsService.batchIncrementCounter({
|
||||
key: MetricsKeys.MessageChannelSyncJobFailedUnknown,
|
||||
eventIds: messageChannelIds,
|
||||
});
|
||||
}
|
||||
|
||||
public async markAsFailedInsufficientPermissionsAndFlushMessagesToImport(
|
||||
@ -230,11 +228,10 @@ export class MessageChannelSyncStatusService {
|
||||
syncStatus: MessageChannelSyncStatus.FAILED_INSUFFICIENT_PERMISSIONS,
|
||||
});
|
||||
|
||||
await this.healthCacheService.updateMessageOrCalendarChannelSyncJobByStatusCache(
|
||||
HealthCounterCacheKeys.MessageChannelSyncJobByStatus,
|
||||
MessageChannelSyncStatus.FAILED_INSUFFICIENT_PERMISSIONS,
|
||||
messageChannelIds,
|
||||
);
|
||||
await this.metricsService.batchIncrementCounter({
|
||||
key: MetricsKeys.MessageChannelSyncJobFailedInsufficientPermissions,
|
||||
eventIds: messageChannelIds,
|
||||
});
|
||||
|
||||
const connectedAccountRepository =
|
||||
await this.twentyORMManager.getRepository<ConnectedAccountWorkspaceEntity>(
|
||||
|
||||
@ -3,8 +3,8 @@ import { NestFactory } from '@nestjs/core';
|
||||
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
|
||||
import { LoggerService } from 'src/engine/core-modules/logger/logger.service';
|
||||
import { shouldFilterException } from 'src/engine/utils/global-exception-handler.util';
|
||||
import { QueueWorkerModule } from 'src/queue-worker/queue-worker.module';
|
||||
import 'src/instrument';
|
||||
import { QueueWorkerModule } from 'src/queue-worker/queue-worker.module';
|
||||
|
||||
async function bootstrap() {
|
||||
let exceptionHandlerService: ExceptionHandlerService | undefined;
|
||||
|
||||
13
packages/twenty-server/src/utils/parse-array-env-var.ts
Normal file
13
packages/twenty-server/src/utils/parse-array-env-var.ts
Normal file
@ -0,0 +1,13 @@
|
||||
export const parseArrayEnvVar = <T>(
|
||||
envVar: string | undefined,
|
||||
expectedValues: T[],
|
||||
defaultValues: T[],
|
||||
): T[] => {
|
||||
if (!envVar) return defaultValues;
|
||||
|
||||
const values = envVar
|
||||
.split(',')
|
||||
.filter((item) => expectedValues.includes(item as T)) as T[];
|
||||
|
||||
return values.length > 0 ? values : defaultValues;
|
||||
};
|
||||
@ -279,7 +279,7 @@ yarn command:prod cron:calendar:ongoing-stale
|
||||
['SERVERLESS_LAMBDA_SECRET_ACCESS_KEY', '', 'Optional depending on the authentication method'],
|
||||
]}></ArticleTable>
|
||||
|
||||
### Logging
|
||||
### Logging and Observability
|
||||
|
||||
<ArticleTable options={[
|
||||
['LOGGER_DRIVER', 'console', "Currently, only supports 'console'"],
|
||||
@ -290,6 +290,8 @@ yarn command:prod cron:calendar:ongoing-stale
|
||||
['SENTRY_RELEASE', 'latest', 'The sentry release used if sentry logging driver is selected'],
|
||||
['SENTRY_DSN', 'https://xxx@xxx.ingest.sentry.io/xxx', 'The sentry logging endpoint used if sentry logging driver is selected'],
|
||||
['SENTRY_FRONT_DSN', 'https://xxx@xxx.ingest.sentry.io/xxx', 'The sentry logging endpoint used by the frontend if sentry logging driver is selected'],
|
||||
['METER_DRIVER', 'console', "The meter driver can be: 'console' and/or 'opentelemetry' "],
|
||||
['OTLP_COLLECTOR_ENDPOINT_URL', '', 'The OpenTelemetry collector endpoint collects metrics if opentelemetry meter driver is selected. The collector has to be set separately.'],
|
||||
]}></ArticleTable>
|
||||
|
||||
|
||||
|
||||
162
yarn.lock
162
yarn.lock
@ -11885,6 +11885,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@opentelemetry/api-logs@npm:0.200.0":
|
||||
version: 0.200.0
|
||||
resolution: "@opentelemetry/api-logs@npm:0.200.0"
|
||||
dependencies:
|
||||
"@opentelemetry/api": "npm:^1.3.0"
|
||||
checksum: 10c0/c6bc3cfba35c69411f294519d93d0ff9f603517030d1162839ee42ac22ed1b0235edaf71d00cabc40125f813d8b4dc830d14315afcebcef138c1df560eaa5c91
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@opentelemetry/api-logs@npm:0.52.1":
|
||||
version: 0.52.1
|
||||
resolution: "@opentelemetry/api-logs@npm:0.52.1"
|
||||
@ -11903,7 +11912,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@opentelemetry/api@npm:^1.0.0, @opentelemetry/api@npm:^1.8, @opentelemetry/api@npm:^1.9.0":
|
||||
"@opentelemetry/api@npm:^1.0.0, @opentelemetry/api@npm:^1.3.0, @opentelemetry/api@npm:^1.8, @opentelemetry/api@npm:^1.9.0":
|
||||
version: 1.9.0
|
||||
resolution: "@opentelemetry/api@npm:1.9.0"
|
||||
checksum: 10c0/9aae2fe6e8a3a3eeb6c1fdef78e1939cf05a0f37f8a4fae4d6bf2e09eb1e06f966ece85805626e01ba5fab48072b94f19b835449e58b6d26720ee19a58298add
|
||||
@ -11930,6 +11939,32 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@opentelemetry/core@npm:2.0.0":
|
||||
version: 2.0.0
|
||||
resolution: "@opentelemetry/core@npm:2.0.0"
|
||||
dependencies:
|
||||
"@opentelemetry/semantic-conventions": "npm:^1.29.0"
|
||||
peerDependencies:
|
||||
"@opentelemetry/api": ">=1.0.0 <1.10.0"
|
||||
checksum: 10c0/d2cc6d8a955305b9de15cc36135e5d5b0f0405fead8bbd4de51433f2d05369af0a3bcb2c6fe7fe6d9e61b0db782511bcadc5d93ed906027d4c00d5c2e3575a24
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@opentelemetry/exporter-metrics-otlp-http@npm:^0.200.0":
|
||||
version: 0.200.0
|
||||
resolution: "@opentelemetry/exporter-metrics-otlp-http@npm:0.200.0"
|
||||
dependencies:
|
||||
"@opentelemetry/core": "npm:2.0.0"
|
||||
"@opentelemetry/otlp-exporter-base": "npm:0.200.0"
|
||||
"@opentelemetry/otlp-transformer": "npm:0.200.0"
|
||||
"@opentelemetry/resources": "npm:2.0.0"
|
||||
"@opentelemetry/sdk-metrics": "npm:2.0.0"
|
||||
peerDependencies:
|
||||
"@opentelemetry/api": ^1.3.0
|
||||
checksum: 10c0/b94e99a481d3a156ed28f23ea9a72b594613081892ef889fd31be686d32daaa299e961bf173cdc3dcc5235a59f70f6437dd4d21c0256d638c669999c7912c20f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@opentelemetry/instrumentation-connect@npm:0.39.0":
|
||||
version: 0.39.0
|
||||
resolution: "@opentelemetry/instrumentation-connect@npm:0.39.0"
|
||||
@ -12205,6 +12240,35 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@opentelemetry/otlp-exporter-base@npm:0.200.0":
|
||||
version: 0.200.0
|
||||
resolution: "@opentelemetry/otlp-exporter-base@npm:0.200.0"
|
||||
dependencies:
|
||||
"@opentelemetry/core": "npm:2.0.0"
|
||||
"@opentelemetry/otlp-transformer": "npm:0.200.0"
|
||||
peerDependencies:
|
||||
"@opentelemetry/api": ^1.3.0
|
||||
checksum: 10c0/3283c12bffc3156a41d9c16c097966e8418781a1d779250334f3d5b4f864be1aeac69fecfdf489abc95578dc36098dc0e026e5a48eb19ee170d72ef89b94f0e9
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@opentelemetry/otlp-transformer@npm:0.200.0":
|
||||
version: 0.200.0
|
||||
resolution: "@opentelemetry/otlp-transformer@npm:0.200.0"
|
||||
dependencies:
|
||||
"@opentelemetry/api-logs": "npm:0.200.0"
|
||||
"@opentelemetry/core": "npm:2.0.0"
|
||||
"@opentelemetry/resources": "npm:2.0.0"
|
||||
"@opentelemetry/sdk-logs": "npm:0.200.0"
|
||||
"@opentelemetry/sdk-metrics": "npm:2.0.0"
|
||||
"@opentelemetry/sdk-trace-base": "npm:2.0.0"
|
||||
protobufjs: "npm:^7.3.0"
|
||||
peerDependencies:
|
||||
"@opentelemetry/api": ^1.3.0
|
||||
checksum: 10c0/4f5383fad48c62e17824df91f6944b0376cb17f7b132b11d62fa5cf46747f224c980960209c85669b6e341a131f94586c6ad52bc1a6d2fb8d5295e23b460600c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@opentelemetry/redis-common@npm:^0.36.2":
|
||||
version: 0.36.2
|
||||
resolution: "@opentelemetry/redis-common@npm:0.36.2"
|
||||
@ -12224,6 +12288,43 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@opentelemetry/resources@npm:2.0.0":
|
||||
version: 2.0.0
|
||||
resolution: "@opentelemetry/resources@npm:2.0.0"
|
||||
dependencies:
|
||||
"@opentelemetry/core": "npm:2.0.0"
|
||||
"@opentelemetry/semantic-conventions": "npm:^1.29.0"
|
||||
peerDependencies:
|
||||
"@opentelemetry/api": ">=1.3.0 <1.10.0"
|
||||
checksum: 10c0/2f331ff8268ef7168e8f24312fd7505900693c0ea302f6025937e94c157b8173ee54f5d5a737c06b956da721aa63443ac520f530cade880ef3cd40a2a25c702c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@opentelemetry/sdk-logs@npm:0.200.0":
|
||||
version: 0.200.0
|
||||
resolution: "@opentelemetry/sdk-logs@npm:0.200.0"
|
||||
dependencies:
|
||||
"@opentelemetry/api-logs": "npm:0.200.0"
|
||||
"@opentelemetry/core": "npm:2.0.0"
|
||||
"@opentelemetry/resources": "npm:2.0.0"
|
||||
peerDependencies:
|
||||
"@opentelemetry/api": ">=1.4.0 <1.10.0"
|
||||
checksum: 10c0/031dc40dd012fad102e5c8c0c9bdbbce051dbc7fcc2e05e003f959aeb34d252dc3595b353ea2a9f900ff40f45d19cb4c8f7ab95a9faa01391f6b415c7780c786
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@opentelemetry/sdk-metrics@npm:2.0.0, @opentelemetry/sdk-metrics@npm:^2.0.0":
|
||||
version: 2.0.0
|
||||
resolution: "@opentelemetry/sdk-metrics@npm:2.0.0"
|
||||
dependencies:
|
||||
"@opentelemetry/core": "npm:2.0.0"
|
||||
"@opentelemetry/resources": "npm:2.0.0"
|
||||
peerDependencies:
|
||||
"@opentelemetry/api": ">=1.9.0 <1.10.0"
|
||||
checksum: 10c0/9a3c87738671f29a496a39d65b3ab0829b52d0f31c0be662ea575a8f77bc5444044fd01513c891abdff6bf6344a08730e18f79253a85e68962669f3e1fa12e72
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@opentelemetry/sdk-metrics@npm:^1.9.1":
|
||||
version: 1.26.0
|
||||
resolution: "@opentelemetry/sdk-metrics@npm:1.26.0"
|
||||
@ -12236,6 +12337,19 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@opentelemetry/sdk-trace-base@npm:2.0.0":
|
||||
version: 2.0.0
|
||||
resolution: "@opentelemetry/sdk-trace-base@npm:2.0.0"
|
||||
dependencies:
|
||||
"@opentelemetry/core": "npm:2.0.0"
|
||||
"@opentelemetry/resources": "npm:2.0.0"
|
||||
"@opentelemetry/semantic-conventions": "npm:^1.29.0"
|
||||
peerDependencies:
|
||||
"@opentelemetry/api": ">=1.3.0 <1.10.0"
|
||||
checksum: 10c0/c63cc052741e4cc01d084c883e24a1c0792f081a242e14e5cf526d5a3d96bac5974006fa0d8f902bd04f34ed9ce95a0d0f01b7fdb37fcc813cea9f818f2b8f43
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@opentelemetry/sdk-trace-base@npm:^1.22, @opentelemetry/sdk-trace-base@npm:^1.26.0":
|
||||
version: 1.26.0
|
||||
resolution: "@opentelemetry/sdk-trace-base@npm:1.26.0"
|
||||
@ -12256,6 +12370,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@opentelemetry/semantic-conventions@npm:^1.29.0":
|
||||
version: 1.30.0
|
||||
resolution: "@opentelemetry/semantic-conventions@npm:1.30.0"
|
||||
checksum: 10c0/0bf99552e3b4b7e8b7eb504b678d52f59c6f259df88e740a2011a0d858e523d36fee86047ae1b7f45849c77f00f970c3059ba58e0a06a7d47d6f01dbe8c455bd
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@opentelemetry/sql-common@npm:^0.40.1":
|
||||
version: 0.40.1
|
||||
resolution: "@opentelemetry/sql-common@npm:0.40.1"
|
||||
@ -22204,6 +22325,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/node@npm:>=13.7.0":
|
||||
version: 22.13.14
|
||||
resolution: "@types/node@npm:22.13.14"
|
||||
dependencies:
|
||||
undici-types: "npm:~6.20.0"
|
||||
checksum: 10c0/fa2ab5b8277bfbcc86c42e46a3ea9871b0d559894cc9d955685d17178c9499f0b1bf03d1d1ea8d92ef2dda818988f4035acb8abf9dc15423a998fa56173ab804
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/node@npm:^10.1.0":
|
||||
version: 10.17.60
|
||||
resolution: "@types/node@npm:10.17.60"
|
||||
@ -40434,6 +40564,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"long@npm:^5.0.0":
|
||||
version: 5.3.1
|
||||
resolution: "long@npm:5.3.1"
|
||||
checksum: 10c0/8726994c6359bb7162fb94563e14c3f9c0f0eeafd90ec654738f4f144a5705756d36a873c442f172ee2a4b51e08d14ab99765b49aa1fb994c5ba7fe12057bca2
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"longest-streak@npm:^2.0.0":
|
||||
version: 2.0.4
|
||||
resolution: "longest-streak@npm:2.0.4"
|
||||
@ -47139,6 +47276,26 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"protobufjs@npm:^7.3.0":
|
||||
version: 7.4.0
|
||||
resolution: "protobufjs@npm:7.4.0"
|
||||
dependencies:
|
||||
"@protobufjs/aspromise": "npm:^1.1.2"
|
||||
"@protobufjs/base64": "npm:^1.1.2"
|
||||
"@protobufjs/codegen": "npm:^2.0.4"
|
||||
"@protobufjs/eventemitter": "npm:^1.1.0"
|
||||
"@protobufjs/fetch": "npm:^1.1.0"
|
||||
"@protobufjs/float": "npm:^1.0.2"
|
||||
"@protobufjs/inquire": "npm:^1.1.0"
|
||||
"@protobufjs/path": "npm:^1.1.2"
|
||||
"@protobufjs/pool": "npm:^1.1.0"
|
||||
"@protobufjs/utf8": "npm:^1.1.0"
|
||||
"@types/node": "npm:>=13.7.0"
|
||||
long: "npm:^5.0.0"
|
||||
checksum: 10c0/a5460a63fe596523b9a067cbce39a6b310d1a71750fda261f076535662aada97c24450e18c5bc98a27784f70500615904ff1227e1742183509f0db4fdede669b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"proxy-addr@npm:~2.0.7":
|
||||
version: 2.0.7
|
||||
resolution: "proxy-addr@npm:2.0.7"
|
||||
@ -53021,6 +53178,9 @@ __metadata:
|
||||
"@nestjs/graphql": "patch:@nestjs/graphql@12.1.1#./patches/@nestjs+graphql+12.1.1.patch"
|
||||
"@node-saml/passport-saml": "npm:^5.0.0"
|
||||
"@nx/js": "npm:18.3.3"
|
||||
"@opentelemetry/api": "npm:^1.9.0"
|
||||
"@opentelemetry/exporter-metrics-otlp-http": "npm:^0.200.0"
|
||||
"@opentelemetry/sdk-metrics": "npm:^2.0.0"
|
||||
"@ptc-org/nestjs-query-graphql": "patch:@ptc-org/nestjs-query-graphql@4.2.0#./patches/@ptc-org+nestjs-query-graphql+4.2.0.patch"
|
||||
"@revertdotdev/revert-react": "npm:^0.0.21"
|
||||
"@sentry/nestjs": "npm:^8.30.0"
|
||||
|
||||
Reference in New Issue
Block a user