Compare commits

...

4 Commits

Author SHA1 Message Date
Bas950
4dc941bb91 chore: release v0.0.35 2024-09-24 11:08:50 +02:00
Bas950
3a78c6529e feat: use prom-client 2024-09-24 10:50:21 +02:00
Bas950
d4673720a0 chore: release v1.0.4 2024-09-20 19:03:37 +02:00
Bas950
dc859448bd feat: schema v1.11 2024-09-20 18:57:28 +02:00
10 changed files with 343 additions and 149 deletions

View File

@@ -1,7 +1,7 @@
{
"name": "@premid/api-master",
"type": "module",
"version": "0.0.34",
"version": "0.0.35",
"private": true,
"description": "PreMiD's api master",
"license": "MPL-2.0",
@@ -19,9 +19,6 @@
},
"dependencies": {
"@envelop/sentry": "^9.0.0",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/exporter-prometheus": "^0.52.1",
"@opentelemetry/node": "^0.24.0",
"@sentry/node": "^8.17.0",
"cron": "^3.1.7",
"debug": "^4.3.6",
@@ -30,7 +27,8 @@
"ip-location-api": "^1.0.0",
"ky": "^1.7.2",
"p-limit": "^6.1.0",
"postgres": "^3.4.4"
"postgres": "^3.4.4",
"prom-client": "^15.1.3"
},
"devDependencies": {
"@types/debug": "^4.1.12",

View File

@@ -1,83 +0,0 @@
import type { ServerResponse } from "node:http";
import type { Attributes } from "@opentelemetry/api";
import { ValueType, diag } from "@opentelemetry/api";
import type { PrometheusExporter, PrometheusSerializer } from "@opentelemetry/exporter-prometheus";
import { AggregationTemporality, DataPointType, type GaugeMetricData, InstrumentType } from "@opentelemetry/sdk-metrics";
const registeredMetrics = new Map<string, ClearableGaugeMetric>();
//* Custom gauge metric class
export class ClearableGaugeMetric {
private data = new Map<string, { value: number; attributes: Attributes }>();
constructor(private readonly name: string, private readonly description: string) {
registeredMetrics.set(name, this);
}
set(key: string, value: number, attributes: Attributes) {
this.data.set(key, { value, attributes });
}
clear({ except }: { except?: string[] }) {
for (const key of this.data.keys()) {
if (except && except.includes(key))
continue;
this.data.delete(key);
}
}
toMetricData(): GaugeMetricData {
return {
descriptor: {
name: this.name,
description: this.description,
unit: "",
type: InstrumentType.GAUGE,
valueType: ValueType.INT,
},
dataPointType: DataPointType.GAUGE,
dataPoints: Array.from(this.data.values()).map(({ value, attributes }) => ({
value,
attributes,
startTime: [0, 0],
endTime: [0, 0],
})),
aggregationTemporality: AggregationTemporality.CUMULATIVE,
};
}
get hasData() {
return this.data.size > 0;
}
}
export function updatePrometheusMetrics(prometheusExporter: PrometheusExporter) {
// @ts-expect-error We are modifying a private method
prometheusExporter._exportMetrics = function (this: PrometheusExporter, response: ServerResponse) {
response.statusCode = 200;
response.setHeader("content-type", "text/plain");
this.collect().then(
(collectionResult) => {
const { resourceMetrics, errors } = collectionResult;
if (errors.length) {
diag.error(
"PrometheusExporter: metrics collection errors",
...errors,
);
}
for (const metric of registeredMetrics.values()) {
if (metric.hasData) {
resourceMetrics.scopeMetrics[0]!.metrics.push(metric.toMetricData());
}
}
response.end((this as unknown as { _serializer: PrometheusSerializer })._serializer.serialize(resourceMetrics));
},
(err) => {
response.end(`# failed to export metrics: ${err}`);
},
);
}.bind(prometheusExporter);
}

View File

@@ -1,13 +0,0 @@
import { redis } from "../index.js";
import { activeSessionsCounter } from "../tracing.js";
let activeActivities = 0;
activeSessionsCounter.add(0);
export async function setSessionCounter() {
const length = await redis.hlen("pmd-api.sessions");
if (length === activeActivities)
return;
const diff = length - activeActivities;
activeActivities = length;
activeSessionsCounter.add(diff);
}

View File

@@ -0,0 +1,17 @@
import http from "node:http";
import { mainLog } from "../index.js";
import { register } from "../tracing.js";
export function setupServer() {
const server = http.createServer(async (req, res) => {
//* Basic routing logic
res.writeHead(200, { "Content-Type": "text/plain" });
res.end(await register.metrics());
});
server.listen(9464, () => {
mainLog("Server running");
});
return server;
}

View File

@@ -1,7 +1,7 @@
import process from "node:process";
import pLimit from "p-limit";
import type { Gauge } from "prom-client";
import { mainLog, redis } from "../index.js";
import { activePresenceGauge } from "../tracing.js";
import { insertIpData } from "./insertIpData.js";
export const updateActivePresenceGaugeLimit = pLimit(1);
@@ -9,7 +9,7 @@ let log: debug.Debugger | undefined;
const scanCount = Number.parseInt(process.env.SCAN_COUNT || "1000", 10);
export async function updateActivePresenceGauge() {
export async function updateActivePresenceGauge(gauge: Gauge) {
await updateActivePresenceGaugeLimit(async () => {
log ??= mainLog.extend("Heartbeat-Updates");
log?.("Starting active presence gauge update");
@@ -57,11 +57,11 @@ export async function updateActivePresenceGauge() {
log?.("Updating active presence gauge");
//* Batch update the gauge
// activePresenceGauge.clear({ except: [...serviceCounts.keys()] });
// for (const [serviceVersion, count] of serviceCounts) {
// const [presence_name, version] = serviceVersion.split(":");
// activePresenceGauge.set(serviceVersion, count, { presence_name, version });
// }
gauge.reset();
for (const [serviceVersion, count] of serviceCounts) {
const [presence_name, version] = serviceVersion.split(":");
gauge.set({ presence_name, version }, count);
}
//* Convert IP data for insertion
const ipDataForInsertion = new Map(

View File

@@ -3,14 +3,15 @@ import { CronJob } from "cron";
import debug from "debug";
import { clearOldSessions } from "./functions/clearOldSessions.js";
import createRedis from "./functions/createRedis.js";
import { setSessionCounter } from "./functions/setSessionCounter.js";
import "./tracing.js";
import { updateActivePresenceGauge, updateActivePresenceGaugeLimit } from "./functions/updateActivePresenceGauge.js";
// import { reloadIpLocationApi } from "./functions/lookupIp.js";
import { cleanupOldUserData } from "./functions/cleanupOldUserData.js";
import { setupServer } from "./functions/setupServer.js";
export const redis = createRedis();
export const server = setupServer();
export const mainLog = debug("api-master");
debug("Starting cron jobs");
@@ -22,13 +23,6 @@ void new CronJob(
if (process.env.DISABLE_CLEAR_OLD_SESSIONS !== "true") {
clearOldSessions();
}
if (process.env.DISABLE_SET_SESSION_COUNTER !== "true") {
setSessionCounter();
}
if (process.env.DISABLE_ACTIVE_PRESENCE_GAUGE !== "true") {
updateActivePresenceGaugeLimit.clearQueue();
updateActivePresenceGauge();
}
},
undefined,
true,

View File

@@ -1,26 +1,33 @@
import { ValueType } from "@opentelemetry/api";
import { PrometheusExporter } from "@opentelemetry/exporter-prometheus";
import { MeterProvider } from "@opentelemetry/sdk-metrics";
import { ClearableGaugeMetric, updatePrometheusMetrics } from "./functions/clearableGaugeMetric.js";
import process from "node:process";
import { Counter, Gauge, Registry, collectDefaultMetrics } from "prom-client";
import { updateActivePresenceGauge, updateActivePresenceGaugeLimit } from "./functions/updateActivePresenceGauge.js";
import { redis } from "./index.js";
const prometheusExporter = new PrometheusExporter();
export const register = new Registry();
collectDefaultMetrics({ register });
const provider = new MeterProvider({
readers: [prometheusExporter],
export const activeSessionsCounter = new Counter({
name: "active_sessions",
help: "Number of active sessions",
async collect() {
this.reset();
const length = await redis.hlen("pmd-api.sessions");
this.inc(length);
},
});
const meter = provider.getMeter("nice");
export const activeSessionsCounter = meter.createUpDownCounter("active_sessions", {
description: "Number of active sessions",
valueType: ValueType.INT,
export const activePresencesCounter = new Gauge({
name: "active_presences",
help: "Number of active presences",
labelNames: ["presence_name", "version"],
async collect() {
if (process.env.DISABLE_ACTIVE_PRESENCE_GAUGE !== "true") {
this.reset();
updateActivePresenceGaugeLimit.clearQueue();
await updateActivePresenceGauge(this);
}
},
});
export const activePresenceGauge = new ClearableGaugeMetric(
"active_presences",
"Per presence name+version, active number of users",
);
updatePrometheusMetrics(prometheusExporter);
prometheusExporter.startServer();
register.registerMetric(activeSessionsCounter);
register.registerMetric(activePresencesCounter);

View File

@@ -1,7 +1,7 @@
{
"name": "@premid/schema-server",
"type": "module",
"version": "1.0.3",
"version": "1.0.4",
"private": true,
"description": "A small service to serve the JSON schemas for PreMiD",
"license": "MPL-2.0",

View File

@@ -0,0 +1,260 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"$id": "https://schemas.premid.app/metadata/1.11",
"title": "Metadata",
"type": "object",
"description": "Metadata that describes a presence.",
"definitions": {
"user": {
"type": "object",
"description": "User information.",
"properties": {
"name": {
"type": "string",
"description": "The name of the user."
},
"id": {
"type": "string",
"description": "The Discord snowflake of the user.",
"pattern": "^\\d+$"
}
},
"additionalProperties": false,
"required": [
"name",
"id"
]
}
},
"properties": {
"$schema": {
"$comment": "This is required otherwise the schema will fail itself when it is applied to a document via $schema. This is optional so that validators that use this schema don't fail if the metadata doesn't have the $schema property.",
"type": "string",
"description": "The metadata schema URL."
},
"author": {
"$ref": "#/definitions/user",
"description": "The author of this presence."
},
"contributors": {
"type": "array",
"description": "Any extra contributors to this presence.",
"items": {
"$ref": "#/definitions/user"
}
},
"service": {
"type": "string",
"description": "The service this presence is for."
},
"altnames": {
"type": "array",
"description": "Alternative names for the service.",
"items": {
"type": "string",
"description": "An alternative name."
},
"minItems": 1
},
"description": {
"type": "object",
"description": "A description of the presence in multiple languages.",
"propertyNames": {
"type": "string",
"description": "The language key. The key must be languagecode(_REGIONCODE).",
"pattern": "^[a-z]{2}(?:_(?:[A-Z]{2}|[0-9]{1,3}))?$"
},
"patternProperties": {
"^[a-z]{2}(?:_(?:[A-Z]{2}|[0-9]{1,3}))?$": {
"type": "string",
"description": "The description of the presence in the key's language."
}
},
"additionalProperties": false,
"required": [
"en"
]
},
"url": {
"type": [
"string",
"array"
],
"description": "The service's website URL, or an array of URLs. Protocols should not be added.",
"pattern": "^(([a-z0-9-]+\\.)*[0-9a-z_-]+(\\.[a-z]+)+|(\\d{1,3}\\.){3}\\d{1,3}|localhost)$",
"items": {
"type": "string",
"description": "One of the service's website URLs.",
"pattern": "^(([a-z0-9-]+\\.)*[0-9a-z_-]+(\\.[a-z]+)+|(\\d{1,3}\\.){3}\\d{1,3}|localhost)$"
},
"minItems": 2
},
"version": {
"type": "string",
"description": "The SemVer version of the presence. Must just be major.minor.patch.",
"pattern": "^\\d+\\.\\d+\\.\\d+$"
},
"apiVersion": {
"type": "integer",
"description": "The Presence System version this Presence supports.",
"minimum": 1,
"maximum": 2
},
"logo": {
"type": "string",
"description": "The logo of the service this presence is for.",
"pattern": "^https?://.+\\.(png|jpe?g|gif|webp)$"
},
"thumbnail": {
"type": "string",
"description": "A thumbnail of the service this presence is for.",
"pattern": "^https?://.+\\.(png|jpe?g|gif|webp)$"
},
"color": {
"type": "string",
"description": "The theme color of the service this presence is for. Must be either a 6 digit or a 3 digit hex code.",
"pattern": "^#([A-Fa-f0-9]{3}){1,2}$"
},
"tags": {
"type": [
"array"
],
"description": "The tags for the presence.",
"items": {
"type": "string",
"description": "A tag.",
"pattern": "^[^A-Z\\s!\"#$%&'()*+,./:;<=>?@\\[\\\\\\]^_`{|}~]+$"
},
"minItems": 1
},
"category": {
"type": "string",
"description": "The category the presence falls under.",
"enum": [
"anime",
"games",
"music",
"socials",
"videos",
"other"
]
},
"iframe": {
"type": "boolean",
"description": "Whether or not the presence should run in IFrames."
},
"readLogs": {
"type": "boolean",
"description": "Whether or not the extension should be reading logs."
},
"regExp": {
"type": "string",
"description": "A regular expression used to match URLs for the presence to inject into."
},
"iFrameRegExp": {
"type": "string",
"description": "A regular expression used to match IFrames for the presence to inject into."
},
"button": {
"type": "boolean",
"description": "Controls whether the presence is automatically added when the extension is installed. For partner presences only."
},
"warning": {
"type": "boolean",
"description": "Shows a warning saying that it requires additional steps for the presence to function correctly."
},
"settings": {
"type": "array",
"description": "An array of settings the user can change in the presence.",
"items": {
"type": "object",
"description": "A setting.",
"properties": {
"id": {
"type": "string",
"description": "The ID of the setting."
},
"title": {
"type": "string",
"description": "The title of the setting. Required only if `multiLanguage` is disabled."
},
"icon": {
"type": "string",
"description": "The icon of the setting. Required only if `multiLanguage` is disabled.",
"pattern": "^fa([bsdrlt]|([-](brands|solid|duotone|regular|light|thin))) fa-[0-9a-z-]+$"
},
"if": {
"type": "object",
"description": "Restrict showing this setting if another setting is the defined value.",
"propertyNames": {
"type": "string",
"description": "The ID of the setting."
},
"patternProperties": {
"": {
"type": [
"string",
"number",
"boolean"
],
"description": "The value of the setting."
}
},
"additionalProperties": false
},
"placeholder": {
"type": "string",
"description": "The placeholder for settings that require input. Shown when the input is empty."
},
"value": {
"type": [
"string",
"number",
"boolean"
],
"description": "The default value of the setting. Not compatible with `values`."
},
"values": {
"type": "array",
"description": "The default values of the setting. Not compatible with `value`.",
"items": {
"type": [
"string",
"number",
"boolean"
],
"description": "The value of the setting."
}
},
"multiLanguage": {
"type": [
"string",
"boolean",
"array"
],
"description": "When false, multi-localization is disabled. When true, strings from the `general.json` file are available for use. When a string, it is the name of a file (excluding .json) of a used language from the localization GitHub repo. When an array of strings, it is all of the file names (excluding .json) of used languages from the localization GitHub repo.",
"items": {
"type": "string",
"description": "The name of a file from the localization GitHub repository."
}
}
},
"additionalProperties": false
}
}
},
"additionalProperties": false,
"required": [
"author",
"service",
"description",
"url",
"version",
"apiVersion",
"logo",
"thumbnail",
"color",
"tags",
"category"
]
}

36
pnpm-lock.yaml generated
View File

@@ -61,15 +61,6 @@ importers:
'@envelop/sentry':
specifier: ^9.0.0
version: 9.0.0(@envelop/core@5.0.2)(@sentry/node@8.30.0)(graphql@16.9.0)
'@opentelemetry/api':
specifier: ^1.9.0
version: 1.9.0
'@opentelemetry/exporter-prometheus':
specifier: ^0.52.1
version: 0.52.1(@opentelemetry/api@1.9.0)
'@opentelemetry/node':
specifier: ^0.24.0
version: 0.24.0(@opentelemetry/api@1.9.0)
'@sentry/node':
specifier: ^8.17.0
version: 8.30.0
@@ -97,6 +88,9 @@ importers:
postgres:
specifier: ^3.4.4
version: 3.4.4
prom-client:
specifier: ^15.1.3
version: 15.1.3
devDependencies:
'@types/debug':
specifier: ^4.1.12
@@ -3123,7 +3117,6 @@ packages:
'@rollup/rollup-linux-arm64-gnu@4.21.2':
resolution: {integrity: sha512-69CF19Kp3TdMopyteO/LJbWufOzqqXzkrv4L2sP8kfMaAQ6iwky7NoXTp7bD6/irKgknDKM0P9E/1l5XxVQAhw==}
cpu: [arm64]
os: [linux]
'@rollup/rollup-linux-arm64-musl@4.18.1':
@@ -4246,6 +4239,9 @@ packages:
bindings@1.5.0:
resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==}
bintrees@1.0.2:
resolution: {integrity: sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==}
birpc@0.2.17:
resolution: {integrity: sha512-+hkTxhot+dWsLpp3gia5AkVHIsKlZybNT5gIYiDlNzJrmYPcTM9k5/w2uaj3IPpd7LlEYpmCj4Jj1nC41VhDFg==}
@@ -7657,6 +7653,10 @@ packages:
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
engines: {node: '>= 0.6.0'}
prom-client@15.1.3:
resolution: {integrity: sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==}
engines: {node: ^16 || ^18 || >=20}
promise@7.3.1:
resolution: {integrity: sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==}
@@ -8326,6 +8326,9 @@ packages:
resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==}
engines: {node: '>=10'}
tdigest@0.1.2:
resolution: {integrity: sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==}
terser-webpack-plugin@5.3.10:
resolution: {integrity: sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==}
engines: {node: '>= 10.13.0'}
@@ -13954,7 +13957,7 @@ snapshots:
pathe: 1.1.2
sirv: 2.0.4
tinyrainbow: 1.2.0
vitest: 2.0.5(@types/node@20.16.5)(@vitest/ui@2.0.5)(happy-dom@15.7.4)(sass@1.78.0)(terser@5.32.0)
vitest: 2.0.5(@types/node@22.5.4)(@vitest/ui@2.0.5)(happy-dom@15.0.0)(sass@1.78.0)(terser@5.32.0)
'@vitest/utils@2.0.5':
dependencies:
@@ -14654,6 +14657,8 @@ snapshots:
dependencies:
file-uri-to-path: 1.0.0
bintrees@1.0.2: {}
birpc@0.2.17: {}
bl@4.1.0:
@@ -18889,6 +18894,11 @@ snapshots:
process@0.11.10: {}
prom-client@15.1.3:
dependencies:
'@opentelemetry/api': 1.9.0
tdigest: 0.1.2
promise@7.3.1:
dependencies:
asap: 2.0.6
@@ -19639,6 +19649,10 @@ snapshots:
mkdirp: 1.0.4
yallist: 4.0.0
tdigest@0.1.2:
dependencies:
bintrees: 1.0.2
terser-webpack-plugin@5.3.10(esbuild@0.23.1)(webpack@5.94.0(esbuild@0.23.1)):
dependencies:
'@jridgewell/trace-mapping': 0.3.25