mirror of
https://github.com/apricote/Listory.git
synced 2026-01-13 21:21:02 +00:00
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.
This commit is contained in:
parent
85c31705ef
commit
41dfae3c50
7 changed files with 229 additions and 3 deletions
20
package-lock.json
generated
20
package-lock.json
generated
|
|
@ -10,6 +10,7 @@
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hapi/joi": "17.1.1",
|
"@hapi/joi": "17.1.1",
|
||||||
|
"@narando/nest-axios-interceptor": "2.1.0",
|
||||||
"@nestjs/axios": "0.0.8",
|
"@nestjs/axios": "0.0.8",
|
||||||
"@nestjs/common": "8.4.7",
|
"@nestjs/common": "8.4.7",
|
||||||
"@nestjs/config": "2.1.0",
|
"@nestjs/config": "2.1.0",
|
||||||
|
|
@ -23,6 +24,7 @@
|
||||||
"@nestjs/terminus": "8.1.0",
|
"@nestjs/terminus": "8.1.0",
|
||||||
"@nestjs/typeorm": "8.1.4",
|
"@nestjs/typeorm": "8.1.4",
|
||||||
"@opentelemetry/api": "1.0.4",
|
"@opentelemetry/api": "1.0.4",
|
||||||
|
"@opentelemetry/api-metrics": "0.27.0",
|
||||||
"@opentelemetry/context-async-hooks": "1.0.1",
|
"@opentelemetry/context-async-hooks": "1.0.1",
|
||||||
"@opentelemetry/exporter-prometheus": "0.27.0",
|
"@opentelemetry/exporter-prometheus": "0.27.0",
|
||||||
"@opentelemetry/exporter-trace-otlp-http": "0.27.0",
|
"@opentelemetry/exporter-trace-otlp-http": "0.27.0",
|
||||||
|
|
@ -1560,6 +1562,18 @@
|
||||||
"@jridgewell/sourcemap-codec": "^1.4.10"
|
"@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": {
|
"node_modules/@nestjs/apollo": {
|
||||||
"version": "10.0.14",
|
"version": "10.0.14",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/apollo/-/apollo-10.0.14.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/apollo/-/apollo-10.0.14.tgz",
|
||||||
|
|
@ -13543,6 +13557,12 @@
|
||||||
"@jridgewell/sourcemap-codec": "^1.4.10"
|
"@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": {
|
"@nestjs/apollo": {
|
||||||
"version": "10.0.14",
|
"version": "10.0.14",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/apollo/-/apollo-10.0.14.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/apollo/-/apollo-10.0.14.tgz",
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hapi/joi": "17.1.1",
|
"@hapi/joi": "17.1.1",
|
||||||
|
"@narando/nest-axios-interceptor": "2.1.0",
|
||||||
"@nestjs/axios": "0.0.8",
|
"@nestjs/axios": "0.0.8",
|
||||||
"@nestjs/common": "8.4.7",
|
"@nestjs/common": "8.4.7",
|
||||||
"@nestjs/config": "2.1.0",
|
"@nestjs/config": "2.1.0",
|
||||||
|
|
@ -39,6 +40,7 @@
|
||||||
"@nestjs/terminus": "8.1.0",
|
"@nestjs/terminus": "8.1.0",
|
||||||
"@nestjs/typeorm": "8.1.4",
|
"@nestjs/typeorm": "8.1.4",
|
||||||
"@opentelemetry/api": "1.0.4",
|
"@opentelemetry/api": "1.0.4",
|
||||||
|
"@opentelemetry/api-metrics": "0.27.0",
|
||||||
"@opentelemetry/context-async-hooks": "1.0.1",
|
"@opentelemetry/context-async-hooks": "1.0.1",
|
||||||
"@opentelemetry/exporter-prometheus": "0.27.0",
|
"@opentelemetry/exporter-prometheus": "0.27.0",
|
||||||
"@opentelemetry/exporter-trace-otlp-http": "0.27.0",
|
"@opentelemetry/exporter-trace-otlp-http": "0.27.0",
|
||||||
|
|
|
||||||
|
|
@ -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 { OpenTelemetryModule as UpstreamModule } from "nestjs-otel";
|
||||||
import { otelSDK } from "./sdk";
|
import { otelSDK } from "./sdk";
|
||||||
|
import { UrlValueParserService } from "./url-value-parser.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
|
@ -16,8 +17,10 @@ import { otelSDK } from "./sdk";
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
exports: [UpstreamModule],
|
providers: [UrlValueParserService],
|
||||||
|
exports: [UpstreamModule, UrlValueParserService],
|
||||||
})
|
})
|
||||||
|
@Global()
|
||||||
export class OpenTelemetryModule implements OnApplicationShutdown {
|
export class OpenTelemetryModule implements OnApplicationShutdown {
|
||||||
async onApplicationShutdown(): Promise<void> {
|
async onApplicationShutdown(): Promise<void> {
|
||||||
await otelSDK.shutdown();
|
await otelSDK.shutdown();
|
||||||
|
|
|
||||||
49
src/open-telemetry/url-value-parser.service.spec.ts
Normal file
49
src/open-telemetry/url-value-parser.service.spec.ts
Normal file
|
|
@ -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>(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",
|
||||||
|
"<id>"
|
||||||
|
);
|
||||||
|
expect(replaced).toBe("/in/world/<id>/userId/<id>");
|
||||||
|
});
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
67
src/open-telemetry/url-value-parser.service.ts
Normal file
67
src/open-telemetry/url-value-parser.service.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
84
src/sources/spotify/spotify-api/metrics.axios-interceptor.ts
Normal file
84
src/sources/spotify/spotify-api/metrics.axios-interceptor.ts
Normal file
|
|
@ -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<SpotifyApiMetricsConfig> {
|
||||||
|
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<SpotifyApiMetricsConfig> {
|
||||||
|
return (config) => {
|
||||||
|
config[SPOTIFY_API_METRICS_CONFIG_KEY] = {
|
||||||
|
startTime: new Date().getTime(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return config;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
responseFulfilled(): AxiosFulfilledInterceptor<
|
||||||
|
AxiosResponseCustomConfig<SpotifyApiMetricsConfig>
|
||||||
|
> {
|
||||||
|
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,
|
||||||
|
"<id>"
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.requestHistogram.record(responseTimeSeconds, metricLabels);
|
||||||
|
this.responseCounter.add(1, metricLabels);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { HttpModule } from "@nestjs/axios";
|
import { HttpModule } from "@nestjs/axios";
|
||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { MetricsInterceptor } from "./metrics.axios-interceptor";
|
||||||
import { SpotifyApiService } from "./spotify-api.service";
|
import { SpotifyApiService } from "./spotify-api.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
|
@ -13,7 +14,7 @@ import { SpotifyApiService } from "./spotify-api.service";
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
providers: [SpotifyApiService],
|
providers: [SpotifyApiService, MetricsInterceptor],
|
||||||
exports: [SpotifyApiService],
|
exports: [SpotifyApiService],
|
||||||
})
|
})
|
||||||
export class SpotifyApiModule {}
|
export class SpotifyApiModule {}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue