mirror of
				https://github.com/immich-app/immich.git
				synced 2025-11-03 19:17:11 -05:00 
			
		
		
		
	feat(server): job metrics (#8255)
* metric repo * add metric repo * remove unused import * formatting * fix * try disabling job metrics for e2e * import otel in test module
This commit is contained in:
		
							parent
							
								
									1855aaea99
								
							
						
					
					
						commit
						c58a70ac8f
					
				@ -51,6 +51,7 @@ import { ILibraryRepository } from 'src/interfaces/library.interface';
 | 
				
			|||||||
import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface';
 | 
					import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface';
 | 
				
			||||||
import { IMediaRepository } from 'src/interfaces/media.interface';
 | 
					import { IMediaRepository } from 'src/interfaces/media.interface';
 | 
				
			||||||
import { IMetadataRepository } from 'src/interfaces/metadata.interface';
 | 
					import { IMetadataRepository } from 'src/interfaces/metadata.interface';
 | 
				
			||||||
 | 
					import { IMetricRepository } from 'src/interfaces/metric.interface';
 | 
				
			||||||
import { IMoveRepository } from 'src/interfaces/move.interface';
 | 
					import { IMoveRepository } from 'src/interfaces/move.interface';
 | 
				
			||||||
import { IPartnerRepository } from 'src/interfaces/partner.interface';
 | 
					import { IPartnerRepository } from 'src/interfaces/partner.interface';
 | 
				
			||||||
import { IPersonRepository } from 'src/interfaces/person.interface';
 | 
					import { IPersonRepository } from 'src/interfaces/person.interface';
 | 
				
			||||||
@ -83,6 +84,7 @@ import { LibraryRepository } from 'src/repositories/library.repository';
 | 
				
			|||||||
import { MachineLearningRepository } from 'src/repositories/machine-learning.repository';
 | 
					import { MachineLearningRepository } from 'src/repositories/machine-learning.repository';
 | 
				
			||||||
import { MediaRepository } from 'src/repositories/media.repository';
 | 
					import { MediaRepository } from 'src/repositories/media.repository';
 | 
				
			||||||
import { MetadataRepository } from 'src/repositories/metadata.repository';
 | 
					import { MetadataRepository } from 'src/repositories/metadata.repository';
 | 
				
			||||||
 | 
					import { MetricRepository } from 'src/repositories/metric.repository';
 | 
				
			||||||
import { MoveRepository } from 'src/repositories/move.repository';
 | 
					import { MoveRepository } from 'src/repositories/move.repository';
 | 
				
			||||||
import { PartnerRepository } from 'src/repositories/partner.repository';
 | 
					import { PartnerRepository } from 'src/repositories/partner.repository';
 | 
				
			||||||
import { PersonRepository } from 'src/repositories/person.repository';
 | 
					import { PersonRepository } from 'src/repositories/person.repository';
 | 
				
			||||||
@ -163,7 +165,6 @@ const controllers = [
 | 
				
			|||||||
const services: Provider[] = [
 | 
					const services: Provider[] = [
 | 
				
			||||||
  ApiService,
 | 
					  ApiService,
 | 
				
			||||||
  MicroservicesService,
 | 
					  MicroservicesService,
 | 
				
			||||||
 | 
					 | 
				
			||||||
  APIKeyService,
 | 
					  APIKeyService,
 | 
				
			||||||
  ActivityService,
 | 
					  ActivityService,
 | 
				
			||||||
  AlbumService,
 | 
					  AlbumService,
 | 
				
			||||||
@ -208,6 +209,7 @@ const repositories: Provider[] = [
 | 
				
			|||||||
  { provide: IKeyRepository, useClass: ApiKeyRepository },
 | 
					  { provide: IKeyRepository, useClass: ApiKeyRepository },
 | 
				
			||||||
  { provide: IMachineLearningRepository, useClass: MachineLearningRepository },
 | 
					  { provide: IMachineLearningRepository, useClass: MachineLearningRepository },
 | 
				
			||||||
  { provide: IMetadataRepository, useClass: MetadataRepository },
 | 
					  { provide: IMetadataRepository, useClass: MetadataRepository },
 | 
				
			||||||
 | 
					  { provide: IMetricRepository, useClass: MetricRepository },
 | 
				
			||||||
  { provide: IMoveRepository, useClass: MoveRepository },
 | 
					  { provide: IMoveRepository, useClass: MoveRepository },
 | 
				
			||||||
  { provide: IPartnerRepository, useClass: PartnerRepository },
 | 
					  { provide: IPartnerRepository, useClass: PartnerRepository },
 | 
				
			||||||
  { provide: IPersonRepository, useClass: PersonRepository },
 | 
					  { provide: IPersonRepository, useClass: PersonRepository },
 | 
				
			||||||
@ -277,6 +279,7 @@ export class ImmichAdminModule {}
 | 
				
			|||||||
    EventEmitterModule.forRoot(),
 | 
					    EventEmitterModule.forRoot(),
 | 
				
			||||||
    TypeOrmModule.forRoot(databaseConfig),
 | 
					    TypeOrmModule.forRoot(databaseConfig),
 | 
				
			||||||
    TypeOrmModule.forFeature(databaseEntities),
 | 
					    TypeOrmModule.forFeature(databaseEntities),
 | 
				
			||||||
 | 
					    OpenTelemetryModule.forRoot(otelConfig),
 | 
				
			||||||
  ],
 | 
					  ],
 | 
				
			||||||
  controllers: [...controllers],
 | 
					  controllers: [...controllers],
 | 
				
			||||||
  providers: [...services, ...repositories, ...middleware, SchedulerRegistry],
 | 
					  providers: [...services, ...repositories, ...middleware, SchedulerRegistry],
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										13
									
								
								server/src/interfaces/metric.interface.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								server/src/interfaces/metric.interface.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,13 @@
 | 
				
			|||||||
 | 
					import { MetricOptions } from '@opentelemetry/api';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export interface CustomMetricOptions extends MetricOptions {
 | 
				
			||||||
 | 
					  enabled?: boolean;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const IMetricRepository = 'IMetricRepository';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export interface IMetricRepository {
 | 
				
			||||||
 | 
					  addToCounter(name: string, value: number, options?: CustomMetricOptions): void;
 | 
				
			||||||
 | 
					  updateGauge(name: string, value: number, options?: CustomMetricOptions): void;
 | 
				
			||||||
 | 
					  updateHistogram(name: string, value: number, options?: CustomMetricOptions): void;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										31
									
								
								server/src/repositories/metric.repository.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								server/src/repositories/metric.repository.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,31 @@
 | 
				
			|||||||
 | 
					import { Inject } from '@nestjs/common';
 | 
				
			||||||
 | 
					import { MetricService } from 'nestjs-otel';
 | 
				
			||||||
 | 
					import { CustomMetricOptions, IMetricRepository } from 'src/interfaces/metric.interface';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export class MetricRepository implements IMetricRepository {
 | 
				
			||||||
 | 
					  constructor(@Inject(MetricService) private readonly metricService: MetricService) {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  addToCounter(name: string, value: number, options?: CustomMetricOptions): void {
 | 
				
			||||||
 | 
					    if (options?.enabled === false) {
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    this.metricService.getCounter(name, options).add(value);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  updateGauge(name: string, value: number, options?: CustomMetricOptions): void {
 | 
				
			||||||
 | 
					    if (options?.enabled === false) {
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    this.metricService.getUpDownCounter(name, options).add(value);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  updateHistogram(name: string, value: number, options?: CustomMetricOptions): void {
 | 
				
			||||||
 | 
					    if (options?.enabled === false) {
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    this.metricService.getHistogram(name, options).record(value);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -12,6 +12,7 @@ import {
 | 
				
			|||||||
  JobStatus,
 | 
					  JobStatus,
 | 
				
			||||||
  QueueName,
 | 
					  QueueName,
 | 
				
			||||||
} from 'src/interfaces/job.interface';
 | 
					} from 'src/interfaces/job.interface';
 | 
				
			||||||
 | 
					import { IMetricRepository } from 'src/interfaces/metric.interface';
 | 
				
			||||||
import { IPersonRepository } from 'src/interfaces/person.interface';
 | 
					import { IPersonRepository } from 'src/interfaces/person.interface';
 | 
				
			||||||
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface';
 | 
					import { ISystemConfigRepository } from 'src/interfaces/system-config.interface';
 | 
				
			||||||
import { JobService } from 'src/services/job.service';
 | 
					import { JobService } from 'src/services/job.service';
 | 
				
			||||||
@ -19,6 +20,7 @@ import { assetStub } from 'test/fixtures/asset.stub';
 | 
				
			|||||||
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
 | 
					import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
 | 
				
			||||||
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
 | 
					import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
 | 
				
			||||||
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
 | 
					import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
 | 
				
			||||||
 | 
					import { newMetricRepositoryMock } from 'test/repositories/metric.repository.mock';
 | 
				
			||||||
import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock';
 | 
					import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock';
 | 
				
			||||||
import { newSystemConfigRepositoryMock } from 'test/repositories/system-config.repository.mock';
 | 
					import { newSystemConfigRepositoryMock } from 'test/repositories/system-config.repository.mock';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -37,6 +39,7 @@ describe(JobService.name, () => {
 | 
				
			|||||||
  let eventMock: jest.Mocked<IEventRepository>;
 | 
					  let eventMock: jest.Mocked<IEventRepository>;
 | 
				
			||||||
  let jobMock: jest.Mocked<IJobRepository>;
 | 
					  let jobMock: jest.Mocked<IJobRepository>;
 | 
				
			||||||
  let personMock: jest.Mocked<IPersonRepository>;
 | 
					  let personMock: jest.Mocked<IPersonRepository>;
 | 
				
			||||||
 | 
					  let metricMock: jest.Mocked<IMetricRepository>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  beforeEach(() => {
 | 
					  beforeEach(() => {
 | 
				
			||||||
    assetMock = newAssetRepositoryMock();
 | 
					    assetMock = newAssetRepositoryMock();
 | 
				
			||||||
@ -44,7 +47,8 @@ describe(JobService.name, () => {
 | 
				
			|||||||
    eventMock = newEventRepositoryMock();
 | 
					    eventMock = newEventRepositoryMock();
 | 
				
			||||||
    jobMock = newJobRepositoryMock();
 | 
					    jobMock = newJobRepositoryMock();
 | 
				
			||||||
    personMock = newPersonRepositoryMock();
 | 
					    personMock = newPersonRepositoryMock();
 | 
				
			||||||
    sut = new JobService(assetMock, eventMock, jobMock, configMock, personMock);
 | 
					    metricMock = newMetricRepositoryMock();
 | 
				
			||||||
 | 
					    sut = new JobService(assetMock, eventMock, jobMock, configMock, personMock, metricMock);
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  it('should work', () => {
 | 
					  it('should work', () => {
 | 
				
			||||||
 | 
				
			|||||||
@ -1,4 +1,5 @@
 | 
				
			|||||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
 | 
					import { BadRequestException, Inject, Injectable } from '@nestjs/common';
 | 
				
			||||||
 | 
					import { snakeCase } from 'lodash';
 | 
				
			||||||
import { FeatureFlag, SystemConfigCore } from 'src/cores/system-config.core';
 | 
					import { FeatureFlag, SystemConfigCore } from 'src/cores/system-config.core';
 | 
				
			||||||
import { mapAsset } from 'src/dtos/asset-response.dto';
 | 
					import { mapAsset } from 'src/dtos/asset-response.dto';
 | 
				
			||||||
import { AllJobStatusResponseDto, JobCommandDto, JobStatusDto } from 'src/dtos/job.dto';
 | 
					import { AllJobStatusResponseDto, JobCommandDto, JobStatusDto } from 'src/dtos/job.dto';
 | 
				
			||||||
@ -16,8 +17,10 @@ import {
 | 
				
			|||||||
  QueueCleanType,
 | 
					  QueueCleanType,
 | 
				
			||||||
  QueueName,
 | 
					  QueueName,
 | 
				
			||||||
} from 'src/interfaces/job.interface';
 | 
					} from 'src/interfaces/job.interface';
 | 
				
			||||||
 | 
					import { IMetricRepository } from 'src/interfaces/metric.interface';
 | 
				
			||||||
import { IPersonRepository } from 'src/interfaces/person.interface';
 | 
					import { IPersonRepository } from 'src/interfaces/person.interface';
 | 
				
			||||||
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface';
 | 
					import { ISystemConfigRepository } from 'src/interfaces/system-config.interface';
 | 
				
			||||||
 | 
					import { jobMetrics } from 'src/utils/instrumentation';
 | 
				
			||||||
import { ImmichLogger } from 'src/utils/logger';
 | 
					import { ImmichLogger } from 'src/utils/logger';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@Injectable()
 | 
					@Injectable()
 | 
				
			||||||
@ -31,6 +34,7 @@ export class JobService {
 | 
				
			|||||||
    @Inject(IJobRepository) private jobRepository: IJobRepository,
 | 
					    @Inject(IJobRepository) private jobRepository: IJobRepository,
 | 
				
			||||||
    @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
 | 
					    @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
 | 
				
			||||||
    @Inject(IPersonRepository) private personRepository: IPersonRepository,
 | 
					    @Inject(IPersonRepository) private personRepository: IPersonRepository,
 | 
				
			||||||
 | 
					    @Inject(IMetricRepository) private metricRepository: IMetricRepository,
 | 
				
			||||||
  ) {
 | 
					  ) {
 | 
				
			||||||
    this.configCore = SystemConfigCore.create(configRepository);
 | 
					    this.configCore = SystemConfigCore.create(configRepository);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
@ -92,6 +96,8 @@ export class JobService {
 | 
				
			|||||||
      throw new BadRequestException(`Job is already running`);
 | 
					      throw new BadRequestException(`Job is already running`);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    this.metricRepository.addToCounter(`immich.queues.${snakeCase(name)}.started`, 1), { enabled: jobMetrics };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    switch (name) {
 | 
					    switch (name) {
 | 
				
			||||||
      case QueueName.VIDEO_CONVERSION: {
 | 
					      case QueueName.VIDEO_CONVERSION: {
 | 
				
			||||||
        return this.jobRepository.queue({ name: JobName.QUEUE_VIDEO_CONVERSION, data: { force } });
 | 
					        return this.jobRepository.queue({ name: JobName.QUEUE_VIDEO_CONVERSION, data: { force } });
 | 
				
			||||||
@ -156,14 +162,21 @@ export class JobService {
 | 
				
			|||||||
      this.jobRepository.addHandler(queueName, concurrency, async (item: JobItem): Promise<void> => {
 | 
					      this.jobRepository.addHandler(queueName, concurrency, async (item: JobItem): Promise<void> => {
 | 
				
			||||||
        const { name, data } = item;
 | 
					        const { name, data } = item;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        const queueMetric = `immich.queues.${snakeCase(queueName)}.active`;
 | 
				
			||||||
 | 
					        this.metricRepository.updateGauge(queueMetric, 1, { enabled: jobMetrics });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        try {
 | 
					        try {
 | 
				
			||||||
          const handler = jobHandlers[name];
 | 
					          const handler = jobHandlers[name];
 | 
				
			||||||
          const status = await handler(data);
 | 
					          const status = await handler(data);
 | 
				
			||||||
 | 
					          const jobMetric = `immich.jobs.${name.replaceAll('-', '_')}.${status}`;
 | 
				
			||||||
 | 
					          this.metricRepository.addToCounter(jobMetric, 1, { enabled: jobMetrics });
 | 
				
			||||||
          if (status === JobStatus.SUCCESS || status == JobStatus.SKIPPED) {
 | 
					          if (status === JobStatus.SUCCESS || status == JobStatus.SKIPPED) {
 | 
				
			||||||
            await this.onDone(item);
 | 
					            await this.onDone(item);
 | 
				
			||||||
          }
 | 
					          }
 | 
				
			||||||
        } catch (error: Error | any) {
 | 
					        } catch (error: Error | any) {
 | 
				
			||||||
          this.logger.error(`Unable to run job handler (${queueName}/${name}): ${error}`, error?.stack, data);
 | 
					          this.logger.error(`Unable to run job handler (${queueName}/${name}): ${error}`, error?.stack, data);
 | 
				
			||||||
 | 
					        } finally {
 | 
				
			||||||
 | 
					          this.metricRepository.updateGauge(queueMetric, -1, { enabled: jobMetrics });
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
				
			|||||||
@ -17,12 +17,16 @@ import { excludePaths, serverVersion } from 'src/constants';
 | 
				
			|||||||
import { DecorateAll } from 'src/decorators';
 | 
					import { DecorateAll } from 'src/decorators';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
let metricsEnabled = process.env.IMMICH_METRICS === 'true';
 | 
					let metricsEnabled = process.env.IMMICH_METRICS === 'true';
 | 
				
			||||||
const hostMetrics =
 | 
					export const hostMetrics =
 | 
				
			||||||
  process.env.IMMICH_HOST_METRICS == null ? metricsEnabled : process.env.IMMICH_HOST_METRICS === 'true';
 | 
					  process.env.IMMICH_HOST_METRICS == null ? metricsEnabled : process.env.IMMICH_HOST_METRICS === 'true';
 | 
				
			||||||
const apiMetrics = process.env.IMMICH_API_METRICS == null ? metricsEnabled : process.env.IMMICH_API_METRICS === 'true';
 | 
					export const apiMetrics =
 | 
				
			||||||
const repoMetrics = process.env.IMMICH_IO_METRICS == null ? metricsEnabled : process.env.IMMICH_IO_METRICS === 'true';
 | 
					  process.env.IMMICH_API_METRICS == null ? metricsEnabled : process.env.IMMICH_API_METRICS === 'true';
 | 
				
			||||||
 | 
					export const repoMetrics =
 | 
				
			||||||
 | 
					  process.env.IMMICH_IO_METRICS == null ? metricsEnabled : process.env.IMMICH_IO_METRICS === 'true';
 | 
				
			||||||
 | 
					export const jobMetrics =
 | 
				
			||||||
 | 
					  process.env.IMMICH_JOB_METRICS == null ? metricsEnabled : process.env.IMMICH_JOB_METRICS === 'true';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
metricsEnabled ||= hostMetrics || apiMetrics || repoMetrics;
 | 
					metricsEnabled ||= hostMetrics || apiMetrics || repoMetrics || jobMetrics;
 | 
				
			||||||
if (!metricsEnabled && process.env.OTEL_SDK_DISABLED === undefined) {
 | 
					if (!metricsEnabled && process.env.OTEL_SDK_DISABLED === undefined) {
 | 
				
			||||||
  process.env.OTEL_SDK_DISABLED = 'true';
 | 
					  process.env.OTEL_SDK_DISABLED = 'true';
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										9
									
								
								server/test/repositories/metric.repository.mock.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								server/test/repositories/metric.repository.mock.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,9 @@
 | 
				
			|||||||
 | 
					import { IMetricRepository } from 'src/interfaces/metric.interface';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const newMetricRepositoryMock = (): jest.Mocked<IMetricRepository> => {
 | 
				
			||||||
 | 
					  return {
 | 
				
			||||||
 | 
					    addToCounter: jest.fn(),
 | 
				
			||||||
 | 
					    updateGauge: jest.fn(),
 | 
				
			||||||
 | 
					    updateHistogram: jest.fn(),
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user