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:
Etienne
2025-03-28 08:45:24 +01:00
committed by GitHub
parent e9e33c4d29
commit 391392dd87
32 changed files with 575 additions and 297 deletions

View File

@ -21,10 +21,6 @@ export const SettingsAdminHealthAccountSyncCountersTable = ({
} }
const items = [ const items = [
{
label: 'Not Synced',
value: details.counters.NOT_SYNCED,
},
{ {
label: 'Active Sync', label: 'Active Sync',
value: details.counters.ACTIVE, value: details.counters.ACTIVE,

View File

@ -40,6 +40,7 @@ FRONTEND_URL=http://localhost:3001
# LOGGER_DRIVER=console # LOGGER_DRIVER=console
# LOGGER_IS_BUFFER_ENABLED=true # LOGGER_IS_BUFFER_ENABLED=true
# EXCEPTION_HANDLER_DRIVER=sentry # EXCEPTION_HANDLER_DRIVER=sentry
# METER_DRIVER=opentelemetry,console
# SENTRY_ENVIRONMENT=main # SENTRY_ENVIRONMENT=main
# SENTRY_RELEASE=latest # SENTRY_RELEASE=latest
# SENTRY_DSN=https://xxx@xxx.ingest.sentry.io/xxx # SENTRY_DSN=https://xxx@xxx.ingest.sentry.io/xxx

View File

@ -25,6 +25,9 @@
"@nestjs/devtools-integration": "^0.1.6", "@nestjs/devtools-integration": "^0.1.6",
"@nestjs/graphql": "patch:@nestjs/graphql@12.1.1#./patches/@nestjs+graphql+12.1.1.patch", "@nestjs/graphql": "patch:@nestjs/graphql@12.1.1#./patches/@nestjs+graphql+12.1.1.patch",
"@node-saml/passport-saml": "^5.0.0", "@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", "@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", "@revertdotdev/revert-react": "^0.0.21",
"@sentry/nestjs": "^8.30.0", "@sentry/nestjs": "^8.30.0",

View File

@ -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 { 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 { 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 { 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 { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
import { KeyValuePair } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity'; 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 { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.module';
import { WorkspaceSSOModule } from 'src/engine/core-modules/sso/sso.module'; import { WorkspaceSSOModule } from 'src/engine/core-modules/sso/sso.module';
import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity'; 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, WorkspaceInvitationModule,
EmailVerificationModule, EmailVerificationModule,
GuardRedirectModule, GuardRedirectModule,
HealthModule, MetricsModule,
PermissionsModule, PermissionsModule,
UserRoleModule, UserRoleModule,
], ],

View File

@ -7,13 +7,14 @@ import {
import { GqlExecutionContext } from '@nestjs/graphql'; import { GqlExecutionContext } from '@nestjs/graphql';
import { CaptchaService } from 'src/engine/core-modules/captcha/captcha.service'; 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() @Injectable()
export class CaptchaGuard implements CanActivate { export class CaptchaGuard implements CanActivate {
constructor( constructor(
private captchaService: CaptchaService, private captchaService: CaptchaService,
private healthCacheService: HealthCacheService, private metricsService: MetricsService,
) {} ) {}
async canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
@ -26,7 +27,10 @@ export class CaptchaGuard implements CanActivate {
if (result.success) { if (result.success) {
return true; return true;
} else { } else {
await this.healthCacheService.updateInvalidCaptchaCache(token); await this.metricsService.incrementCounter({
key: MetricsKeys.InvalidCaptcha,
eventId: token,
});
throw new BadRequestException( throw new BadRequestException(
'Invalid Captcha, please try another device', 'Invalid Captcha, please try another device',

View File

@ -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.', '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, isHiddenOnLoad: true,
}, },
[EnvironmentVariablesGroup.Other]: { [EnvironmentVariablesGroup.Metering]: {
position: 900, 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: 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", "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, isHiddenOnLoad: true,
}, },
[EnvironmentVariablesGroup.BillingConfig]: { [EnvironmentVariablesGroup.BillingConfig]: {
position: 1000, position: 1100,
description: description:
'We use Stripe in our Cloud app to charge customers. Not relevant to Self-hosters.', 'We use Stripe in our Cloud app to charge customers. Not relevant to Self-hosters.',
isHiddenOnLoad: true, isHiddenOnLoad: true,
}, },
[EnvironmentVariablesGroup.CaptchaConfig]: { [EnvironmentVariablesGroup.CaptchaConfig]: {
position: 1100, position: 1200,
description: description:
'This protects critical endpoints like login and signup with a captcha to prevent bot attacks. Likely unnecessary for self-hosting scenarios.', 'This protects critical endpoints like login and signup with a captcha to prevent bot attacks. Likely unnecessary for self-hosting scenarios.',
isHiddenOnLoad: true, isHiddenOnLoad: true,
}, },
[EnvironmentVariablesGroup.CloudflareConfig]: { [EnvironmentVariablesGroup.CloudflareConfig]: {
position: 1200, position: 1300,
description: '', description: '',
isHiddenOnLoad: true, isHiddenOnLoad: true,
}, },
[EnvironmentVariablesGroup.LLM]: { [EnvironmentVariablesGroup.LLM]: {
position: 1300, position: 1400,
description: description:
'Configure the LLM provider and model to use for the app. This is experimental and not linked to any public feature.', 'Configure the LLM provider and model to use for the app. This is experimental and not linked to any public feature.',
isHiddenOnLoad: true, isHiddenOnLoad: true,
}, },
[EnvironmentVariablesGroup.ServerlessConfig]: { [EnvironmentVariablesGroup.ServerlessConfig]: {
position: 1400, position: 1500,
description: 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.', '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, isHiddenOnLoad: true,
}, },
[EnvironmentVariablesGroup.SSL]: { [EnvironmentVariablesGroup.SSL]: {
position: 1500, position: 1600,
description: 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.', '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, isHiddenOnLoad: true,
}, },
[EnvironmentVariablesGroup.SupportChatConfig]: { [EnvironmentVariablesGroup.SupportChatConfig]: {
position: 1600, position: 1700,
description: description:
'We use this to setup a small support chat on the bottom left. Currently powered by Front.', 'We use this to setup a small support chat on the bottom left. Currently powered by Front.',
isHiddenOnLoad: true, isHiddenOnLoad: true,
}, },
[EnvironmentVariablesGroup.AnalyticsConfig]: { [EnvironmentVariablesGroup.AnalyticsConfig]: {
position: 1700, position: 1800,
description: description:
'Were running a test to perform analytics within the app. This will evolve.', 'Were running a test to perform analytics within the app. This will evolve.',
isHiddenOnLoad: true, isHiddenOnLoad: true,
}, },
[EnvironmentVariablesGroup.TokensDuration]: { [EnvironmentVariablesGroup.TokensDuration]: {
position: 1800, position: 1900,
description: description:
'These have been set to sensible default so you probably dont need to change them unless you have a specific use-case.', 'These have been set to sensible default so you probably dont need to change them unless you have a specific use-case.',
isHiddenOnLoad: true, isHiddenOnLoad: true,

View File

@ -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;
};

View File

@ -6,6 +6,7 @@ export enum EnvironmentVariablesGroup {
MicrosoftAuth = 'microsoft-auth', MicrosoftAuth = 'microsoft-auth',
EmailSettings = 'email-settings', EmailSettings = 'email-settings',
Logging = 'logging', Logging = 'logging',
Metering = 'metering',
ExceptionHandler = 'exception-handler', ExceptionHandler = 'exception-handler',
Other = 'other', Other = 'other',
BillingConfig = 'billing-config', BillingConfig = 'billing-config',

View File

@ -25,6 +25,7 @@ import { LLMTracingDriver } from 'src/engine/core-modules/llm-tracing/interfaces
import { CaptchaDriverType } from 'src/engine/core-modules/captcha/interfaces'; import { CaptchaDriverType } from 'src/engine/core-modules/captcha/interfaces';
import { CastToBoolean } from 'src/engine/core-modules/environment/decorators/cast-to-boolean.decorator'; 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 { 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 { 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 { 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'; 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 { ExceptionHandlerDriver } from 'src/engine/core-modules/exception-handler/interfaces';
import { StorageDriverType } from 'src/engine/core-modules/file-storage/interfaces'; import { StorageDriverType } from 'src/engine/core-modules/file-storage/interfaces';
import { LoggerDriverType } from 'src/engine/core-modules/logger/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'; import { ServerlessDriverType } from 'src/engine/core-modules/serverless/serverless.interface';
export class EnvironmentVariables { export class EnvironmentVariables {
@ -585,6 +587,22 @@ export class EnvironmentVariables {
@IsOptional() @IsOptional()
LOG_LEVELS: LogLevel[] = ['log', 'error', 'warn']; 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({ @EnvironmentVariablesMetadata({
group: EnvironmentVariablesGroup.ExceptionHandler, group: EnvironmentVariablesGroup.ExceptionHandler,
description: 'Driver used for logging (only console for now)', description: 'Driver used for logging (only console for now)',

View File

@ -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();
});
});

View File

@ -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,
);
}
}

View File

@ -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),
),
);
}
}

View File

@ -3,14 +3,12 @@ import { TerminusModule } from '@nestjs/terminus';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { HealthController } from 'src/engine/core-modules/health/controllers/health.controller'; 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 { 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 { RedisClientModule } from 'src/engine/core-modules/redis-client/redis-client.module';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceMigrationModule } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.module'; 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 { ConnectedAccountHealth } from './indicators/connected-account.health';
import { DatabaseHealthIndicator } from './indicators/database.health'; import { DatabaseHealthIndicator } from './indicators/database.health';
import { RedisHealthIndicator } from './indicators/redis.health'; import { RedisHealthIndicator } from './indicators/redis.health';
@ -21,10 +19,10 @@ import { WorkerHealthIndicator } from './indicators/worker.health';
RedisClientModule, RedisClientModule,
WorkspaceMigrationModule, WorkspaceMigrationModule,
TypeOrmModule.forFeature([Workspace], 'core'), TypeOrmModule.forFeature([Workspace], 'core'),
MetricsModule,
], ],
controllers: [HealthController, MetricsController], controllers: [HealthController],
providers: [ providers: [
HealthCacheService,
DatabaseHealthIndicator, DatabaseHealthIndicator,
RedisHealthIndicator, RedisHealthIndicator,
WorkerHealthIndicator, WorkerHealthIndicator,
@ -32,7 +30,6 @@ import { WorkerHealthIndicator } from './indicators/worker.health';
AppHealthIndicator, AppHealthIndicator,
], ],
exports: [ exports: [
HealthCacheService,
DatabaseHealthIndicator, DatabaseHealthIndicator,
RedisHealthIndicator, RedisHealthIndicator,
WorkerHealthIndicator, WorkerHealthIndicator,

View File

@ -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_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 { 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 { 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 { 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 { 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'; import { MessageChannelSyncStatus } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
describe('ConnectedAccountHealth', () => { describe('ConnectedAccountHealth', () => {
let service: ConnectedAccountHealth; let service: ConnectedAccountHealth;
let healthCacheService: jest.Mocked<HealthCacheService>; let metricsService: jest.Mocked<MetricsService>;
let healthIndicatorService: jest.Mocked<HealthIndicatorService>; let healthIndicatorService: jest.Mocked<HealthIndicatorService>;
beforeEach(async () => { beforeEach(async () => {
healthCacheService = { metricsService = {
countChannelSyncJobByStatus: jest.fn(), groupMetrics: jest.fn(),
} as any; } as any;
healthIndicatorService = { healthIndicatorService = {
@ -41,8 +41,8 @@ describe('ConnectedAccountHealth', () => {
providers: [ providers: [
ConnectedAccountHealth, ConnectedAccountHealth,
{ {
provide: HealthCacheService, provide: MetricsService,
useValue: healthCacheService, useValue: metricsService,
}, },
{ {
provide: HealthIndicatorService, provide: HealthIndicatorService,
@ -64,7 +64,7 @@ describe('ConnectedAccountHealth', () => {
describe('message sync health', () => { describe('message sync health', () => {
it('should return up status when no message sync jobs are present', async () => { it('should return up status when no message sync jobs are present', async () => {
healthCacheService.countChannelSyncJobByStatus metricsService.groupMetrics
.mockResolvedValueOnce({ .mockResolvedValueOnce({
[MessageChannelSyncStatus.NOT_SYNCED]: 0, [MessageChannelSyncStatus.NOT_SYNCED]: 0,
[MessageChannelSyncStatus.ACTIVE]: 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 () => { it(`should return down status when message sync failure rate is above ${METRICS_FAILURE_RATE_THRESHOLD}%`, async () => {
healthCacheService.countChannelSyncJobByStatus metricsService.groupMetrics
.mockResolvedValueOnce({ .mockResolvedValueOnce({
[MessageChannelSyncStatus.NOT_SYNCED]: 0, [MessageChannelSyncStatus.NOT_SYNCED]: 0,
[MessageChannelSyncStatus.ACTIVE]: 1, [MessageChannelSyncStatus.ACTIVE]: 1,
@ -122,7 +122,7 @@ describe('ConnectedAccountHealth', () => {
describe('calendar sync health', () => { describe('calendar sync health', () => {
it('should return up status when no calendar sync jobs are present', async () => { it('should return up status when no calendar sync jobs are present', async () => {
healthCacheService.countChannelSyncJobByStatus metricsService.groupMetrics
.mockResolvedValueOnce({ .mockResolvedValueOnce({
[MessageChannelSyncStatus.NOT_SYNCED]: 0, [MessageChannelSyncStatus.NOT_SYNCED]: 0,
[MessageChannelSyncStatus.ACTIVE]: 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 () => { it(`should return down status when calendar sync failure rate is above ${METRICS_FAILURE_RATE_THRESHOLD}%`, async () => {
healthCacheService.countChannelSyncJobByStatus metricsService.groupMetrics
.mockResolvedValueOnce({ .mockResolvedValueOnce({
[MessageChannelSyncStatus.NOT_SYNCED]: 0, [MessageChannelSyncStatus.NOT_SYNCED]: 0,
[MessageChannelSyncStatus.ACTIVE]: 1, [MessageChannelSyncStatus.ACTIVE]: 1,
@ -180,7 +180,7 @@ describe('ConnectedAccountHealth', () => {
describe('timeout handling', () => { describe('timeout handling', () => {
it('should handle message sync timeout', async () => { it('should handle message sync timeout', async () => {
healthCacheService.countChannelSyncJobByStatus metricsService.groupMetrics
.mockResolvedValueOnce( .mockResolvedValueOnce(
new Promise((resolve) => new Promise((resolve) =>
setTimeout(resolve, HEALTH_INDICATORS_TIMEOUT + 100), setTimeout(resolve, HEALTH_INDICATORS_TIMEOUT + 100),
@ -207,7 +207,7 @@ describe('ConnectedAccountHealth', () => {
}); });
it('should handle calendar sync timeout', async () => { it('should handle calendar sync timeout', async () => {
healthCacheService.countChannelSyncJobByStatus metricsService.groupMetrics
.mockResolvedValueOnce({ .mockResolvedValueOnce({
[MessageChannelSyncStatus.NOT_SYNCED]: 0, [MessageChannelSyncStatus.NOT_SYNCED]: 0,
[MessageChannelSyncStatus.ACTIVE]: 1, [MessageChannelSyncStatus.ACTIVE]: 1,
@ -236,7 +236,7 @@ describe('ConnectedAccountHealth', () => {
describe('combined health check', () => { describe('combined health check', () => {
it('should return combined status with both checks healthy', async () => { it('should return combined status with both checks healthy', async () => {
healthCacheService.countChannelSyncJobByStatus metricsService.groupMetrics
.mockResolvedValueOnce({ .mockResolvedValueOnce({
[MessageChannelSyncStatus.NOT_SYNCED]: 0, [MessageChannelSyncStatus.NOT_SYNCED]: 0,
[MessageChannelSyncStatus.ACTIVE]: 8, [MessageChannelSyncStatus.ACTIVE]: 8,
@ -256,7 +256,7 @@ describe('ConnectedAccountHealth', () => {
}); });
it('should return down status when both syncs fail', async () => { it('should return down status when both syncs fail', async () => {
healthCacheService.countChannelSyncJobByStatus metricsService.groupMetrics
.mockResolvedValueOnce({ .mockResolvedValueOnce({
[MessageChannelSyncStatus.NOT_SYNCED]: 0, [MessageChannelSyncStatus.NOT_SYNCED]: 0,
[MessageChannelSyncStatus.ACTIVE]: 1, [MessageChannelSyncStatus.ACTIVE]: 1,

View File

@ -6,15 +6,17 @@ import {
import { HEALTH_ERROR_MESSAGES } from 'src/engine/core-modules/health/constants/health-error-messages.constants'; 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 { 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 { 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() @Injectable()
export class ConnectedAccountHealth { export class ConnectedAccountHealth {
constructor( constructor(
private readonly healthIndicatorService: HealthIndicatorService, private readonly healthIndicatorService: HealthIndicatorService,
private readonly healthCacheService: HealthCacheService, private readonly metricsService: MetricsService,
) {} ) {}
private async checkMessageSyncHealth(): Promise<HealthIndicatorResult> { private async checkMessageSyncHealth(): Promise<HealthIndicatorResult> {
@ -22,9 +24,7 @@ export class ConnectedAccountHealth {
try { try {
const counters = await withHealthCheckTimeout( const counters = await withHealthCheckTimeout(
this.healthCacheService.countChannelSyncJobByStatus( this.metricsService.groupMetrics(MESSAGE_SYNC_METRICS_BY_STATUS),
HealthCounterCacheKeys.MessageChannelSyncJobByStatus,
),
HEALTH_ERROR_MESSAGES.MESSAGE_SYNC_TIMEOUT, HEALTH_ERROR_MESSAGES.MESSAGE_SYNC_TIMEOUT,
); );
@ -73,9 +73,7 @@ export class ConnectedAccountHealth {
try { try {
const counters = await withHealthCheckTimeout( const counters = await withHealthCheckTimeout(
this.healthCacheService.countChannelSyncJobByStatus( this.metricsService.groupMetrics(CALENDAR_SYNC_METRICS_BY_STATUS),
HealthCounterCacheKeys.CalendarEventSyncJobByStatus,
),
HEALTH_ERROR_MESSAGES.CALENDAR_SYNC_TIMEOUT, HEALTH_ERROR_MESSAGES.CALENDAR_SYNC_TIMEOUT,
); );

View File

@ -1,5 +0,0 @@
export enum HealthCounterCacheKeys {
MessageChannelSyncJobByStatus = 'message-channel-sync-job-by-status',
InvalidCaptcha = 'invalid-captcha',
CalendarEventSyncJobByStatus = 'calendar-event-sync-job-by-status',
}

View File

@ -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,
},
];

View File

@ -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));
}
}

View File

@ -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 {}

View File

@ -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;
}
}

View File

@ -0,0 +1,4 @@
export enum MeterDriver {
OpenTelemetry = 'opentelemetry',
Console = 'console',
}

View File

@ -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',
}

View File

@ -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 { nodeProfilingIntegration } from '@sentry/profiling-node';
import { NodeEnvironment } from 'src/engine/core-modules/environment/interfaces/node-environment.interface'; import { NodeEnvironment } from 'src/engine/core-modules/environment/interfaces/node-environment.interface';
import { ExceptionHandlerDriver } from 'src/engine/core-modules/exception-handler/interfaces'; 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 { 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) { if (process.env.EXCEPTION_HANDLER_DRIVER === ExceptionHandlerDriver.Sentry) {
Sentry.init({ Sentry.init({
@ -29,3 +46,34 @@ if (process.env.EXCEPTION_HANDLER_DRIVER === ExceptionHandlerDriver.Sentry) {
debug: process.env.NODE_ENV === NodeEnvironment.development, 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();
});

View File

@ -3,7 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { BillingModule } from 'src/engine/core-modules/billing/billing.module'; import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; 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 { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceEntity } from 'src/engine/metadata-modules/data-source/data-source.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'; 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, RefreshTokensManagerModule,
ConnectedAccountModule, ConnectedAccountModule,
CalendarCommonModule, CalendarCommonModule,
HealthModule, MetricsModule,
], ],
providers: [ providers: [
CalendarChannelSyncStatusService, CalendarChannelSyncStatusService,

View File

@ -2,7 +2,7 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; 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 { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
import { CalendarChannelSyncStatusService } from 'src/modules/calendar/common/services/calendar-channel-sync-status.service'; import { CalendarChannelSyncStatusService } from 'src/modules/calendar/common/services/calendar-channel-sync-status.service';
import { ConnectedAccountModule } from 'src/modules/connected-account/connected-account.module'; import { ConnectedAccountModule } from 'src/modules/connected-account/connected-account.module';
@ -12,7 +12,7 @@ import { ConnectedAccountModule } from 'src/modules/connected-account/connected-
WorkspaceDataSourceModule, WorkspaceDataSourceModule,
TypeOrmModule.forFeature([FeatureFlag], 'core'), TypeOrmModule.forFeature([FeatureFlag], 'core'),
ConnectedAccountModule, ConnectedAccountModule,
HealthModule, MetricsModule,
], ],
providers: [CalendarChannelSyncStatusService], providers: [CalendarChannelSyncStatusService],
exports: [CalendarChannelSyncStatusService], exports: [CalendarChannelSyncStatusService],

View File

@ -5,8 +5,8 @@ import { Any } from 'typeorm';
import { InjectCacheStorage } from 'src/engine/core-modules/cache-storage/decorators/cache-storage.decorator'; 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 { 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 { 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 { MetricsService } from 'src/engine/core-modules/metrics/metrics.service';
import { HealthCounterCacheKeys } from 'src/engine/core-modules/health/types/health-counter-cache-keys.type'; import { MetricsKeys } from 'src/engine/core-modules/metrics/types/metrics-keys.type';
import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager'; import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
import { import {
CalendarChannelSyncStage, CalendarChannelSyncStage,
@ -24,7 +24,7 @@ export class CalendarChannelSyncStatusService {
@InjectCacheStorage(CacheStorageNamespace.ModuleCalendar) @InjectCacheStorage(CacheStorageNamespace.ModuleCalendar)
private readonly cacheStorage: CacheStorageService, private readonly cacheStorage: CacheStorageService,
private readonly accountsToReconnectService: AccountsToReconnectService, private readonly accountsToReconnectService: AccountsToReconnectService,
private readonly healthCacheService: HealthCacheService, private readonly metricsService: MetricsService,
) {} ) {}
public async scheduleFullCalendarEventListFetch( public async scheduleFullCalendarEventListFetch(
@ -179,11 +179,10 @@ export class CalendarChannelSyncStatusService {
await this.schedulePartialCalendarEventListFetch(calendarChannelIds); await this.schedulePartialCalendarEventListFetch(calendarChannelIds);
await this.healthCacheService.updateMessageOrCalendarChannelSyncJobByStatusCache( await this.metricsService.batchIncrementCounter({
HealthCounterCacheKeys.CalendarEventSyncJobByStatus, key: MetricsKeys.CalendarEventSyncJobActive,
CalendarChannelSyncStatus.ACTIVE, eventIds: calendarChannelIds,
calendarChannelIds, });
);
} }
public async markAsFailedUnknownAndFlushCalendarEventsToImport( public async markAsFailedUnknownAndFlushCalendarEventsToImport(
@ -210,11 +209,10 @@ export class CalendarChannelSyncStatusService {
syncStage: CalendarChannelSyncStage.FAILED, syncStage: CalendarChannelSyncStage.FAILED,
}); });
await this.healthCacheService.updateMessageOrCalendarChannelSyncJobByStatusCache( await this.metricsService.batchIncrementCounter({
HealthCounterCacheKeys.CalendarEventSyncJobByStatus, key: MetricsKeys.CalendarEventSyncJobFailedUnknown,
CalendarChannelSyncStatus.FAILED_UNKNOWN, eventIds: calendarChannelIds,
calendarChannelIds, });
);
} }
public async markAsFailedInsufficientPermissionsAndFlushCalendarEventsToImport( public async markAsFailedInsufficientPermissionsAndFlushCalendarEventsToImport(
@ -266,11 +264,10 @@ export class CalendarChannelSyncStatusService {
workspaceId, workspaceId,
); );
await this.healthCacheService.updateMessageOrCalendarChannelSyncJobByStatusCache( await this.metricsService.batchIncrementCounter({
HealthCounterCacheKeys.CalendarEventSyncJobByStatus, key: MetricsKeys.CalendarEventSyncJobFailedInsufficientPermissions,
CalendarChannelSyncStatus.FAILED_INSUFFICIENT_PERMISSIONS, eventIds: calendarChannelIds,
calendarChannelIds, });
);
} }
private async addToAccountsToReconnect( private async addToAccountsToReconnect(

View File

@ -2,7 +2,7 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; 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 { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
import { ConnectedAccountModule } from 'src/modules/connected-account/connected-account.module'; import { ConnectedAccountModule } from 'src/modules/connected-account/connected-account.module';
import { MessageChannelSyncStatusService } from 'src/modules/messaging/common/services/message-channel-sync-status.service'; 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, WorkspaceDataSourceModule,
TypeOrmModule.forFeature([FeatureFlag], 'core'), TypeOrmModule.forFeature([FeatureFlag], 'core'),
ConnectedAccountModule, ConnectedAccountModule,
HealthModule, MetricsModule,
], ],
providers: [MessageChannelSyncStatusService], providers: [MessageChannelSyncStatusService],
exports: [MessageChannelSyncStatusService], exports: [MessageChannelSyncStatusService],

View File

@ -5,8 +5,8 @@ import { Any } from 'typeorm';
import { InjectCacheStorage } from 'src/engine/core-modules/cache-storage/decorators/cache-storage.decorator'; 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 { 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 { 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 { MetricsService } from 'src/engine/core-modules/metrics/metrics.service';
import { HealthCounterCacheKeys } from 'src/engine/core-modules/health/types/health-counter-cache-keys.type'; import { MetricsKeys } from 'src/engine/core-modules/metrics/types/metrics-keys.type';
import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager'; import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
import { AccountsToReconnectService } from 'src/modules/connected-account/services/accounts-to-reconnect.service'; 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'; 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 cacheStorage: CacheStorageService,
private readonly twentyORMManager: TwentyORMManager, private readonly twentyORMManager: TwentyORMManager,
private readonly accountsToReconnectService: AccountsToReconnectService, private readonly accountsToReconnectService: AccountsToReconnectService,
private readonly healthCacheService: HealthCacheService, private readonly metricsService: MetricsService,
) {} ) {}
public async scheduleFullMessageListFetch(messageChannelIds: string[]) { public async scheduleFullMessageListFetch(messageChannelIds: string[]) {
@ -152,11 +152,10 @@ export class MessageChannelSyncStatusService {
syncedAt: new Date().toISOString(), syncedAt: new Date().toISOString(),
}); });
await this.healthCacheService.updateMessageOrCalendarChannelSyncJobByStatusCache( await this.metricsService.batchIncrementCounter({
HealthCounterCacheKeys.MessageChannelSyncJobByStatus, key: MetricsKeys.MessageChannelSyncJobActive,
MessageChannelSyncStatus.ACTIVE, eventIds: messageChannelIds,
messageChannelIds, });
);
} }
public async markAsMessagesImportOngoing(messageChannelIds: string[]) { public async markAsMessagesImportOngoing(messageChannelIds: string[]) {
@ -199,11 +198,10 @@ export class MessageChannelSyncStatusService {
syncStatus: MessageChannelSyncStatus.FAILED_UNKNOWN, syncStatus: MessageChannelSyncStatus.FAILED_UNKNOWN,
}); });
await this.healthCacheService.updateMessageOrCalendarChannelSyncJobByStatusCache( await this.metricsService.batchIncrementCounter({
HealthCounterCacheKeys.MessageChannelSyncJobByStatus, key: MetricsKeys.MessageChannelSyncJobFailedUnknown,
MessageChannelSyncStatus.FAILED_UNKNOWN, eventIds: messageChannelIds,
messageChannelIds, });
);
} }
public async markAsFailedInsufficientPermissionsAndFlushMessagesToImport( public async markAsFailedInsufficientPermissionsAndFlushMessagesToImport(
@ -230,11 +228,10 @@ export class MessageChannelSyncStatusService {
syncStatus: MessageChannelSyncStatus.FAILED_INSUFFICIENT_PERMISSIONS, syncStatus: MessageChannelSyncStatus.FAILED_INSUFFICIENT_PERMISSIONS,
}); });
await this.healthCacheService.updateMessageOrCalendarChannelSyncJobByStatusCache( await this.metricsService.batchIncrementCounter({
HealthCounterCacheKeys.MessageChannelSyncJobByStatus, key: MetricsKeys.MessageChannelSyncJobFailedInsufficientPermissions,
MessageChannelSyncStatus.FAILED_INSUFFICIENT_PERMISSIONS, eventIds: messageChannelIds,
messageChannelIds, });
);
const connectedAccountRepository = const connectedAccountRepository =
await this.twentyORMManager.getRepository<ConnectedAccountWorkspaceEntity>( await this.twentyORMManager.getRepository<ConnectedAccountWorkspaceEntity>(

View File

@ -3,8 +3,8 @@ import { NestFactory } from '@nestjs/core';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service'; import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { LoggerService } from 'src/engine/core-modules/logger/logger.service'; import { LoggerService } from 'src/engine/core-modules/logger/logger.service';
import { shouldFilterException } from 'src/engine/utils/global-exception-handler.util'; import { shouldFilterException } from 'src/engine/utils/global-exception-handler.util';
import { QueueWorkerModule } from 'src/queue-worker/queue-worker.module';
import 'src/instrument'; import 'src/instrument';
import { QueueWorkerModule } from 'src/queue-worker/queue-worker.module';
async function bootstrap() { async function bootstrap() {
let exceptionHandlerService: ExceptionHandlerService | undefined; let exceptionHandlerService: ExceptionHandlerService | undefined;

View 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;
};

View File

@ -279,7 +279,7 @@ yarn command:prod cron:calendar:ongoing-stale
['SERVERLESS_LAMBDA_SECRET_ACCESS_KEY', '', 'Optional depending on the authentication method'], ['SERVERLESS_LAMBDA_SECRET_ACCESS_KEY', '', 'Optional depending on the authentication method'],
]}></ArticleTable> ]}></ArticleTable>
### Logging ### Logging and Observability
<ArticleTable options={[ <ArticleTable options={[
['LOGGER_DRIVER', 'console', "Currently, only supports 'console'"], ['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_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_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'], ['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> ]}></ArticleTable>

162
yarn.lock
View File

@ -11885,6 +11885,15 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "@opentelemetry/api-logs@npm:0.52.1":
version: 0.52.1 version: 0.52.1
resolution: "@opentelemetry/api-logs@npm:0.52.1" resolution: "@opentelemetry/api-logs@npm:0.52.1"
@ -11903,7 +11912,7 @@ __metadata:
languageName: node languageName: node
linkType: hard 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 version: 1.9.0
resolution: "@opentelemetry/api@npm:1.9.0" resolution: "@opentelemetry/api@npm:1.9.0"
checksum: 10c0/9aae2fe6e8a3a3eeb6c1fdef78e1939cf05a0f37f8a4fae4d6bf2e09eb1e06f966ece85805626e01ba5fab48072b94f19b835449e58b6d26720ee19a58298add checksum: 10c0/9aae2fe6e8a3a3eeb6c1fdef78e1939cf05a0f37f8a4fae4d6bf2e09eb1e06f966ece85805626e01ba5fab48072b94f19b835449e58b6d26720ee19a58298add
@ -11930,6 +11939,32 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "@opentelemetry/instrumentation-connect@npm:0.39.0":
version: 0.39.0 version: 0.39.0
resolution: "@opentelemetry/instrumentation-connect@npm:0.39.0" resolution: "@opentelemetry/instrumentation-connect@npm:0.39.0"
@ -12205,6 +12240,35 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "@opentelemetry/redis-common@npm:^0.36.2":
version: 0.36.2 version: 0.36.2
resolution: "@opentelemetry/redis-common@npm:0.36.2" resolution: "@opentelemetry/redis-common@npm:0.36.2"
@ -12224,6 +12288,43 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "@opentelemetry/sdk-metrics@npm:^1.9.1":
version: 1.26.0 version: 1.26.0
resolution: "@opentelemetry/sdk-metrics@npm:1.26.0" resolution: "@opentelemetry/sdk-metrics@npm:1.26.0"
@ -12236,6 +12337,19 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "@opentelemetry/sdk-trace-base@npm:^1.22, @opentelemetry/sdk-trace-base@npm:^1.26.0":
version: 1.26.0 version: 1.26.0
resolution: "@opentelemetry/sdk-trace-base@npm:1.26.0" resolution: "@opentelemetry/sdk-trace-base@npm:1.26.0"
@ -12256,6 +12370,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "@opentelemetry/sql-common@npm:^0.40.1":
version: 0.40.1 version: 0.40.1
resolution: "@opentelemetry/sql-common@npm:0.40.1" resolution: "@opentelemetry/sql-common@npm:0.40.1"
@ -22204,6 +22325,15 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "@types/node@npm:^10.1.0":
version: 10.17.60 version: 10.17.60
resolution: "@types/node@npm:10.17.60" resolution: "@types/node@npm:10.17.60"
@ -40434,6 +40564,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "longest-streak@npm:^2.0.0":
version: 2.0.4 version: 2.0.4
resolution: "longest-streak@npm:2.0.4" resolution: "longest-streak@npm:2.0.4"
@ -47139,6 +47276,26 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "proxy-addr@npm:~2.0.7":
version: 2.0.7 version: 2.0.7
resolution: "proxy-addr@npm: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" "@nestjs/graphql": "patch:@nestjs/graphql@12.1.1#./patches/@nestjs+graphql+12.1.1.patch"
"@node-saml/passport-saml": "npm:^5.0.0" "@node-saml/passport-saml": "npm:^5.0.0"
"@nx/js": "npm:18.3.3" "@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" "@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" "@revertdotdev/revert-react": "npm:^0.0.21"
"@sentry/nestjs": "npm:^8.30.0" "@sentry/nestjs": "npm:^8.30.0"