From 9bdee873ccac66c939790b86646fd5c48a4dcb24 Mon Sep 17 00:00:00 2001 From: Faraz Razi <72218210+FarazRazi@users.noreply.github.com> Date: Mon, 21 Apr 2025 13:03:54 +0000 Subject: [PATCH 1/2] feat(logger): add request logging interceptor with detailed and concise logging --- env-example-document | 4 + env-example-relational | 4 + package-lock.json | 229 +++++++++++++++++++++++++ package.json | 3 + src/app.module.ts | 196 ++++++++++----------- src/config/config.type.ts | 40 +++-- src/config/logger-config.type.ts | 4 + src/config/logger.config.ts | 32 ++++ src/database/typeorm-config.service.ts | 114 ++++++------ src/logger/logger.interceptor.ts | 120 +++++++++++++ src/logger/logger.module.ts | 14 ++ src/logger/logger.service.ts | 51 ++++++ src/main.ts | 126 +++++++------- 13 files changed, 705 insertions(+), 232 deletions(-) create mode 100644 src/config/logger-config.type.ts create mode 100644 src/config/logger.config.ts create mode 100644 src/logger/logger.interceptor.ts create mode 100644 src/logger/logger.module.ts create mode 100644 src/logger/logger.service.ts diff --git a/env-example-document b/env-example-document index c5ab965f6..0fc617391 100644 --- a/env-example-document +++ b/env-example-document @@ -50,3 +50,7 @@ GOOGLE_CLIENT_SECRET= APPLE_APP_AUDIENCE=[] WORKER_HOST=redis://redis:6379/1 + +# Logger Configuration +LOG_LEVEL=info +LOG_PRETTY=true \ No newline at end of file diff --git a/env-example-relational b/env-example-relational index 1501e81ec..f8e688e5e 100644 --- a/env-example-relational +++ b/env-example-relational @@ -58,3 +58,7 @@ GOOGLE_CLIENT_SECRET= APPLE_APP_AUDIENCE=[] WORKER_HOST=redis://redis:6379/1 + +# Logger Configuration +LOG_LEVEL=info +LOG_PRETTY=true \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index c40384651..f3bf49c04 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "@nestjs/swagger": "11.1.4", "@nestjs/typeorm": "11.0.0", "@types/multer-s3": "3.0.3", + "@types/pino": "^7.0.5", "@types/prompts": "2.4.9", "apple-signin-auth": "1.7.9", "bcryptjs": "3.0.2", @@ -80,6 +81,8 @@ "hygen": "6.2.11", "is-ci": "4.1.0", "jest": "29.7.0", + "pino": "^9.6.0", + "pino-pretty": "^13.0.0", "prettier": "3.5.3", "prompts": "2.4.2", "release-it": "18.1.2", @@ -6569,6 +6572,16 @@ "@types/passport": "*" } }, + "node_modules/@types/pino": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/@types/pino/-/pino-7.0.5.tgz", + "integrity": "sha512-wKoab31pknvILkxAF8ss+v9iNyhw5Iu/0jLtRkUD74cNfOOLJNnqfFKAv0r7wVaTQxRZtWrMpGfShwwBjOcgcg==", + "deprecated": "This is a stub types definition. pino provides its own type definitions, so you do not need this installed.", + "license": "MIT", + "dependencies": { + "pino": "*" + } + }, "node_modules/@types/prompts": { "version": "2.4.9", "resolved": "https://registry.npmjs.org/@types/prompts/-/prompts-2.4.9.tgz", @@ -8188,6 +8201,15 @@ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/atomically": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/atomically/-/atomically-2.0.3.tgz", @@ -9210,6 +9232,13 @@ "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", "dev": true }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -10071,6 +10100,16 @@ "node": ">= 14" } }, + "node_modules/dateformat": { + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", + "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/dayjs": { "version": "1.11.13", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", @@ -10440,6 +10479,16 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.17.1", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz", @@ -11267,6 +11316,13 @@ ], "license": "MIT" }, + "node_modules/fast-copy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-3.0.2.tgz", + "integrity": "sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -11312,6 +11368,15 @@ "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", "dev": true }, + "node_modules/fast-redact": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/fast-redact/-/fast-redact-3.5.0.tgz", + "integrity": "sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/fast-safe-stringify": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", @@ -12399,6 +12464,13 @@ "upper-case": "^1.1.3" } }, + "node_modules/help-me": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", + "dev": true, + "license": "MIT" + }, "node_modules/hexoid": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz", @@ -15121,6 +15193,16 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -16468,6 +16550,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -17129,6 +17220,68 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pino": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.6.0.tgz", + "integrity": "sha512-i85pKRCt4qMjZ1+L7sy2Ag4t1atFcdbEt76+7iRJn1g2BvsnRMGu9p8pivl9fs63M2kF/A0OacFZhTub+m/qMg==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0", + "fast-redact": "^3.1.1", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^4.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-pretty": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.0.0.tgz", + "integrity": "sha512-cQBBIVG3YajgoUjo1FdKVRX6t9XPxwB9lcNJVD5GCnNM4Y6T12YYx8c6zEejxQsU0wrg9TwmDulcE9LR7qcJqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "colorette": "^2.0.7", + "dateformat": "^4.6.3", + "fast-copy": "^3.0.2", + "fast-safe-stringify": "^2.1.1", + "help-me": "^5.0.0", + "joycon": "^3.1.1", + "minimist": "^1.2.6", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pump": "^3.0.0", + "secure-json-parse": "^2.4.0", + "sonic-boom": "^4.0.1", + "strip-json-comments": "^3.1.1" + }, + "bin": { + "pino-pretty": "bin.js" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.0.0.tgz", + "integrity": "sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==", + "license": "MIT" + }, "node_modules/pirates": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.5.tgz", @@ -17325,6 +17478,22 @@ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" }, + "node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -17406,6 +17575,17 @@ "resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz", "integrity": "sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==" }, + "node_modules/pump": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", + "integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -17468,6 +17648,12 @@ "dev": true, "license": "MIT" }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, "node_modules/quick-lru": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", @@ -17668,6 +17854,15 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/rechoir": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz", @@ -18524,6 +18719,15 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -18547,6 +18751,13 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/secure-json-parse": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", + "integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/seek-bzip": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/seek-bzip/-/seek-bzip-2.0.0.tgz", @@ -18891,6 +19102,15 @@ "node": ">= 14" } }, + "node_modules/sonic-boom": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz", + "integrity": "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/sort-keys": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/sort-keys/-/sort-keys-1.1.2.tgz", @@ -19646,6 +19866,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/thread-stream": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", + "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + } + }, "node_modules/through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", diff --git a/package.json b/package.json index 929466263..e3f5e8bab 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "@nestjs/swagger": "11.1.4", "@nestjs/typeorm": "11.0.0", "@types/multer-s3": "3.0.3", + "@types/pino": "^7.0.5", "@types/prompts": "2.4.9", "apple-signin-auth": "1.7.9", "bcryptjs": "3.0.2", @@ -121,6 +122,8 @@ "hygen": "6.2.11", "is-ci": "4.1.0", "jest": "29.7.0", + "pino": "^9.6.0", + "pino-pretty": "^13.0.0", "prettier": "3.5.3", "prompts": "2.4.2", "release-it": "18.1.2", diff --git a/src/app.module.ts b/src/app.module.ts index 1d13afb04..2985998ce 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,97 +1,99 @@ -import { Module } from '@nestjs/common'; -import { UsersModule } from './users/users.module'; -import { FilesModule } from './files/files.module'; -import { AuthModule } from './auth/auth.module'; -import databaseConfig from './database/config/database.config'; -import authConfig from './auth/config/auth.config'; -import appConfig from './config/app.config'; -import mailConfig from './mail/config/mail.config'; -import fileConfig from './files/config/file.config'; -import facebookConfig from './auth-facebook/config/facebook.config'; -import googleConfig from './auth-google/config/google.config'; -import appleConfig from './auth-apple/config/apple.config'; -import path from 'path'; -import { ConfigModule, ConfigService } from '@nestjs/config'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { AuthAppleModule } from './auth-apple/auth-apple.module'; -import { AuthFacebookModule } from './auth-facebook/auth-facebook.module'; -import { AuthGoogleModule } from './auth-google/auth-google.module'; -import { HeaderResolver, I18nModule } from 'nestjs-i18n'; -import { TypeOrmConfigService } from './database/typeorm-config.service'; -import { MailModule } from './mail/mail.module'; -import { HomeModule } from './home/home.module'; -import { DataSource, DataSourceOptions } from 'typeorm'; -import { AllConfigType } from './config/config.type'; -import { SessionModule } from './session/session.module'; -import { MailerModule } from './mailer/mailer.module'; -import { MongooseModule } from '@nestjs/mongoose'; -import { MongooseConfigService } from './database/mongoose-config.service'; -import { DatabaseConfig } from './database/config/database-config.type'; - -// -const infrastructureDatabaseModule = (databaseConfig() as DatabaseConfig) - .isDocumentDatabase - ? MongooseModule.forRootAsync({ - useClass: MongooseConfigService, - }) - : TypeOrmModule.forRootAsync({ - useClass: TypeOrmConfigService, - dataSourceFactory: async (options: DataSourceOptions) => { - return new DataSource(options).initialize(); - }, - }); -// - -@Module({ - imports: [ - ConfigModule.forRoot({ - isGlobal: true, - load: [ - databaseConfig, - authConfig, - appConfig, - mailConfig, - fileConfig, - facebookConfig, - googleConfig, - appleConfig, - ], - envFilePath: ['.env'], - }), - infrastructureDatabaseModule, - I18nModule.forRootAsync({ - useFactory: (configService: ConfigService) => ({ - fallbackLanguage: configService.getOrThrow('app.fallbackLanguage', { - infer: true, - }), - loaderOptions: { path: path.join(__dirname, '/i18n/'), watch: true }, - }), - resolvers: [ - { - use: HeaderResolver, - useFactory: (configService: ConfigService) => { - return [ - configService.get('app.headerLanguage', { - infer: true, - }), - ]; - }, - inject: [ConfigService], - }, - ], - imports: [ConfigModule], - inject: [ConfigService], - }), - UsersModule, - FilesModule, - AuthModule, - AuthFacebookModule, - AuthGoogleModule, - AuthAppleModule, - SessionModule, - MailModule, - MailerModule, - HomeModule, - ], -}) -export class AppModule {} +import { Module } from '@nestjs/common'; +import { UsersModule } from './users/users.module'; +import { FilesModule } from './files/files.module'; +import { AuthModule } from './auth/auth.module'; +import databaseConfig from './database/config/database.config'; +import authConfig from './auth/config/auth.config'; +import appConfig from './config/app.config'; +import mailConfig from './mail/config/mail.config'; +import fileConfig from './files/config/file.config'; +import facebookConfig from './auth-facebook/config/facebook.config'; +import googleConfig from './auth-google/config/google.config'; +import appleConfig from './auth-apple/config/apple.config'; +import path from 'path'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuthAppleModule } from './auth-apple/auth-apple.module'; +import { AuthFacebookModule } from './auth-facebook/auth-facebook.module'; +import { AuthGoogleModule } from './auth-google/auth-google.module'; +import { HeaderResolver, I18nModule } from 'nestjs-i18n'; +import { TypeOrmConfigService } from './database/typeorm-config.service'; +import { MailModule } from './mail/mail.module'; +import { HomeModule } from './home/home.module'; +import { DataSource, DataSourceOptions } from 'typeorm'; +import { AllConfigType } from './config/config.type'; +import { SessionModule } from './session/session.module'; +import { MailerModule } from './mailer/mailer.module'; +import { MongooseModule } from '@nestjs/mongoose'; +import { MongooseConfigService } from './database/mongoose-config.service'; +import { DatabaseConfig } from './database/config/database-config.type'; +import { LoggerModule } from './logger/logger.module'; + +// +const infrastructureDatabaseModule = (databaseConfig() as DatabaseConfig) + .isDocumentDatabase + ? MongooseModule.forRootAsync({ + useClass: MongooseConfigService, + }) + : TypeOrmModule.forRootAsync({ + useClass: TypeOrmConfigService, + dataSourceFactory: async (options: DataSourceOptions) => { + return new DataSource(options).initialize(); + }, + }); +// + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + load: [ + databaseConfig, + authConfig, + appConfig, + mailConfig, + fileConfig, + facebookConfig, + googleConfig, + appleConfig, + ], + envFilePath: ['.env'], + }), + infrastructureDatabaseModule, + I18nModule.forRootAsync({ + useFactory: (configService: ConfigService) => ({ + fallbackLanguage: configService.getOrThrow('app.fallbackLanguage', { + infer: true, + }), + loaderOptions: { path: path.join(__dirname, '/i18n/'), watch: true }, + }), + resolvers: [ + { + use: HeaderResolver, + useFactory: (configService: ConfigService) => { + return [ + configService.get('app.headerLanguage', { + infer: true, + }), + ]; + }, + inject: [ConfigService], + }, + ], + imports: [ConfigModule], + inject: [ConfigService], + }), + UsersModule, + FilesModule, + AuthModule, + AuthFacebookModule, + AuthGoogleModule, + AuthAppleModule, + SessionModule, + MailModule, + MailerModule, + HomeModule, + LoggerModule, + ], +}) +export class AppModule {} diff --git a/src/config/config.type.ts b/src/config/config.type.ts index 778e63746..409a5c57f 100644 --- a/src/config/config.type.ts +++ b/src/config/config.type.ts @@ -1,19 +1,21 @@ -import { AppConfig } from './app-config.type'; -import { AppleConfig } from '../auth-apple/config/apple-config.type'; -import { AuthConfig } from '../auth/config/auth-config.type'; -import { DatabaseConfig } from '../database/config/database-config.type'; -import { FacebookConfig } from '../auth-facebook/config/facebook-config.type'; -import { FileConfig } from '../files/config/file-config.type'; -import { GoogleConfig } from '../auth-google/config/google-config.type'; -import { MailConfig } from '../mail/config/mail-config.type'; - -export type AllConfigType = { - app: AppConfig; - apple: AppleConfig; - auth: AuthConfig; - database: DatabaseConfig; - facebook: FacebookConfig; - file: FileConfig; - google: GoogleConfig; - mail: MailConfig; -}; +import { AppleConfig } from '../auth-apple/config/apple-config.type'; +import { FacebookConfig } from '../auth-facebook/config/facebook-config.type'; +import { GoogleConfig } from '../auth-google/config/google-config.type'; +import { AuthConfig } from '../auth/config/auth-config.type'; +import { DatabaseConfig } from '../database/config/database-config.type'; +import { FileConfig } from '../files/config/file-config.type'; +import { MailConfig } from '../mail/config/mail-config.type'; +import { AppConfig } from './app-config.type'; +import { LoggerConfig } from './logger-config.type'; + +export type AllConfigType = { + app: AppConfig; + apple: AppleConfig; + auth: AuthConfig; + database: DatabaseConfig; + facebook: FacebookConfig; + file: FileConfig; + google: GoogleConfig; + mail: MailConfig; + logger: LoggerConfig; +}; diff --git a/src/config/logger-config.type.ts b/src/config/logger-config.type.ts new file mode 100644 index 000000000..e9103d9dc --- /dev/null +++ b/src/config/logger-config.type.ts @@ -0,0 +1,4 @@ +export interface LoggerConfig { + level: string; + pretty: boolean; +} diff --git a/src/config/logger.config.ts b/src/config/logger.config.ts new file mode 100644 index 000000000..75c71a3aa --- /dev/null +++ b/src/config/logger.config.ts @@ -0,0 +1,32 @@ +import { IsEnum, IsOptional, IsString } from 'class-validator'; + +import { registerAs } from '@nestjs/config'; + +import validateConfig from '../utils/validate-config'; + +enum LogLevel { + ERROR = 'error', + WARN = 'warn', + INFO = 'info', + DEBUG = 'debug', + TRACE = 'trace', +} + +class EnvironmentVariablesValidator { + @IsEnum(LogLevel) + @IsOptional() + LOG_LEVEL: LogLevel; + + @IsString() + @IsOptional() + LOG_PRETTY: string; +} + +export default registerAs('logger', () => { + validateConfig(process.env, EnvironmentVariablesValidator); + + return { + level: process.env.LOG_LEVEL || 'info', + pretty: process.env.LOG_PRETTY === 'true', + }; +}); diff --git a/src/database/typeorm-config.service.ts b/src/database/typeorm-config.service.ts index 45c79bc12..333299fa2 100644 --- a/src/database/typeorm-config.service.ts +++ b/src/database/typeorm-config.service.ts @@ -1,57 +1,57 @@ -import { Injectable } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { TypeOrmModuleOptions, TypeOrmOptionsFactory } from '@nestjs/typeorm'; -import { AllConfigType } from '../config/config.type'; - -@Injectable() -export class TypeOrmConfigService implements TypeOrmOptionsFactory { - constructor(private configService: ConfigService) {} - - createTypeOrmOptions(): TypeOrmModuleOptions { - return { - type: this.configService.get('database.type', { infer: true }), - url: this.configService.get('database.url', { infer: true }), - host: this.configService.get('database.host', { infer: true }), - port: this.configService.get('database.port', { infer: true }), - username: this.configService.get('database.username', { infer: true }), - password: this.configService.get('database.password', { infer: true }), - database: this.configService.get('database.name', { infer: true }), - synchronize: this.configService.get('database.synchronize', { - infer: true, - }), - dropSchema: false, - keepConnectionAlive: true, - logging: - this.configService.get('app.nodeEnv', { infer: true }) !== 'production', - entities: [__dirname + '/../**/*.entity{.ts,.js}'], - migrations: [__dirname + '/migrations/**/*{.ts,.js}'], - cli: { - entitiesDir: 'src', - - subscribersDir: 'subscriber', - }, - extra: { - // based on https://node-postgres.com/apis/pool - // max connection pool size - max: this.configService.get('database.maxConnections', { infer: true }), - ssl: this.configService.get('database.sslEnabled', { infer: true }) - ? { - rejectUnauthorized: this.configService.get( - 'database.rejectUnauthorized', - { infer: true }, - ), - ca: - this.configService.get('database.ca', { infer: true }) ?? - undefined, - key: - this.configService.get('database.key', { infer: true }) ?? - undefined, - cert: - this.configService.get('database.cert', { infer: true }) ?? - undefined, - } - : undefined, - }, - } as TypeOrmModuleOptions; - } -} +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { TypeOrmModuleOptions, TypeOrmOptionsFactory } from '@nestjs/typeorm'; +import { AllConfigType } from '../config/config.type'; + +@Injectable() +export class TypeOrmConfigService implements TypeOrmOptionsFactory { + constructor(private configService: ConfigService) {} + + createTypeOrmOptions(): TypeOrmModuleOptions { + return { + type: this.configService.get('database.type', { infer: true }), + url: this.configService.get('database.url', { infer: true }), + host: this.configService.get('database.host', { infer: true }), + port: this.configService.get('database.port', { infer: true }), + username: this.configService.get('database.username', { infer: true }), + password: this.configService.get('database.password', { infer: true }), + database: this.configService.get('database.name', { infer: true }), + synchronize: this.configService.get('database.synchronize', { + infer: true, + }), + dropSchema: false, + keepConnectionAlive: true, + logging: + this.configService.get('logger.level', { infer: true }) === 'debug', + entities: [__dirname + '/../**/*.entity{.ts,.js}'], + migrations: [__dirname + '/migrations/**/*{.ts,.js}'], + cli: { + entitiesDir: 'src', + + subscribersDir: 'subscriber', + }, + extra: { + // based on https://node-postgres.com/apis/pool + // max connection pool size + max: this.configService.get('database.maxConnections', { infer: true }), + ssl: this.configService.get('database.sslEnabled', { infer: true }) + ? { + rejectUnauthorized: this.configService.get( + 'database.rejectUnauthorized', + { infer: true }, + ), + ca: + this.configService.get('database.ca', { infer: true }) ?? + undefined, + key: + this.configService.get('database.key', { infer: true }) ?? + undefined, + cert: + this.configService.get('database.cert', { infer: true }) ?? + undefined, + } + : undefined, + }, + } as TypeOrmModuleOptions; + } +} diff --git a/src/logger/logger.interceptor.ts b/src/logger/logger.interceptor.ts new file mode 100644 index 000000000..4bf8a77a2 --- /dev/null +++ b/src/logger/logger.interceptor.ts @@ -0,0 +1,120 @@ +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; + +import { PinoLoggerService } from './logger.service'; + +@Injectable() +export class LoggingInterceptor implements NestInterceptor { + constructor(private readonly logger: PinoLoggerService) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const request = context.switchToHttp().getRequest(); + const { method, url, body, query, params, headers } = request; + const startTime = Date.now(); + + // One-line info log for incoming request + this.logger.log(`${method} ${url}`); + + // Detailed debug log + this.logger.debug({ + message: 'Incoming Request', + method, + url, + body, + query, + params, + headers: this.sanitizeHeaders(headers), + }); + + return next.handle().pipe( + tap({ + next: (data) => { + const response = context.switchToHttp().getResponse(); + const { statusCode } = response; + const duration = Date.now() - startTime; + + // Log response + this.logger.debug({ + message: 'Outgoing Response', + method, + url, + statusCode, + duration: `${duration}ms`, + response: this.sanitizeResponse(data), + }); + }, + error: (error) => { + const duration = Date.now() - startTime; + const statusCode = error.status || 500; + + // Log error + this.logger.error({ + message: 'Request Error', + method, + url, + statusCode, + duration: `${duration}ms`, + error: { + message: error.message, + stack: error.stack, + }, + }); + }, + }), + ); + } + + private sanitizeHeaders(headers: Record): Record { + const sensitiveHeaders = ['authorization', 'cookie', 'set-cookie']; + const sanitized = { ...headers }; + + Object.keys(sanitized).forEach((key) => { + if (sensitiveHeaders.some((h) => h.toLowerCase() === key.toLowerCase())) { + sanitized[key] = '[REDACTED]'; + } + }); + return sanitized; + } + + private sanitizeResponse(data: any): any { + if (!data) return data; + + const sensitiveFields = [ + 'password', + 'token', + 'refreshToken', + 'accessToken', + ]; + + if (Array.isArray(data)) { + return data.map((item) => this.sanitizeResponse(item)); + } + + if (typeof data === 'object' && data !== null) { + const sanitized = { ...data }; + + // Sanitize current level + sensitiveFields.forEach((field) => { + if (field in sanitized) { + sanitized[field] = '[REDACTED]'; + } + }); + + // Recursively sanitize nested objects + for (const key in sanitized) { + if (typeof sanitized[key] === 'object' && sanitized[key] !== null) { + sanitized[key] = this.sanitizeResponse(sanitized[key]); + } + } + + return sanitized; + } + return data; + } +} diff --git a/src/logger/logger.module.ts b/src/logger/logger.module.ts new file mode 100644 index 000000000..b243b234a --- /dev/null +++ b/src/logger/logger.module.ts @@ -0,0 +1,14 @@ +import { Global, Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; + +import loggerConfig from '../config/logger.config'; +import { LoggingInterceptor } from './logger.interceptor'; +import { PinoLoggerService } from './logger.service'; + +@Global() +@Module({ + imports: [ConfigModule.forFeature(loggerConfig)], + providers: [PinoLoggerService, LoggingInterceptor], + exports: [PinoLoggerService, LoggingInterceptor], +}) +export class LoggerModule {} diff --git a/src/logger/logger.service.ts b/src/logger/logger.service.ts new file mode 100644 index 000000000..87ab71572 --- /dev/null +++ b/src/logger/logger.service.ts @@ -0,0 +1,51 @@ +import pino from 'pino'; + +import { Injectable, LoggerService } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +import { AllConfigType } from '../config/config.type'; + +@Injectable() +export class PinoLoggerService implements LoggerService { + private logger: pino.Logger; + + constructor(private configService: ConfigService) { + const loggerConfig = this.configService.get('logger', { infer: true }); + if (!loggerConfig) { + throw new Error('Logger configuration is not defined'); + } + + this.logger = pino({ + level: loggerConfig.level || 'info', + transport: loggerConfig.pretty + ? { + target: 'pino-pretty', + options: { + colorize: true, + translateTime: 'SYS:standard', + }, + } + : undefined, + }); + } + + log(message: any, ...optionalParams: any[]) { + this.logger.info(message, ...optionalParams); + } + + error(message: any, ...optionalParams: any[]) { + this.logger.error(message, ...optionalParams); + } + + warn(message: any, ...optionalParams: any[]) { + this.logger.warn(message, ...optionalParams); + } + + debug(message: any, ...optionalParams: any[]) { + this.logger.debug(message, ...optionalParams); + } + + verbose(message: any, ...optionalParams: any[]) { + this.logger.trace(message, ...optionalParams); + } +} diff --git a/src/main.ts b/src/main.ts index 481d8ed0a..7789ea4f8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,59 +1,67 @@ -import 'dotenv/config'; -import { - ClassSerializerInterceptor, - ValidationPipe, - VersioningType, -} from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { NestFactory, Reflector } from '@nestjs/core'; -import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; -import { useContainer } from 'class-validator'; -import { AppModule } from './app.module'; -import validationOptions from './utils/validation-options'; -import { AllConfigType } from './config/config.type'; -import { ResolvePromisesInterceptor } from './utils/serializer.interceptor'; - -async function bootstrap() { - const app = await NestFactory.create(AppModule, { cors: true }); - useContainer(app.select(AppModule), { fallbackOnErrors: true }); - const configService = app.get(ConfigService); - - app.enableShutdownHooks(); - app.setGlobalPrefix( - configService.getOrThrow('app.apiPrefix', { infer: true }), - { - exclude: ['/'], - }, - ); - app.enableVersioning({ - type: VersioningType.URI, - }); - app.useGlobalPipes(new ValidationPipe(validationOptions)); - app.useGlobalInterceptors( - // ResolvePromisesInterceptor is used to resolve promises in responses because class-transformer can't do it - // https://github.com/typestack/class-transformer/issues/549 - new ResolvePromisesInterceptor(), - new ClassSerializerInterceptor(app.get(Reflector)), - ); - - const options = new DocumentBuilder() - .setTitle('API') - .setDescription('API docs') - .setVersion('1.0') - .addBearerAuth() - .addGlobalParameters({ - in: 'header', - required: false, - name: process.env.APP_HEADER_LANGUAGE || 'x-custom-lang', - schema: { - example: 'en', - }, - }) - .build(); - - const document = SwaggerModule.createDocument(app, options); - SwaggerModule.setup('docs', app, document); - - await app.listen(configService.getOrThrow('app.port', { infer: true })); -} -void bootstrap(); +import 'dotenv/config'; +import { + ClassSerializerInterceptor, + ValidationPipe, + VersioningType, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { NestFactory, Reflector } from '@nestjs/core'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import { useContainer } from 'class-validator'; +import { AppModule } from './app.module'; +import validationOptions from './utils/validation-options'; +import { AllConfigType } from './config/config.type'; +import { ResolvePromisesInterceptor } from './utils/serializer.interceptor'; +import { LoggingInterceptor } from './logger/logger.interceptor'; +import { PinoLoggerService } from './logger/logger.service'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule, { + cors: true, + bufferLogs: true, + }); + const logger = app.get(PinoLoggerService); + app.useLogger(logger); + useContainer(app.select(AppModule), { fallbackOnErrors: true }); + const configService = app.get(ConfigService); + + app.enableShutdownHooks(); + app.setGlobalPrefix( + configService.getOrThrow('app.apiPrefix', { infer: true }), + { + exclude: ['/'], + }, + ); + app.enableVersioning({ + type: VersioningType.URI, + }); + app.useGlobalPipes(new ValidationPipe(validationOptions)); + app.useGlobalInterceptors( + new LoggingInterceptor(logger), + new ResolvePromisesInterceptor(), + new ClassSerializerInterceptor(app.get(Reflector)), + ); + + const options = new DocumentBuilder() + .setTitle('API') + .setDescription('API docs') + .setVersion('1.0') + .addBearerAuth() + .addGlobalParameters({ + in: 'header', + required: false, + name: process.env.APP_HEADER_LANGUAGE || 'x-custom-lang', + schema: { + example: 'en', + }, + }) + .build(); + + const document = SwaggerModule.createDocument(app, options); + SwaggerModule.setup('docs', app, document); + + const port = configService.getOrThrow('app.port', { infer: true }); + await app.listen(port); + logger.log(`Application is running on: http://localhost:${port}`); +} +void bootstrap(); From dc38ef5749c6dc2b62f188f89f878dd9ec5868bd Mon Sep 17 00:00:00 2001 From: Faraz Razi <72218210+FarazRazi@users.noreply.github.com> Date: Mon, 21 Apr 2025 13:04:55 +0000 Subject: [PATCH 2/2] fix(eslint): added fix for endOfLine error due to windows formatting --- eslint.config.mjs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 4bd58851c..2dba3d0e2 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,10 +1,11 @@ -import tsEslintPlugin from '@typescript-eslint/eslint-plugin'; import globals from 'globals'; -import tsParser from '@typescript-eslint/parser'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import js from '@eslint/js'; + import { FlatCompat } from '@eslint/eslintrc'; +import js from '@eslint/js'; +import tsEslintPlugin from '@typescript-eslint/eslint-plugin'; +import tsParser from '@typescript-eslint/parser'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -60,6 +61,12 @@ export default [ message: '"it" should start with "should"', }, ], + 'prettier/prettier': [ + 'error', + { + endOfLine: 'auto', + }, + ], }, }, ];