mirror of
https://github.com/apricote/Listory.git
synced 2026-01-13 21:21:02 +00:00
feat(observability): Replace Prometheus package with OpenTelemetry
This commit is contained in:
parent
f67383b761
commit
6b1640b753
22 changed files with 2391 additions and 568 deletions
18
README.md
18
README.md
|
|
@ -50,16 +50,20 @@ You can use Sentry to automatically detect and report any exceptions thrown.
|
|||
- `SENTRY_ENABLED`: **false**, Set to `true` to enable Sentry.
|
||||
- `SENTRY_DSN`: _Required_, but only if `SENTRY_ENABLED` is `true`. The [DSN](https://docs.sentry.io/product/sentry-basics/dsn-explainer/) for your Sentry project.
|
||||
|
||||
#### Prometheus
|
||||
#### OpenTelemetry
|
||||
|
||||
You can use Prometheus to track various metrics about your Listory deployment.
|
||||
We use OpenTelemetry to provide observability into Listory API.
|
||||
|
||||
The metrics will be exposed on the `/api/metrics` endpoint. Make sure that this endpoint is not publicly available in your deployment.
|
||||
The metrics will be exposed on a seperate port at `:9464/metrics`. Make sure that this endpoint is not publicly available in your deployment.
|
||||
|
||||
- `PROMETHEUS_ENABLED`: **false**, Set to `true` to enable Prometheus Metrics.
|
||||
- `PROMETHEUS_BASIC_AUTH`: **false**, Set to `true` to require basic auth to access the metrics endpoint.
|
||||
- `PROMETHEUS_BASIC_AUTH_USERNAME`: _Required_, if `PROMETHEUS_BASIC_AUTH` is `true`.
|
||||
- `PROMETHEUS_BASIC_AUTH_PASSWORD`: _Required_, if `PROMETHEUS_BASIC_AUTH` is `true`.
|
||||
Traces will be sent to the specified endpoint.
|
||||
|
||||
To use observability tools locally, check out `docker-compose` setup in `observability/`.
|
||||
|
||||
- `OTEL_METRICS_ENABLED`: **false**, Set to `true` to activate metrics.
|
||||
- `OTEL_TRACES_ENABLED`: **false**, Set to `true` to activate traces.
|
||||
- `OTEL_EXPORTER_OTLP_ENDPOINT`: _Required_, but only if `OTEL_TRACES_ENABLED` is `true`. The endpoint that traces are sent to, see [OpenTelemetry docs](https://github.com/open-telemetry/opentelemetry-js/tree/main/experimental/packages/exporter-trace-otlp-http#configuration-options-as-environment-variables)
|
||||
- `OTEL_EXPORTER_PROMETHEUS_PORT`: **9464**, Set to configure non-standard port for Prometheus metrics
|
||||
|
||||
## Development
|
||||
|
||||
|
|
|
|||
|
|
@ -63,32 +63,30 @@ spec:
|
|||
value: {{ .Values.sentry.dsn }}
|
||||
{{- end }}
|
||||
|
||||
{{- if .Values.prometheus.enabled }}
|
||||
- name: PROMETHEUS_ENABLED
|
||||
{{- if .Values.opentelemetry.metrics.enabled }}
|
||||
- name: OTEL_METRICS_ENABLED
|
||||
value: "true"
|
||||
|
||||
{{- if .Values.prometheus.basicAuth.enabled }}
|
||||
- name: PROMETHEUS_BASIC_AUTH
|
||||
value: "true"
|
||||
- name: PROMETHEUS_BASIC_AUTH_USERNAME
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "listory.fullname" . }}
|
||||
key: prometheus-basic-auth-username
|
||||
- name: PROMETHEUS_BASIC_AUTH_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "listory.fullname" . }}
|
||||
key: prometheus-basic-auth-password
|
||||
{{- end }}
|
||||
- name: OTEL_EXPORTER_PROMETHEUS_PORT
|
||||
value: "{{ .Values.opentelemetry.metrics.port }}"
|
||||
{{- end }}
|
||||
|
||||
{{- if .Values.opentelemetry.traces.enabled }}
|
||||
- name: OTEL_TRACES_ENABLED
|
||||
value: "true"
|
||||
- name: OTEL_EXPORTER_OTLP_ENDPOINT
|
||||
value: "{{ .Values.opentelemetry.traces.otlpEndpoint }}"
|
||||
{{- end }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.securityContext | nindent 12 }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 3000
|
||||
protocol: TCP
|
||||
{{- if .Values.opentelemetry.metrics.enabled }}
|
||||
- name: metrics
|
||||
containerPort: {{ .Values.opentelemetry.metrics.port }}
|
||||
protocol: TCP
|
||||
{{- end }}
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/v1/health
|
||||
|
|
|
|||
|
|
@ -11,5 +11,11 @@ spec:
|
|||
targetPort: http
|
||||
protocol: TCP
|
||||
name: http
|
||||
{{- if .Values.opentelemetry.metrics.enabled }}
|
||||
- port: {{ .Values.opentelemetry.metrics.port }}
|
||||
targetPort: metrics
|
||||
protocol: TCP
|
||||
name: metrics
|
||||
{{- end }}
|
||||
selector:
|
||||
{{- include "listory.selectorLabels" . | nindent 4 }}
|
||||
|
|
|
|||
|
|
@ -90,10 +90,10 @@ sentry:
|
|||
enabled: false
|
||||
dsn: ""
|
||||
|
||||
prometheus:
|
||||
opentelemetry:
|
||||
metrics:
|
||||
enabled: false
|
||||
|
||||
basicAuth:
|
||||
port: 9464
|
||||
traces:
|
||||
enabled: false
|
||||
username: ""
|
||||
password: ""
|
||||
otlpEndpoint: ""
|
||||
|
|
|
|||
|
|
@ -22,11 +22,15 @@ services:
|
|||
DB_DATABASE: listory
|
||||
JWT_SECRET: listory
|
||||
APP_URL: "http://localhost:3000"
|
||||
NODE_ENV: local # pretty logs
|
||||
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo:55681/v1/traces
|
||||
env_file: .env
|
||||
volumes:
|
||||
- ./src:/app/src
|
||||
ports:
|
||||
- 3000
|
||||
- 3000 # API
|
||||
- "9464:9464" # Metrics
|
||||
labels:
|
||||
- "traefik.enable=true" # Enable reverse-proxy for this service
|
||||
- "traefik.http.routers.api.rule=PathPrefix(`/api`)"
|
||||
|
|
|
|||
2473
package-lock.json
generated
2473
package-lock.json
generated
File diff suppressed because it is too large
Load diff
27
package.json
27
package.json
|
|
@ -25,31 +25,50 @@
|
|||
"test:e2e": "jest --config ./apps/listory/test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@digikare/nestjs-prom": "1.0.0",
|
||||
"@hapi/joi": "17.1.1",
|
||||
"@nestjs/axios": "0.0.4",
|
||||
"@nestjs/common": "8.1.2",
|
||||
"@nestjs/config": "1.1.5",
|
||||
"@nestjs/core": "8.1.2",
|
||||
"@nestjs/jwt": "8.0.0",
|
||||
"@nestjs/passport": "8.0.1",
|
||||
"@nestjs/passport": "8.2.2",
|
||||
"@nestjs/platform-express": "8.1.2",
|
||||
"@nestjs/schedule": "1.0.2",
|
||||
"@nestjs/serve-static": "2.2.2",
|
||||
"@nestjs/swagger": "5.1.5",
|
||||
"@nestjs/swagger": "5.2.1",
|
||||
"@nestjs/terminus": "8.0.3",
|
||||
"@nestjs/typeorm": "8.0.2",
|
||||
"@opentelemetry/api": "1.0.4",
|
||||
"@opentelemetry/context-async-hooks": "1.0.1",
|
||||
"@opentelemetry/exporter-prometheus": "0.27.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "0.27.0",
|
||||
"@opentelemetry/instrumentation": "0.27.0",
|
||||
"@opentelemetry/instrumentation-dns": "0.27.1",
|
||||
"@opentelemetry/instrumentation-express": "0.27.0",
|
||||
"@opentelemetry/instrumentation-http": "0.27.0",
|
||||
"@opentelemetry/instrumentation-nestjs-core": "0.28.3",
|
||||
"@opentelemetry/instrumentation-pg": "0.27.0",
|
||||
"@opentelemetry/instrumentation-pino": "0.28.1",
|
||||
"@opentelemetry/resources": "1.0.1",
|
||||
"@opentelemetry/sdk-metrics-base": "0.27.0",
|
||||
"@opentelemetry/sdk-node": "0.27.0",
|
||||
"@opentelemetry/sdk-trace-base": "1.0.1",
|
||||
"@opentelemetry/semantic-conventions": "1.0.1",
|
||||
"@sentry/node": "6.19.7",
|
||||
"class-transformer": "0.5.1",
|
||||
"class-validator": "0.13.2",
|
||||
"cookie-parser": "1.4.6",
|
||||
"date-fns": "2.27.0",
|
||||
"nest-raven": "8.1.0",
|
||||
"nestjs-otel": "3.0.4",
|
||||
"nestjs-pino": "2.6.0",
|
||||
"nestjs-typeorm-paginate": "2.6.3",
|
||||
"passport": "0.5.0",
|
||||
"passport-jwt": "4.0.0",
|
||||
"passport-spotify": "2.0.0",
|
||||
"pg": "8.7.1",
|
||||
"pino": "7.11.0",
|
||||
"pino-http": "6.6.0",
|
||||
"reflect-metadata": "0.1.13",
|
||||
"rimraf": "3.0.2",
|
||||
"rxjs": "7.4.0",
|
||||
|
|
@ -64,6 +83,7 @@
|
|||
"@types/express": "4.17.13",
|
||||
"@types/hapi__joi": "17.1.7",
|
||||
"@types/jest": "27.0.3",
|
||||
"@types/long": "^4.0.2",
|
||||
"@types/node": "15.6.0",
|
||||
"@types/passport-jwt": "3.0.6",
|
||||
"@types/supertest": "2.0.11",
|
||||
|
|
@ -80,6 +100,7 @@
|
|||
"eslint-plugin-react": "7.30.0",
|
||||
"eslint-plugin-react-hooks": "4.3.0",
|
||||
"jest": "27.4.3",
|
||||
"pino-pretty": "8.0.0",
|
||||
"prettier": "2.5.1",
|
||||
"supertest": "6.1.6",
|
||||
"ts-jest": "27.1.1",
|
||||
|
|
|
|||
|
|
@ -9,11 +9,11 @@ import { DatabaseModule } from "./database/database.module";
|
|||
import { HealthCheckModule } from "./health-check/health-check.module";
|
||||
import { ListensModule } from "./listens/listens.module";
|
||||
import { LoggerModule } from "./logger/logger.module";
|
||||
import { MetricsModule } from "./metrics/metrics.module";
|
||||
import { MusicLibraryModule } from "./music-library/music-library.module";
|
||||
import { ReportsModule } from "./reports/reports.module";
|
||||
import { SourcesModule } from "./sources/sources.module";
|
||||
import { UsersModule } from "./users/users.module";
|
||||
import { OpenTelemetryModule } from "./open-telemetry/open-telemetry.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -26,7 +26,7 @@ import { UsersModule } from "./users/users.module";
|
|||
exclude: ["/api*"],
|
||||
}),
|
||||
RavenModule,
|
||||
MetricsModule.forRoot(),
|
||||
OpenTelemetryModule,
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
SourcesModule,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import type { Response } from "express";
|
||||
import { Logger } from "../logger/logger.service";
|
||||
import { User } from "../users/user.entity";
|
||||
import { AuthSession } from "./auth-session.entity";
|
||||
import { AuthController } from "./auth.controller";
|
||||
|
|
@ -14,10 +13,7 @@ describe("AuthController", () => {
|
|||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
{ provide: AuthService, useFactory: () => ({}) },
|
||||
{ provide: Logger, useClass: Logger },
|
||||
],
|
||||
providers: [{ provide: AuthService, useFactory: () => ({}) }],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<AuthController>(AuthController);
|
||||
|
|
|
|||
|
|
@ -3,15 +3,13 @@ import {
|
|||
Catch,
|
||||
ExceptionFilter,
|
||||
ForbiddenException,
|
||||
Logger,
|
||||
} from "@nestjs/common";
|
||||
import type { Response } from "express";
|
||||
import { Logger } from "../logger/logger.service";
|
||||
|
||||
@Catch()
|
||||
export class SpotifyAuthFilter implements ExceptionFilter {
|
||||
constructor(private readonly logger: Logger) {
|
||||
this.logger.setContext(this.constructor.name);
|
||||
}
|
||||
private readonly logger = new Logger(this.constructor.name);
|
||||
|
||||
catch(exception: Error, host: ArgumentsHost) {
|
||||
const response = host.switchToHttp().getResponse<Response>();
|
||||
|
|
|
|||
|
|
@ -1,9 +1,18 @@
|
|||
import { Module, Global } from "@nestjs/common";
|
||||
import { Logger } from "./logger.service";
|
||||
import { Module, RequestMethod } from "@nestjs/common";
|
||||
import { LoggerModule as PinoLoggerModule } from "nestjs-pino";
|
||||
import { logger } from "./logger";
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [Logger],
|
||||
exports: [Logger],
|
||||
imports: [
|
||||
PinoLoggerModule.forRoot({
|
||||
pinoHttp: {
|
||||
logger: logger,
|
||||
autoLogging: true,
|
||||
quietReqLogger: true,
|
||||
redact: ["req.headers", "res.headers"],
|
||||
},
|
||||
exclude: [{ method: RequestMethod.ALL, path: "/" }],
|
||||
}),
|
||||
],
|
||||
})
|
||||
export class LoggerModule {}
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
import { Injectable, Scope, ConsoleLogger } from "@nestjs/common";
|
||||
|
||||
@Injectable({ scope: Scope.TRANSIENT })
|
||||
export class Logger extends ConsoleLogger {}
|
||||
32
src/logger/logger.ts
Normal file
32
src/logger/logger.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { context, trace } from "@opentelemetry/api";
|
||||
import Pino, { Logger, LoggerOptions } from "pino";
|
||||
|
||||
export const loggerOptions: LoggerOptions = {
|
||||
level: "debug",
|
||||
formatters: {
|
||||
level(label) {
|
||||
return { level: label };
|
||||
},
|
||||
log(object) {
|
||||
const span = trace.getSpan(context.active());
|
||||
if (!span) return { ...object };
|
||||
const { spanId, traceId } = trace
|
||||
.getSpan(context.active())
|
||||
?.spanContext();
|
||||
return { ...object, spanId, traceId };
|
||||
},
|
||||
},
|
||||
transport:
|
||||
process.env.NODE_ENV === "local"
|
||||
? {
|
||||
target: "pino-pretty",
|
||||
options: {
|
||||
colorize: true,
|
||||
levelFirst: true,
|
||||
translateTime: true,
|
||||
},
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
export const logger: Logger = Pino(loggerOptions);
|
||||
27
src/main.ts
27
src/main.ts
|
|
@ -1,11 +1,31 @@
|
|||
import { otelSDK } from "./open-telemetry/sdk"; // needs to be loaded first - always -
|
||||
import { ValidationPipe } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
|
||||
import { context, trace } from "@opentelemetry/api";
|
||||
import * as Sentry from "@sentry/node";
|
||||
import { RavenInterceptor } from "nest-raven";
|
||||
import Pino from "pino";
|
||||
import { AppModule } from "./app.module";
|
||||
import { Logger } from "nestjs-pino";
|
||||
|
||||
const logger = Pino({
|
||||
formatters: {
|
||||
log(object) {
|
||||
const span = trace.getSpan(context.active());
|
||||
if (!span) return { ...object };
|
||||
const { spanId, traceId } = trace
|
||||
.getSpan(context.active())
|
||||
?.spanContext();
|
||||
return { ...object, spanId, traceId };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// @ts-expect-error
|
||||
logger.log = logger.info;
|
||||
|
||||
function setupSentry(
|
||||
app: NestExpressApplication,
|
||||
|
|
@ -23,7 +43,12 @@ function setupSentry(
|
|||
}
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
await otelSDK.start();
|
||||
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||
bufferLogs: true,
|
||||
});
|
||||
app.useLogger(app.get(Logger));
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
transform: true,
|
||||
|
|
|
|||
|
|
@ -1,74 +0,0 @@
|
|||
import { UnauthorizedException } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { IncomingMessage } from "http";
|
||||
import { MetricsAuthMiddleware } from "./metrics-auth.middleware";
|
||||
|
||||
describe("MetricsAuthMiddleware", () => {
|
||||
let middleware: MetricsAuthMiddleware;
|
||||
let config: ConfigService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
MetricsAuthMiddleware,
|
||||
{
|
||||
provide: ConfigService,
|
||||
useFactory: () => ({
|
||||
get: jest
|
||||
.fn()
|
||||
.mockReturnValueOnce("foo") // Username
|
||||
.mockReturnValueOnce("bar"), // Password
|
||||
}),
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
middleware = module.get<MetricsAuthMiddleware>(MetricsAuthMiddleware);
|
||||
config = module.get<ConfigService>(ConfigService);
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(middleware).toBeDefined();
|
||||
expect(config).toBeDefined();
|
||||
});
|
||||
|
||||
describe("use", () => {
|
||||
let req: IncomingMessage;
|
||||
let res: any;
|
||||
let next: () => void;
|
||||
|
||||
beforeEach(() => {
|
||||
req = {
|
||||
headers: { authorization: `Basic Zm9vOmJhcg==` },
|
||||
} as IncomingMessage; // Buffer.from("foo:bar").toString("base64")
|
||||
|
||||
res = {};
|
||||
|
||||
next = jest.fn();
|
||||
});
|
||||
|
||||
it("calls next", async () => {
|
||||
middleware.use(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it("throws UnauthorizedException if header is not set", async () => {
|
||||
delete req.headers.authorization;
|
||||
|
||||
expect(() => middleware.use(req, res, next)).toThrow(
|
||||
UnauthorizedException
|
||||
);
|
||||
});
|
||||
|
||||
it("throws UnauthorizedException if header does not match", async () => {
|
||||
req.headers.authorization = `Basic doesnotmatch`;
|
||||
|
||||
expect(() => middleware.use(req, res, next)).toThrow(
|
||||
UnauthorizedException
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
import {
|
||||
Injectable,
|
||||
NestMiddleware,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { IncomingMessage } from "http";
|
||||
|
||||
@Injectable()
|
||||
export class MetricsAuthMiddleware implements NestMiddleware {
|
||||
private readonly expectedHeaderValue: string;
|
||||
|
||||
constructor(config: ConfigService) {
|
||||
const username = config.get<string>("PROMETHEUS_BASIC_AUTH_USERNAME");
|
||||
const password = config.get<string>("PROMETHEUS_BASIC_AUTH_PASSWORD");
|
||||
|
||||
this.expectedHeaderValue = MetricsAuthMiddleware.buildHeaderValue(
|
||||
username,
|
||||
password
|
||||
);
|
||||
}
|
||||
|
||||
private static buildHeaderValue(username: string, password: string): string {
|
||||
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`;
|
||||
}
|
||||
|
||||
use(req: IncomingMessage, res: any, next: () => void) {
|
||||
const header = req.headers?.authorization;
|
||||
|
||||
if (header !== this.expectedHeaderValue) {
|
||||
throw new UnauthorizedException("MetricsBasicAuthNotMatching");
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
import { InboundMiddleware, PromModule } from "@digikare/nestjs-prom";
|
||||
import { DEFAULT_PROM_OPTIONS } from "@digikare/nestjs-prom/dist/prom.constants";
|
||||
import {
|
||||
DynamicModule,
|
||||
MiddlewareConsumer,
|
||||
Module,
|
||||
NestModule,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { MetricsAuthMiddleware } from "./metrics-auth.middleware";
|
||||
|
||||
// Dirty hack because we can not conditionally import modules based on
|
||||
// injected services and upstream module does not support dynamic configuration
|
||||
//
|
||||
// https://github.com/digikare/nestjs-prom/issues/27
|
||||
const promEnabled = process.env.PROMETHEUS_ENABLED === "true";
|
||||
|
||||
const METRIC_PATH = "/api/metrics";
|
||||
|
||||
@Module({})
|
||||
export class MetricsModule implements NestModule {
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
|
||||
static forRoot(): DynamicModule {
|
||||
const module = {
|
||||
imports: [],
|
||||
providers: [],
|
||||
};
|
||||
if (promEnabled) {
|
||||
const promOptions = {
|
||||
metricPath: METRIC_PATH,
|
||||
withDefaultsMetrics: true,
|
||||
withDefaultController: true,
|
||||
};
|
||||
|
||||
module.imports.push(PromModule.forRoot(promOptions));
|
||||
|
||||
module.providers.push({
|
||||
provide: DEFAULT_PROM_OPTIONS,
|
||||
useValue: promOptions,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
module: MetricsModule,
|
||||
...module,
|
||||
};
|
||||
}
|
||||
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
if (this.config.get<boolean>("PROMETHEUS_ENABLED")) {
|
||||
// We register the Middleware ourselves to avoid tracking
|
||||
// latency for static files served for the frontend.
|
||||
consumer.apply(InboundMiddleware).exclude(METRIC_PATH).forRoutes("/api");
|
||||
|
||||
if (this.config.get<boolean>("PROMETHEUS_BASIC_AUTH")) {
|
||||
consumer.apply(MetricsAuthMiddleware).forRoutes(METRIC_PATH);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
25
src/open-telemetry/open-telemetry.module.ts
Normal file
25
src/open-telemetry/open-telemetry.module.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { Module, OnApplicationShutdown } from "@nestjs/common";
|
||||
import { OpenTelemetryModule as UpstreamModule } from "nestjs-otel";
|
||||
import { otelSDK } from "./sdk";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
UpstreamModule.forRoot({
|
||||
metrics: {
|
||||
hostMetrics: true, // Includes Host Metrics
|
||||
defaultMetrics: true, // Includes Default Metrics
|
||||
apiMetrics: {
|
||||
enable: true, // Includes api metrics
|
||||
timeBuckets: [], // You can change the default time buckets
|
||||
ignoreUndefinedRoutes: false, //Records metrics for all URLs, even undefined ones
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
exports: [UpstreamModule],
|
||||
})
|
||||
export class OpenTelemetryModule implements OnApplicationShutdown {
|
||||
async onApplicationShutdown(): Promise<void> {
|
||||
await otelSDK.shutdown();
|
||||
}
|
||||
}
|
||||
48
src/open-telemetry/sdk.ts
Normal file
48
src/open-telemetry/sdk.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks";
|
||||
import { PrometheusExporter } from "@opentelemetry/exporter-prometheus";
|
||||
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
|
||||
import { DnsInstrumentation } from "@opentelemetry/instrumentation-dns";
|
||||
import { ExpressInstrumentation } from "@opentelemetry/instrumentation-express";
|
||||
import { HttpInstrumentation } from "@opentelemetry/instrumentation-http";
|
||||
import { NestInstrumentation } from "@opentelemetry/instrumentation-nestjs-core";
|
||||
import { PgInstrumentation } from "@opentelemetry/instrumentation-pg";
|
||||
import { PinoInstrumentation } from "@opentelemetry/instrumentation-pino";
|
||||
import { Resource } from "@opentelemetry/resources";
|
||||
import { NodeSDK, NodeSDKConfiguration } from "@opentelemetry/sdk-node";
|
||||
import { SemanticResourceAttributes } from "@opentelemetry/semantic-conventions";
|
||||
import { hostname } from "os";
|
||||
|
||||
const metricsEnabled = process.env.OTEL_METRICS_ENABLED === "true";
|
||||
const tracesEnabled = process.env.OTEL_TRACES_ENABLED === "true";
|
||||
const anyEnabled = metricsEnabled || tracesEnabled;
|
||||
|
||||
// We can not use ConfigService because the SDK needs to be initialized before
|
||||
// Nest is allowed to start.
|
||||
let sdkOptions: Partial<NodeSDKConfiguration> = {};
|
||||
|
||||
if (metricsEnabled) {
|
||||
sdkOptions.metricExporter = new PrometheusExporter();
|
||||
}
|
||||
|
||||
if (tracesEnabled) {
|
||||
sdkOptions.traceExporter = new OTLPTraceExporter({});
|
||||
sdkOptions.contextManager = new AsyncLocalStorageContextManager();
|
||||
sdkOptions.resource = new Resource({
|
||||
[SemanticResourceAttributes.SERVICE_NAMESPACE]: "listory",
|
||||
[SemanticResourceAttributes.SERVICE_NAME]: "api",
|
||||
[SemanticResourceAttributes.SERVICE_INSTANCE_ID]: hostname(),
|
||||
});
|
||||
}
|
||||
|
||||
if (anyEnabled) {
|
||||
sdkOptions.instrumentations = [
|
||||
new DnsInstrumentation(),
|
||||
new HttpInstrumentation(),
|
||||
new ExpressInstrumentation(),
|
||||
new NestInstrumentation(),
|
||||
new PgInstrumentation(),
|
||||
new PinoInstrumentation(),
|
||||
];
|
||||
}
|
||||
|
||||
export const otelSDK = new NodeSDK(sdkOptions);
|
||||
|
|
@ -1,20 +1,18 @@
|
|||
import { Injectable, OnApplicationBootstrap } from "@nestjs/common";
|
||||
import { Injectable, Logger, OnApplicationBootstrap } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { SchedulerRegistry } from "@nestjs/schedule";
|
||||
import { captureException } from "@sentry/node";
|
||||
import { Logger } from "../logger/logger.service";
|
||||
import { SpotifyService } from "./spotify/spotify.service";
|
||||
|
||||
@Injectable()
|
||||
export class SchedulerService implements OnApplicationBootstrap {
|
||||
private readonly logger = new Logger(this.constructor.name);
|
||||
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly registry: SchedulerRegistry,
|
||||
private readonly spotifyService: SpotifyService,
|
||||
private readonly logger: Logger
|
||||
) {
|
||||
this.logger.setContext(this.constructor.name);
|
||||
}
|
||||
private readonly spotifyService: SpotifyService
|
||||
) {}
|
||||
|
||||
onApplicationBootstrap() {
|
||||
this.setupSpotifyCrawler();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { ListensService } from "../../listens/listens.service";
|
||||
import { Logger } from "../../logger/logger.service";
|
||||
import { MusicLibraryService } from "../../music-library/music-library.service";
|
||||
import { UsersService } from "../../users/users.service";
|
||||
import { SpotifyApiService } from "./spotify-api/spotify-api.service";
|
||||
|
|
@ -14,7 +13,6 @@ describe("SpotifyService", () => {
|
|||
let musicLibraryService: MusicLibraryService;
|
||||
let spotifyApi: SpotifyApiService;
|
||||
let spotifyAuth: SpotifyAuthService;
|
||||
let logger: Logger;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
|
|
@ -25,7 +23,6 @@ describe("SpotifyService", () => {
|
|||
{ provide: MusicLibraryService, useFactory: () => ({}) },
|
||||
{ provide: SpotifyApiService, useFactory: () => ({}) },
|
||||
{ provide: SpotifyAuthService, useFactory: () => ({}) },
|
||||
{ provide: Logger, useValue: new Logger() },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
|
|
@ -35,7 +32,6 @@ describe("SpotifyService", () => {
|
|||
musicLibraryService = module.get<MusicLibraryService>(MusicLibraryService);
|
||||
spotifyApi = module.get<SpotifyApiService>(SpotifyApiService);
|
||||
spotifyAuth = module.get<SpotifyAuthService>(SpotifyAuthService);
|
||||
logger = module.get<Logger>(Logger);
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
|
|
@ -45,6 +41,5 @@ describe("SpotifyService", () => {
|
|||
expect(musicLibraryService).toBeDefined();
|
||||
expect(spotifyApi).toBeDefined();
|
||||
expect(spotifyAuth).toBeDefined();
|
||||
expect(logger).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Injectable } from "@nestjs/common";
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { Span } from "nestjs-otel";
|
||||
import { ListensService } from "../../listens/listens.service";
|
||||
import { Logger } from "../../logger/logger.service";
|
||||
import { Album } from "../../music-library/album.entity";
|
||||
import { Artist } from "../../music-library/artist.entity";
|
||||
import { Genre } from "../../music-library/genre.entity";
|
||||
|
|
@ -17,6 +17,8 @@ import { SpotifyAuthService } from "./spotify-auth/spotify-auth.service";
|
|||
|
||||
@Injectable()
|
||||
export class SpotifyService {
|
||||
private readonly logger = new Logger(this.constructor.name);
|
||||
|
||||
private appAccessToken: string | null;
|
||||
private appAccessTokenInProgress: Promise<void> | null;
|
||||
|
||||
|
|
@ -25,12 +27,10 @@ export class SpotifyService {
|
|||
private readonly listensService: ListensService,
|
||||
private readonly musicLibraryService: MusicLibraryService,
|
||||
private readonly spotifyApi: SpotifyApiService,
|
||||
private readonly spotifyAuth: SpotifyAuthService,
|
||||
private readonly logger: Logger
|
||||
) {
|
||||
this.logger.setContext(this.constructor.name);
|
||||
}
|
||||
private readonly spotifyAuth: SpotifyAuthService
|
||||
) {}
|
||||
|
||||
@Span()
|
||||
async runCrawlerForAllUsers(): Promise<void> {
|
||||
this.logger.debug("Starting Spotify crawler loop");
|
||||
const users = await this.usersService.findAll();
|
||||
|
|
@ -42,11 +42,12 @@ export class SpotifyService {
|
|||
}
|
||||
}
|
||||
|
||||
@Span()
|
||||
async crawlListensForUser(
|
||||
user: User,
|
||||
retryOnExpiredToken: boolean = true
|
||||
): Promise<void> {
|
||||
this.logger.debug(`Crawling recently played tracks for user "${user.id}"`);
|
||||
this.logger.debug({ userId: user.id }, `Crawling recently played tracks`);
|
||||
|
||||
let playHistory: PlayHistoryObject[];
|
||||
try {
|
||||
|
|
@ -64,6 +65,7 @@ export class SpotifyService {
|
|||
await this.crawlListensForUser(user, false);
|
||||
} catch (errFromAuth) {
|
||||
this.logger.error(
|
||||
{ userId: user.id },
|
||||
`Refreshing access token failed for user "${user.id}": ${errFromAuth}`
|
||||
);
|
||||
}
|
||||
|
|
@ -95,6 +97,7 @@ export class SpotifyService {
|
|||
|
||||
if (!isDuplicate) {
|
||||
this.logger.debug(
|
||||
{ userId: user.id },
|
||||
`New listen found! ${user.id} listened to "${
|
||||
track.name
|
||||
}" by ${track.artists
|
||||
|
|
@ -126,6 +129,7 @@ export class SpotifyService {
|
|||
});
|
||||
}
|
||||
|
||||
@Span()
|
||||
async runUpdaterForAllEntities(): Promise<void> {
|
||||
this.logger.debug("Starting Spotify updater loop");
|
||||
|
||||
|
|
@ -137,6 +141,7 @@ export class SpotifyService {
|
|||
}
|
||||
}
|
||||
|
||||
@Span()
|
||||
async importTrack(
|
||||
spotifyID: string,
|
||||
retryOnExpiredToken: boolean = true
|
||||
|
|
@ -187,6 +192,7 @@ export class SpotifyService {
|
|||
});
|
||||
}
|
||||
|
||||
@Span()
|
||||
async importAlbum(
|
||||
spotifyID: string,
|
||||
retryOnExpiredToken: boolean = true
|
||||
|
|
@ -233,6 +239,7 @@ export class SpotifyService {
|
|||
});
|
||||
}
|
||||
|
||||
@Span()
|
||||
async importArtist(
|
||||
spotifyID: string,
|
||||
retryOnExpiredToken: boolean = true
|
||||
|
|
@ -277,6 +284,7 @@ export class SpotifyService {
|
|||
});
|
||||
}
|
||||
|
||||
@Span()
|
||||
async updateArtist(
|
||||
spotifyID: string,
|
||||
retryOnExpiredToken: boolean = true
|
||||
|
|
@ -315,6 +323,7 @@ export class SpotifyService {
|
|||
return artist;
|
||||
}
|
||||
|
||||
@Span()
|
||||
async importGenre(name: string): Promise<Genre> {
|
||||
const genre = await this.musicLibraryService.findGenre({
|
||||
name,
|
||||
|
|
@ -328,6 +337,7 @@ export class SpotifyService {
|
|||
});
|
||||
}
|
||||
|
||||
@Span()
|
||||
private async refreshAppAccessToken(): Promise<void> {
|
||||
if (!this.appAccessTokenInProgress) {
|
||||
this.logger.debug("refreshing spotify app access token");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue