Skip to content

Commit

Permalink
* Remove express-prom-bundle, puppeteer-extra, and puppeteer-extra-pl…
Browse files Browse the repository at this point in the history
…ugin-stealth dependencies

* Update dependencies
* Update README
* Use "observability" instead of "logger"
* Improve and fix Express logging and metrics
* Allow Pino logging level to be configurable
  • Loading branch information
danthonywalker committed Jan 8, 2024
1 parent 43c3194 commit 6237832
Show file tree
Hide file tree
Showing 16 changed files with 922 additions and 1,052 deletions.
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,19 @@ A custom bot designed for [The Anti-Car Collective](https://discord.gg/anticar)

### Requirements

- [Docker Compose](https://docs.docker.com/compose)
- [Node.js](https://nodejs.org)
- [docker-compose](https://docs.docker.com/compose)

### Environment Variables

Required environment variables must be configured in a `.env` file at the project's root. While [debugging](#debugging), additional environment variables can be configured in a `.env.debug` file at the project's root; for example, setting `REDIS_HOST` and `POSTGRESQL_HOST` to `localhost` is a common debug requirement.
Required environment variables must be configured in a `.env` file at the project's root.

| Environment Variable | Required | Default Value | Notes |
| -------------------- | -------- | ------------- | ------------------------------------------------------------------------------------------------------ |
| BOT_GUILD_ID || | Guild ID to enable `/bot` |
| DISCORD_TOKEN || | [Create Discord Token](https://discord.com/developers/docs/getting-started#configuring-your-bot) |
| ENABLE_CARSIZED || | IIF value is `true` then `/carsized` is enabled |
| EXPRESS_PORT || 8080 | |
| PINO_LEVEL || info | `silent`, `fatal`, `error`, `warn`, `info`, `debug`, `trace` |
| POSTGRESQL_HOST || postgres | |
| POSTGRESQL_PORT || 5432 | |
| POSTGRESQL_DATABASE || db | |
Expand All @@ -34,7 +34,7 @@ Required environment variables must be configured in a `.env` file at the projec

### Quick Start

Simply run `npm start`! Assuming the above requirements are met, the project will automatically be built and deployed locally along with required dependencies and services.
Simply run `npm start`! If the above requirements are met then the project will automatically be built and deployed locally along with required dependencies and services.

### Debugging

Expand All @@ -45,6 +45,8 @@ Steps 1-3 is not required if the ask is already installed, updated, and/or runni
3. Build project (`npm run build`)
4. Attach debugger while running `npm run debug`

Additional environment variables can be configured in a `.env.debug` file at the project's root; for example, setting `REDIS_HOST` and `POSTGRESQL_HOST` to `localhost` is a common debug requirement.

### Database Migration

It is recommended to dump (`npm run dump`) the database before migrating (`npm run migrate`) in case restoring (`npm run restore`) is required.
Expand Down
1,617 changes: 659 additions & 958 deletions package-lock.json

Large diffs are not rendered by default.

19 changes: 8 additions & 11 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,31 +8,28 @@
"csv": "6.3.6",
"discord.js": "14.14.1",
"express": "4.18.2",
"express-prom-bundle": "6.6.0",
"glob": "10.3.10",
"ioredis": "5.3.2",
"lunr": "2.3.9",
"pg": "8.11.3",
"pino": "8.17.1",
"pino-http": "8.6.0",
"pino": "8.17.2",
"pino-http": "9.0.0",
"prom-client": "15.1.0",
"puppeteer": "21.6.1",
"puppeteer-extra": "3.3.6",
"puppeteer-extra-plugin-stealth": "2.11.2",
"puppeteer": "21.7.0",
"rss-parser": "3.13.0",
"vega": "5.26.1"
"vega": "5.27.0"
},
"devDependencies": {
"@types/express": "4.17.21",
"@types/lunr": "2.3.7",
"@types/pg": "8.10.9",
"@typescript-eslint/eslint-plugin": "6.14.0",
"@typescript-eslint/parser": "6.14.0",
"@typescript-eslint/eslint-plugin": "6.18.0",
"@typescript-eslint/parser": "6.18.0",
"eslint": "8.56.0",
"eslint-plugin-perfectionist": "2.5.0",
"eslint-plugin-sonarjs": "0.23.0",
"eslint-plugin-unicorn": "49.0.0",
"pino-pretty": "10.3.0",
"eslint-plugin-unicorn": "50.0.1",
"pino-pretty": "10.3.1",
"prettier": "3.1.1",
"shx": "0.3.4",
"typescript": "5.3.3"
Expand Down
6 changes: 4 additions & 2 deletions src/commands/carsized/carsized.manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ import assert from "node:assert";
import type { Car, CompareCars } from "./types";

import Environment from "../../shared/environment";
import loggerFactory from "../../shared/logger";
import { isNonNullable } from "../../shared/nullable";
import * as observability from "../../shared/observability";
import { usePage } from "../../shared/puppeteer";
import RedisKey, * as redis from "../../shared/redis";
import { Perspective } from "./constants";

const logger = loggerFactory(module);
// region Logger
const logger = observability.logger(module);
// endregion

const CarsizedBaseUrl = "https://www.carsized.com/en";

Expand Down
6 changes: 4 additions & 2 deletions src/commands/carsized/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { AttachmentBuilder } from "discord.js";
import type { CompareCars } from "./types";

import Session from "../../session";
import loggerFactory from "../../shared/logger";
import * as observability from "../../shared/observability";
import * as carsized from "./carsized.manager";
import UI from "./ui";

Expand All @@ -20,7 +20,9 @@ export type Context = CompareCars & {
type Interaction = CommandInteraction | MessageComponentInteraction;
// endregion

const logger = loggerFactory(module);
// region Logger
const logger = observability.logger(module);
// endregion

const session = new Session<Context>();
export default session;
Expand Down
6 changes: 4 additions & 2 deletions src/commands/surveys/functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@ import type {
Survey,
} from "./types";

import loggerFactory from "../../shared/logger";
import * as observability from "../../shared/observability";
import { QuestionType } from "./constants";

const logger = loggerFactory(module);
// region Logger
const logger = observability.logger(module);
// endregion

// region Survey
export const surveyLink = ({ channelId, guildId, id }: PartialSurvey) =>
Expand Down
6 changes: 4 additions & 2 deletions src/creators/post/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ import type { CreatorSubscription } from "./database";

import { by, unique } from "../../shared/array";
import discord from "../../shared/discord";
import loggerFactory from "../../shared/logger";
import { isNullable } from "../../shared/nullable";
import * as observability from "../../shared/observability";
import * as creatorsDatabase from "../database";
import * as postDatabase from "./database";

Expand All @@ -35,7 +35,9 @@ export type Option = {
type Poster = (creatorDomainId: string) => Promise<Option[]>;
// endregion

const logger = loggerFactory(module);
// region Logger
const logger = observability.logger(module);
// endregion

const posters = new Map<CreatorType, Poster>();
export const registerPoster = (creatorType: CreatorType, poster: Poster) => {
Expand Down
52 changes: 32 additions & 20 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,47 @@
import { glob } from "glob";
import path from "node:path";
import { collectDefaultMetrics } from "prom-client";

import discord from "./shared/discord";
import Environment from "./shared/environment";
import express from "./shared/express";
import loggerFactory from "./shared/logger";
import * as observability from "./shared/observability";

// region Logger and Metrics
const logger = loggerFactory(module);

collectDefaultMetrics();
// region Logger
const logger = observability.logger(module);
// endregion

const importModules = async () => {
let paths = await glob(`${__dirname}/**/*.js`);
logger.trace(paths, "Glob paths for modules to import");
paths = paths.map((to) => `./${path.relative(__dirname, to)}`);
logger.debug(paths, "Relative paths for modules to import");

const imports = paths.map((importPath) => import(importPath));
await Promise.all(imports);
logger.info(`Imported ${imports.length} modules`);
};

const startExpress = async () => {
await new Promise<void>((resolve, reject) => {
express.once("error", reject);
express.listen(Environment.ExpressPort, resolve);
});

logger.info(`Express is listening on port ${Environment.ExpressPort}`);
};

const startDiscord = async () => {
await discord.login();
logger.info("Discord connected to the gateway");
};

const main = async () => {
try {
// Import all modules automatically to trigger side effects
const importPaths = await glob(`${__dirname}/**/*.js`);
const imports = importPaths
.map((importPath) => path.relative(__dirname, importPath))
.map((importPath) => import(`./${importPath}`));
await Promise.all(imports);

express.listen(Environment.ExpressPort, () => {
logger.info(`Express is listening on port ${Environment.ExpressPort}`);
});

await discord.login();
logger.info("Discord finished login");
await importModules();
await startExpress();
await startDiscord();
} catch (error) {
logger.fatal(error, "MAIN_ERROR");
logger.fatal(error, "Exiting process because an error was thrown in main");
process.exitCode = 1;
}
};
Expand Down
4 changes: 2 additions & 2 deletions src/shared/discord.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@ import { Client, Events, Routes, User } from "discord.js";
import assert from "node:assert";
import { Gauge, Histogram } from "prom-client";

import loggerFactory from "./logger";
import * as observability from "./observability";

// region Logger and Metrics
const logger = loggerFactory(module);
const logger = observability.logger(module);

const interactionRequestDuration = new Histogram({
help: "Interaction request duration in seconds",
Expand Down
23 changes: 17 additions & 6 deletions src/shared/environment.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,32 @@
import assert from "node:assert";
import type { LevelWithSilent } from "pino";

import assert from "node:assert/strict";

const { env } = process;

const required = (name: string) => {
const value = env[name];
// region assert
assert(value !== undefined, `Environment variable ${name} is required`);
// endregion
return value;
};

export default {
BotGuildId: env.BOT_GUILD_ID,
DiscordToken: env.DISCORD_TOKEN ?? assert.fail(),
EnableCarsized: env.ENABLE_CARSIZED === `${true}`,
DiscordToken: required("DISCORD_TOKEN"),
EnableCarsized: env.ENABLE_CARSIZED === true.toString(),
ExpressPort: Number(env.EXPRESS_PORT ?? 8080),
PinoLevel: (env.PINO_LEVEL ?? "info") as LevelWithSilent,
PostgresqlDatabase: env.POSTGRESQL_DATABASE ?? "db",
PostgresqlHost: env.POSTGRESQL_HOST ?? "postgres",
PostgresqlPassword: env.POSTGRESQL_PASSWORD ?? "password",
PostgresqlPort: Number(env.POSTGRESQL_PORT ?? 5432),
PostgresqlUser: env.POSTGRESQL_USER ?? "user",
ProjectName: env.PROJECT_NAME ?? "Pedestrian",
RedisCluster: env.REDIS_CLUSTER === `${true}`,
RedisCluster: env.REDIS_CLUSTER === true.toString(),
RedisHost: env.REDIS_HOST ?? "redis",
RedisPassword: env.REDIS_PASSWORD,
RedisPort: Number(env.REDIS_PORT ?? 6379),
RedisUsername: env.REDIS_USERNAME,
YoutubeApiKey: env.YOUTUBE_API_KEY ?? assert.fail(),
YoutubeApiKey: required("YOUTUBE_API_KEY"),
} as const;
100 changes: 83 additions & 17 deletions src/shared/express.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,92 @@
import type { Request } from "express";
import type { ServerResponse } from "node:http";

import express from "express";
import promBundle from "express-prom-bundle";
import pinoBundle from "pino-http";
import { randomUUID } from "node:crypto";
import pinoBundle, { startTime } from "pino-http";
import { Histogram, register } from "prom-client";

import loggerFactory from "./logger";
import * as observability from "./observability";

const server = express();

// region Logger and Metrics
const logger = loggerFactory(module);

server.use(
promBundle({
includeMethod: true,
includePath: true,
}),
);

server.use(
pinoBundle({
logger,
}),
);
const httpRequestDuration = new Histogram({
help: "HTTP request duration in seconds",
labelNames: ["method", "path", "statusCode"],
name: "http_request_duration_seconds",
});

const observe = (
request: Request,
response: ServerResponse,
responseTime: number,
) => {
const route: unknown = request.route;
let path: string | undefined;

if (typeof route === "object" && route !== null && "path" in route) {
const { path: routePath } = route;
if (typeof routePath === "string") path = routePath;
}

const { method } = request;
const { statusCode } = response;

const labels = { method, path, statusCode };
httpRequestDuration.observe(labels, responseTime / 1000);
};

const logger = pinoBundle<Request>({
customAttributeKeys: {
err: "error",
req: "request",
reqId: "correlationId",
res: "response",
},
customErrorMessage: (request, response) => {
const responseTime = Date.now() - response[startTime];
observe(request, response, responseTime);
return `Request errored in ${responseTime}ms`;
},
customLogLevel: (request, { statusCode }) => {
if (statusCode >= 500) return "error";
else if (statusCode >= 400) return "warn";
else return "info";
},
customSuccessMessage: (request, response, responseTime) => {
observe(request, response, responseTime);
return `Request succeeded in ${responseTime}ms`;
},
genReqId: observability.correlationId,
logger: observability.logger(module),
});

server.use((request, response, next) => {
response[startTime] = Date.now();

let correlationId = request.headers["X-Request-Id"];
if (Array.isArray(correlationId)) correlationId = correlationId[0];
correlationId ??= randomUUID();

response.setHeader("X-Request-Id", correlationId);
observability.correlate({ correlationId }, () => {
logger(request, response, next);
});
});

server.get("/metrics", (request, response, next) => {
register
.metrics()
.then((metrics) => {
response.setHeader("Content-Type", register.contentType);
response.send(metrics);

const { log } = response;
log.debug(`Global register metrics:\n${metrics}`);
})
.catch(next);
});
// endregion

export default server;
Loading

0 comments on commit 6237832

Please sign in to comment.