mirror of
https://github.com/immich-app/immich.git
synced 2025-01-04 02:46:47 +01: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
7 changed files with 83 additions and 6 deletions
|
@ -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…
Reference in a new issue