From 40ecdcc31b8abe1b76b6dd532fc0e893b439b007 Mon Sep 17 00:00:00 2001 From: Danilo Hoffmann Date: Wed, 23 Aug 2023 15:37:23 +0200 Subject: [PATCH] feat: export rest metrics --- server.ts | 23 +++++- src/app/app.server.module.ts | 2 + .../universal-prometheus.interceptor.ts | 80 +++++++++++++++++++ 3 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 src/app/core/interceptors/universal-prometheus.interceptor.ts diff --git a/server.ts b/server.ts index 0a1ddb7ece4..9998bc70560 100644 --- a/server.ts +++ b/server.ts @@ -38,6 +38,13 @@ const requestDuration = new client.Histogram({ labelNames: ['status_code', 'base_href', 'path'], }); +const restRequestDuration = new client.Summary({ + name: 'pwa_rest_request_duration_seconds', + help: 'duration histogram of ICM rest responses', + percentiles: [0.5, 0.9, 0.95, 0.99], + labelNames: ['endpoint'], +}); + const PM2 = process.env.pm_id && process.env.name ? `${process.env.pm_id} ${process.env.name}` : undefined; if (PM2) { @@ -163,12 +170,17 @@ export function app() { // Express server const server = express(); + const prometheusRest: { endpoint: string; duration: number }[] = []; + // Our Universal express-engine (found @ https://github.com/angular/universal/tree/master/modules/express-engine) server.engine( 'html', ngExpressEngine({ bootstrap: AppServerModule, - providers: [{ provide: 'SSR_HYBRID', useValue: !!process.env.SSR_HYBRID }], + providers: [ + { provide: 'SSR_HYBRID', useValue: !!process.env.SSR_HYBRID }, + { provide: 'PROMETHEUS_REST', useValue: prometheusRest }, + ], inlineCriticalCss: false, }) ); @@ -450,12 +462,19 @@ export function app() { onFinished(res, () => { const duration = Date.now() - start; const matched = /;baseHref=([^;?]*)/.exec(req.originalUrl); - const base_href = matched?.[1] ? `${decodeURIComponent(decodeURIComponent(matched[1]))}/` : '/'; + let base_href = matched?.[1] ? `${decodeURIComponent(decodeURIComponent(matched[1]))}` : '/'; + if (!base_href.endsWith('/')) { + base_href += '/'; + } const cleanUrl = req.originalUrl.replace(/[;?].*/g, ''); const path = cleanUrl.replace(base_href, ''); requestCounts.inc({ method: req.method, status_code: res.statusCode, base_href, path }); requestDuration.labels({ status_code: res.statusCode, base_href, path }).observe(duration / 1000); + prometheusRest.forEach(({ endpoint, duration }) => { + restRequestDuration.labels({ endpoint }).observe(duration / 1000); + }); + prometheusRest.length = 0; }); next(); }); diff --git a/src/app/app.server.module.ts b/src/app/app.server.module.ts index e091275cf92..e4d875dd0dc 100644 --- a/src/app/app.server.module.ts +++ b/src/app/app.server.module.ts @@ -10,6 +10,7 @@ import { COOKIE_CONSENT_VERSION, DISPLAY_VERSION } from 'ish-core/configurations import { UniversalCacheInterceptor } from 'ish-core/interceptors/universal-cache.interceptor'; import { UniversalLogInterceptor } from 'ish-core/interceptors/universal-log.interceptor'; import { UniversalMockInterceptor } from 'ish-core/interceptors/universal-mock.interceptor'; +import { UniversalPrometheusInterceptor } from 'ish-core/interceptors/universal-prometheus.interceptor'; import { environment } from '../environments/environment'; @@ -40,6 +41,7 @@ export class UniversalErrorHandler implements ErrorHandler { { provide: HTTP_INTERCEPTORS, useClass: UniversalMockInterceptor, multi: true }, { provide: HTTP_INTERCEPTORS, useClass: UniversalCacheInterceptor, multi: true }, { provide: HTTP_INTERCEPTORS, useClass: UniversalLogInterceptor, multi: true }, + { provide: HTTP_INTERCEPTORS, useClass: UniversalPrometheusInterceptor, multi: true }, { provide: ErrorHandler, useClass: UniversalErrorHandler }, { provide: META_REDUCERS, useValue: configurationMeta, multi: true }, // disable data retention for SSR diff --git a/src/app/core/interceptors/universal-prometheus.interceptor.ts b/src/app/core/interceptors/universal-prometheus.interceptor.ts new file mode 100644 index 00000000000..a10126ffba2 --- /dev/null +++ b/src/app/core/interceptors/universal-prometheus.interceptor.ts @@ -0,0 +1,80 @@ +import { + HttpErrorResponse, + HttpEvent, + HttpHandler, + HttpInterceptor, + HttpRequest, + HttpResponse, +} from '@angular/common/http'; +import { Inject, Injectable, Optional } from '@angular/core'; +import { Store, select } from '@ngrx/store'; +import { Observable } from 'rxjs'; +import { map, tap, withLatestFrom } from 'rxjs/operators'; + +import { getRestEndpoint } from 'ish-core/store/core/configuration'; +import { whenTruthy } from 'ish-core/utils/operators'; + +@Injectable() +export class UniversalPrometheusInterceptor implements HttpInterceptor { + constructor( + private store: Store, + @Optional() + @Inject('PROMETHEUS_REST') + private restCalls: { endpoint: string; duration: number }[] + ) {} + + private endpointCategory(path: string): string { + const pathSegments = path + // clear leading slash and before (usually ...;loc=...;cur=...) + .replace(/^[^\/]*\//, '') + // clear trailing slash + .replace(/\/$/, '') + .split('/'); + + const endpoint = pathSegments[0]; + if (endpoint === 'products' && pathSegments.length > 2) { + // product sub calls like /products/SKU/links + return `${endpoint}/${pathSegments[2]}`; + } else if (endpoint === 'categories' && pathSegments.length === 1) { + // category tree + return `${endpoint}/tree`; + } else if (endpoint === 'categories' && pathSegments[pathSegments.length - 1] === 'products') { + // category product list + return `${endpoint}/products`; + } else if (endpoint === 'cms' && pathSegments.length >= 2) { + // cms sub calls + return `${endpoint}/${pathSegments[1]}`; + } + return endpoint; + } + + intercept(req: HttpRequest, next: HttpHandler): Observable> { + if (!SSR && !/on|1|true|yes/.test(process.env.PROMETHEUS?.toLowerCase())) { + return next.handle(req); + } + + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { performance } = require('perf_hooks'); + + const start = performance.now(); + + const tracker = (args: [HttpEvent, string]) => { + if (args && args.length === 2) { + const [res, restEndPoint] = args; + + if ((res instanceof HttpResponse || res instanceof HttpErrorResponse) && req.url.startsWith(restEndPoint)) { + const duration = performance.now() - start; + const url = req.url.replace(restEndPoint, ''); + const endpoint = this.endpointCategory(url); + this.restCalls.push({ endpoint, duration }); + } + } + }; + + return next.handle(req).pipe( + withLatestFrom(this.store.pipe(select(getRestEndpoint), whenTruthy())), + tap({ next: tracker, error: tracker }), + map(([res]) => res) + ); + } +}