From 41dfae3c508e71f3c74f75dceba4de6bd68b6070 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20T=C3=B6lle?= Date: Wed, 29 Jun 2022 19:58:01 +0200 Subject: [PATCH] feat(api): metrics for Spotify API http requests This will help evaluate how much we are being rate limited, and on what routes it happens most. --- package-lock.json | 20 +++++ package.json | 2 + src/open-telemetry/open-telemetry.module.ts | 7 +- .../url-value-parser.service.spec.ts | 49 +++++++++++ .../url-value-parser.service.ts | 67 +++++++++++++++ .../spotify-api/metrics.axios-interceptor.ts | 84 +++++++++++++++++++ .../spotify/spotify-api/spotify-api.module.ts | 3 +- 7 files changed, 229 insertions(+), 3 deletions(-) create mode 100644 src/open-telemetry/url-value-parser.service.spec.ts create mode 100644 src/open-telemetry/url-value-parser.service.ts create mode 100644 src/sources/spotify/spotify-api/metrics.axios-interceptor.ts diff --git a/package-lock.json b/package-lock.json index 15d73bc..7f71d58 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@hapi/joi": "17.1.1", + "@narando/nest-axios-interceptor": "2.1.0", "@nestjs/axios": "0.0.8", "@nestjs/common": "8.4.7", "@nestjs/config": "2.1.0", @@ -23,6 +24,7 @@ "@nestjs/terminus": "8.1.0", "@nestjs/typeorm": "8.1.4", "@opentelemetry/api": "1.0.4", + "@opentelemetry/api-metrics": "0.27.0", "@opentelemetry/context-async-hooks": "1.0.1", "@opentelemetry/exporter-prometheus": "0.27.0", "@opentelemetry/exporter-trace-otlp-http": "0.27.0", @@ -1560,6 +1562,18 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@narando/nest-axios-interceptor": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@narando/nest-axios-interceptor/-/nest-axios-interceptor-2.1.0.tgz", + "integrity": "sha512-/IyevMkA+B09YAs9Ta+9rt24F/mVLTcLj/dbnvb1Jpg2kEuwOWeYZkPqDwiomaFThiXdFVY1WDABzniY8Od9nw==", + "peerDependencies": { + "@nestjs/axios": ">=0.0.5 <=0.0.8", + "@nestjs/common": "^8.0.0", + "@nestjs/core": "^8.0.0", + "reflect-metadata": "^0.1.13", + "rxjs": "^7.0.0" + } + }, "node_modules/@nestjs/apollo": { "version": "10.0.14", "resolved": "https://registry.npmjs.org/@nestjs/apollo/-/apollo-10.0.14.tgz", @@ -13543,6 +13557,12 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "@narando/nest-axios-interceptor": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@narando/nest-axios-interceptor/-/nest-axios-interceptor-2.1.0.tgz", + "integrity": "sha512-/IyevMkA+B09YAs9Ta+9rt24F/mVLTcLj/dbnvb1Jpg2kEuwOWeYZkPqDwiomaFThiXdFVY1WDABzniY8Od9nw==", + "requires": {} + }, "@nestjs/apollo": { "version": "10.0.14", "resolved": "https://registry.npmjs.org/@nestjs/apollo/-/apollo-10.0.14.tgz", diff --git a/package.json b/package.json index d551c9e..7f93d32 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ }, "dependencies": { "@hapi/joi": "17.1.1", + "@narando/nest-axios-interceptor": "2.1.0", "@nestjs/axios": "0.0.8", "@nestjs/common": "8.4.7", "@nestjs/config": "2.1.0", @@ -39,6 +40,7 @@ "@nestjs/terminus": "8.1.0", "@nestjs/typeorm": "8.1.4", "@opentelemetry/api": "1.0.4", + "@opentelemetry/api-metrics": "0.27.0", "@opentelemetry/context-async-hooks": "1.0.1", "@opentelemetry/exporter-prometheus": "0.27.0", "@opentelemetry/exporter-trace-otlp-http": "0.27.0", diff --git a/src/open-telemetry/open-telemetry.module.ts b/src/open-telemetry/open-telemetry.module.ts index 377b331..1ef7847 100644 --- a/src/open-telemetry/open-telemetry.module.ts +++ b/src/open-telemetry/open-telemetry.module.ts @@ -1,6 +1,7 @@ -import { Module, OnApplicationShutdown } from "@nestjs/common"; +import { Global, Module, OnApplicationShutdown } from "@nestjs/common"; import { OpenTelemetryModule as UpstreamModule } from "nestjs-otel"; import { otelSDK } from "./sdk"; +import { UrlValueParserService } from "./url-value-parser.service"; @Module({ imports: [ @@ -16,8 +17,10 @@ import { otelSDK } from "./sdk"; }, }), ], - exports: [UpstreamModule], + providers: [UrlValueParserService], + exports: [UpstreamModule, UrlValueParserService], }) +@Global() export class OpenTelemetryModule implements OnApplicationShutdown { async onApplicationShutdown(): Promise { await otelSDK.shutdown(); diff --git a/src/open-telemetry/url-value-parser.service.spec.ts b/src/open-telemetry/url-value-parser.service.spec.ts new file mode 100644 index 0000000..324c9ba --- /dev/null +++ b/src/open-telemetry/url-value-parser.service.spec.ts @@ -0,0 +1,49 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { UrlValueParserService } from "./url-value-parser.service"; + +describe("UrlValueParserService", () => { + let service: UrlValueParserService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [UrlValueParserService], + }).compile(); + + service = module.get(UrlValueParserService); + }); + + it("should be defined", () => { + expect(service).toBeDefined(); + }); + + describe("replacePathValues", () => { + it("works with default replacement", () => { + const replaced = service.replacePathValues( + "/in/world/14/userId/abca12d231" + ); + expect(replaced).toBe("/in/world/#val/userId/#val"); + }); + + it("works with custom replacement", () => { + const replaced = service.replacePathValues( + "/in/world/14/userId/abca12d231", + "" + ); + expect(replaced).toBe("/in/world//userId/"); + }); + + it("works with negative decimal numbers", () => { + const replaced = service.replacePathValues( + "/some/path/-154/userId/-ABC363AFE2" + ); + expect(replaced).toBe("/some/path/#val/userId/-ABC363AFE2"); + }); + + it("works with spotify ids", () => { + const replaced = service.replacePathValues( + "/v1/albums/2PzfMWIpq6JKucGhkS1X5M" + ); + expect(replaced).toBe("/v1/albums/#val"); + }); + }); +}); diff --git a/src/open-telemetry/url-value-parser.service.ts b/src/open-telemetry/url-value-parser.service.ts new file mode 100644 index 0000000..074f956 --- /dev/null +++ b/src/open-telemetry/url-value-parser.service.ts @@ -0,0 +1,67 @@ +import { Injectable } from "@nestjs/common"; + +// Service adapted from https://github.com/disjunction/url-value-parser + +const REPLACE_MASKS: RegExp[] = [ + /^\-?\d+$/, + + /^(\d{2}|\d{4})\-\d\d\-\d\d$/, // date + + /^[\da-f]{8}\-[\da-f]{4}\-[\da-f]{4}\-[\da-f]{4}\-[\da-f]{12}$/, // UUID + /^[\dA-F]{8}\-[\dA-F]{4}\-[\dA-F]{4}\-[\dA-F]{4}\-[\dA-F]{12}$/, // UUID uppercased + + // hex code sould have a consistent case + /^[\da-f]{7,}$/, + /^[\dA-F]{7,}$/, + + // base64 encoded with URL safe Base64 + /^[a-zA-Z0-9\-_]{22,}$/, + + // classic Base64 + /^(?:[A-Za-z0-9+/]{4}){16,}(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?/, +]; + +@Injectable() +export class UrlValueParserService { + /** + * replacePathValues replaces IDs and other identifiers from URL paths. + */ + public replacePathValues(path: string, replacement: string = "#val"): string { + const parseResult = this.parsePathValues(path); + console.log({ parseResult, path, replacement }); + return ( + "/" + + parseResult.chunks + .map((chunk, i) => + parseResult.valueIndexes.includes(i) ? replacement : chunk + ) + .join("/") + ); + } + + private getPathChunks(path: string): string[] { + return path.split("/").filter((chunk) => chunk !== ""); + } + + private parsePathValues(path: string): { + chunks: string[]; + valueIndexes: number[]; + } { + const chunks = this.getPathChunks(path); + const valueIndexes = chunks + .map((chunk, index) => (this.isValue(chunk) ? index : null)) + .filter((index) => index !== null); + + return { chunks, valueIndexes }; + } + + private isValue(str: string): boolean { + for (let mask of REPLACE_MASKS) { + if (str.match(mask)) { + console.log("isValue", { str, mask }); + return true; + } + } + return false; + } +} diff --git a/src/sources/spotify/spotify-api/metrics.axios-interceptor.ts b/src/sources/spotify/spotify-api/metrics.axios-interceptor.ts new file mode 100644 index 0000000..19c3851 --- /dev/null +++ b/src/sources/spotify/spotify-api/metrics.axios-interceptor.ts @@ -0,0 +1,84 @@ +import { + AxiosFulfilledInterceptor, + AxiosInterceptor, + AxiosResponseCustomConfig, +} from "@narando/nest-axios-interceptor"; +import { HttpService } from "@nestjs/axios"; +import { Injectable, Logger } from "@nestjs/common"; +import { Counter, Histogram } from "@opentelemetry/api-metrics"; +import type { AxiosRequestConfig } from "axios"; +import { MetricService } from "nestjs-otel"; +import { UrlValueParserService } from "../../../open-telemetry/url-value-parser.service"; + +const SPOTIFY_API_METRICS_CONFIG_KEY = Symbol("kSpotifyApiMetricsInterceptor"); + +// Merging our custom properties with the base config +interface SpotifyApiMetricsConfig extends AxiosRequestConfig { + [SPOTIFY_API_METRICS_CONFIG_KEY]: { + startTime: number; + }; +} + +@Injectable() +export class MetricsInterceptor extends AxiosInterceptor { + private readonly logger = new Logger(this.constructor.name); + responseCounter: Counter; + requestHistogram: Histogram; + + constructor( + httpService: HttpService, + metricService: MetricService, + private readonly urlValueParserService: UrlValueParserService + ) { + super(httpService); + + this.responseCounter = metricService.getCounter( + "listory_spotify_api_http_response", + { description: "Total number of HTTP responses from Spotify API" } + ); + + this.requestHistogram = metricService.getHistogram( + "listory_spotify_api_http_request_duration_seconds", + { + description: + "HTTP latency value recorder in seconds for requests made to Spotify API", + boundaries: [0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10], + } + ); + } + + protected requestFulfilled(): AxiosFulfilledInterceptor { + return (config) => { + config[SPOTIFY_API_METRICS_CONFIG_KEY] = { + startTime: new Date().getTime(), + }; + + return config; + }; + } + + responseFulfilled(): AxiosFulfilledInterceptor< + AxiosResponseCustomConfig + > { + return (response) => { + const startTime = + response.config[SPOTIFY_API_METRICS_CONFIG_KEY].startTime; + const endTime = new Date().getTime(); + const responseTimeSeconds = (endTime - startTime) / 1000; + + const metricLabels = { + method: response.config.method.toUpperCase(), + status: response.status.toString(), + path: this.urlValueParserService.replacePathValues( + response.config.url, + "" + ), + }; + + this.requestHistogram.record(responseTimeSeconds, metricLabels); + this.responseCounter.add(1, metricLabels); + + return response; + }; + } +} diff --git a/src/sources/spotify/spotify-api/spotify-api.module.ts b/src/sources/spotify/spotify-api/spotify-api.module.ts index 67bfe46..d385c0b 100644 --- a/src/sources/spotify/spotify-api/spotify-api.module.ts +++ b/src/sources/spotify/spotify-api/spotify-api.module.ts @@ -1,6 +1,7 @@ import { HttpModule } from "@nestjs/axios"; import { Module } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; +import { MetricsInterceptor } from "./metrics.axios-interceptor"; import { SpotifyApiService } from "./spotify-api.service"; @Module({ @@ -13,7 +14,7 @@ import { SpotifyApiService } from "./spotify-api.service"; inject: [ConfigService], }), ], - providers: [SpotifyApiService], + providers: [SpotifyApiService, MetricsInterceptor], exports: [SpotifyApiService], }) export class SpotifyApiModule {}