Skip to content

Instantly share code, notes, and snippets.

@cuongld2
Created November 19, 2023 00:08
Show Gist options
  • Save cuongld2/eb12bb37df2780d12bfe0fa33ee1836e to your computer and use it in GitHub Desktop.
Save cuongld2/eb12bb37df2780d12bfe0fa33ee1836e to your computer and use it in GitHub Desktop.
distributed-tracing-node-js

Introduction

In a microservices architecture, besides the advantages like scalability, faster time to market, and improving maintability, it also comes with disadvantages. One of the hardest problems with microservices is how to debug errors when they happen. With dozens to hundreds of microservices communicating with each other in an application, you can easily get lost in the ocean of logs where you don't know the root cause of the problem.

To quickly debug and find out the root cause of the problem, you need to apply distributed tracing for your application. By the end of the article, you will understand how to apply distributed tracing for Node.js services using Jaeger.

Prerequisites

To follow along with the step-by-step, terminal-based instructions in article, you need to prepare the following prerequisites:

  1. Prepare an Ubuntu cloud server, preferably version 20.04 or above with 4 vCPUs and 16 GB Memory
  2. Install Docker Desktop latest version to deploy MongoDB for storing the data of the backend services and Jaeger to store traces of the API requests. Please note that you need Docker Compose with version 2 or above to run the Docker services in the article. Docker Compose V2 is included in latest version of Docker Desktop.
  3. Install pm2 globally in the virtual machine to manage multiple backend services
  4. Install OpenSSL to generate the RSA key to create the JWT key for authentication in the demo app.

Demo Application

The demo application is a blog application where users can make API requests to create a new blog or read existing blogs in the database. There are 3 types of users in the demo application.

  • Anonymous users cannot read or create a new blog
  • Authenticated users with READ permission can only read the blogs
  • Authenticated users with WRITE permission can read the blogs and create a new blog.

The blog application has 4 services:

  • User service allows the administrator to create new users
  • Authen service allows user to authenticate their credentials so that they can have the token to read or create new blog later on
  • Author service checks the user token to see whether the user has permission to create a new blog or not
  • Blog service allows users to read or create a new blog post

Example Values

  • Jwt issuer: api@example.net
  • Jwt audience: api.blog.example.net
  • Secret key: example

You can change the above values with your own values to build and run the application.

Initialize App Project

To build the demo application, you need to initialize the app project first.

  1. Create a project directory from any location in your Vultr cloud server

     $ mkdir nodejs-distributed-tracing-app
    

    Navigate to the project directory

     $ cd nodejs-distributed-tracing-app
    
  2. Get the project directory path and save it. You will use this path to navigate to the project directory for convenience

     $ pwd
    

    Save the path to environment variable named PROJECT_DIRECTORY:

     $ export PROJECT_DIRECTORY=$(pwd)
    
  3. Initialize the Node.js app with default options

     $ npm init -y
    
  4. Application dependencies

    For the application to run, you need to install the dependencies for it so that it can interact with the Mongo database or sending logs to Jaeger. To do that, open the package.json file:

     $ nano package.json
    

    Add the following content to the end of the file (remember to add a , character after the license line to have a valid json file):

     "dependencies": {
     "@opentelemetry/auto-instrumentations-node": "^0.39.2",
     "@opentelemetry/exporter-trace-otlp-http": "^0.43.0",
     "@opentelemetry/resources": "^1.17.0",
     "@opentelemetry/sdk-node": "^0.43.0",
     "@opentelemetry/sdk-trace-base": "^1.17.0",
     "@opentelemetry/semantic-conventions": "^1.17.0",
     "auto-bind": "^1.1.0",
     "axios": "^1.5.1",
     "body-parser": "^1.17.1",
     "debug": "^2.6.6",
     "express": "latest",
     "express-api-cache": "^1.0.4",
     "express-validator": "^3.2.0",
     "extend-error": "0.0.2",
     "http-status-codes": "^1.1.6",
     "jsonwebtoken": "^7.4.0",
     "mongoose": "^6.1.1",
     "passport": "^0.3.2",
     "passport-jwt": "^2.2.1",
     "passport-local": "latest",
     "passport-strategy": "latest",
     "should": "^11.2.1",
     "validator": "^7.0.0"
     }
    

    The above code lists all required dependencies to apply distributing tracing for Node.js services such as passport to manage authentication or express to create the web server.

    Run the following command to install all dependencies:

     $ npm install
    
  5. Deploy Mongo database and Jaeger service

    Create a new Docker Compose file to define the Mongo database and Jaeger service that you want to deploy:

     nano docker-compose.yml
    

    Add the following content to the docker-compose.yml file:

     version: "3.4"
     services:
       jaeger:
         container_name: jaeger
         image: jaegertracing/all-in-one:latest
         ports:
         - 16686:16686
         - 14269:14269
         - 4317:4317
         - 4318:4318
         - 14268:14268
    
       mongodb:
         image: mongo
         ports:
         - '27017:27017'
         environment:
           MONGO_INITDB_DATABASE: tracing
         volumes:
         - db:/data/db
    
     volumes:
       db:
    

    The above code defines new services named jaeger and mongodb. These two services use the their latest Docker images from DockerHub and apply port mapping so that you can access them from outside their containers. For the MongoDB to have the persistent data, the mongodb service uses Docker volume with path mapping is db:/data/db.

    Run the following command to deploy the Mongo database and Jaeger service:

     $ docker compose up -d
    

Create the Configuration Files

After you have initialized the project code, in this section, you create the code defining common configuration of your app.

  1. Create a config directory from the root of the project

     $ mkdir config
    

    Navigate to the config directory:

     $ cd config
    
  2. Create a new file named db.js

     $ nano db.js
    

    Add the following content to the db.js file then save it:

     module.exports = {
         MONGO_CONNECT_URL:"mongodb://localhost:27017/tracing?retryWrites=true&w=majority"
     };
    

    The db.js file is for defining connection string so that your application can connect to the Mongo database.

  3. Create a new file named global-paths.js:

     $ nano global-paths.js
    

    Add the following content to the global-paths.js then save it:

     global.APP_MODEL_PATH = APP_ROOT_PATH + 'model/';
     global.APP_AUTH_STRATEGY = APP_ROOT_PATH + 'authstrategy/';
     global.APP_CONTROLLER_PATH = APP_ROOT_PATH + 'controller/';
     global.APP_HANDLER_PATH = APP_ROOT_PATH + 'handler/';
     global.APP_SERVICE_PATH = APP_ROOT_PATH + 'service/';
     global.APP_BASE_PACKAGE_PATH = APP_ROOT_PATH + 'base/';
     global.APP_ERROR_PATH = APP_ROOT_PATH + 'error/';
     global.APP_MANAGER_PATH = APP_ROOT_PATH + 'manager/';
     global.APP_MIDDLEWARE_PATH = APP_ROOT_PATH + 'middleware/';
     global.APP_ROUTE_PATH = APP_ROOT_PATH + 'route/';
     global.CONFIG_BASE_PATH = __dirname + '/';
    

    The global-paths.js file is for defining the variables that stores the directory path to the packages in your project.

  4. Create a new file named index.js:

     $ nano index.js
    

    Add the following content to the index.js file and save it:

       module.exports = {
         db: require('./db'),
         server: require('./server'),
         jwtOptions: require('./jwt-options')
     };
    

    The index.js file exports the object that contains the codes to import packages so that you can conveniently use it later on.

  5. Create a new file named jwt-options.js

     $ nano jwt-options.js
    

    Add the following content to it:

     module.exports = {
         issuer: "api@example.net",
         audience: "api.blog.example.net",
         algorithm: "RS256"
     };
    
  6. Create a new file named server.js

     $ nano server.js
    

    Add the following content to it:

     module.exports = {
         PORT: process.env.PORT || 3000
     };
    

    The server.js file stores the configuration for the service port. Each service uses its own the PORT environment variable. If the environment variable is not set, the service uses the PORT 3000 instead.

  7. Create a new directory named secret inside the config directory

     $ mkdir secret
    

    Navigate to the secret directory:

     $ cd secret
    

    The secret directory stores the SSH private key, and the SSH public key to generate a secured JWT token.

    Run the following command to generate an RSA private key file without passphrase:

     $ openssl genrsa -out jwt-key.pem 2048
    

    Generate the public key file by running the following command:

     $ openssl rsa -in jwt-key.pem -pubout -outform PEM -out jwt-key.pub
    

    You also need a secret key for authentication to create every new user.

    Create a new file named secret.key:

     nano secret.key
    

    Add the following content to the secret.key file. You should not have any space or new line in the file. Otherwise the secret key will not be valid when you use it in the authorization header in the APIs.

     example
    

    The value of the secret key for creating new user is example. You will use this value when making curl request to send a new request to the API creating new user.

  8. Create a new file named ecosystem.config.js

    Navigate to the root project:

     cd $PROJECT_DIRECTORY
    

    Create a new file named ecosystem.config.js:

     nano ecosystem.config.js
    

    Add the following content to it:

     module.exports = {
     apps : [
         {
         name: 'authenSvc',
         script: 'authenSvc.js',
         instances: 1,
         autorestart: true,
         watch: true,
         max_memory_restart: '512M',
         env: {
             SERVICE_NAME: 'authenSvc',
             NODE_ENV: 'development',
             PORT: 3001
         }
         },
         {
         name: 'userSvc',
         script: 'userSvc.js',
         instances: 1,
         autorestart: true,
         watch: true,
         max_memory_restart: '512M',
         env: {
             SERVICE_NAME: 'userSvc',
             NODE_ENV: 'development',
             PORT: 3002
         }
         },
         {
         name: 'blogSvc',
         script: 'blogSvc.js',
         instances: 1,
         autorestart: true,
         watch: true,
         max_memory_restart: '512M',
         env: {
             SERVICE_NAME: 'blogSvc',
             NODE_ENV: 'development',
             PORT: 3003
         }
         },
         {
         name: 'authorSvc',
         script: 'authorSvc.js',
         instances: 1,
         autorestart: true,
         watch: true,
         max_memory_restart: '512M',
         env: {
             SERVICE_NAME: 'authorSvc',
             NODE_ENV: 'development',
             PORT: 3004
         }
         }
     ]
     };
    

    The ecosystem.config.js defines the configuration such as PORT number or max_memory_restart for the authentication, authorization, user management, and blog services. The pm2 tool uses this information to deploy the services.

Create the Database Models

To insert data into the MongoDB database, you need to create the data model objects to interact with the db.

  1. Create a new app directory

     $ mkdir app
    
  2. The model directory

    Navigate to the app directory

     $ cd app
    

    Create the model directory

     $ mkdir model
    
  3. Create a post.js file inside the model directory

    Navigate to the model directory

     $ cd model
    

    Run the following command to create the post.js file:

     $ nano post.js
    

    Add the following content to the post.js file:

     const mongoose = require('mongoose');
     mongoose.set("strictQuery", true);
     const Schema = mongoose.Schema;
     const ObjectId = Schema.ObjectId;
     let BlogPostSchema = new Schema({
         title: String,
         content: String,
         authorId: {
             type: ObjectId,
             required: true
         },
         dateCreated: {type: Date, default: Date.now},
         dateModified: {type: Date, default: Date.now},
     });
     BlogPostSchema.pre('update', function (next, done) {
         this.dateModified = Date.now();
         next();
     });
     BlogPostSchema.pre('save', function (next, done) {
         this.dateModified = Date.now();
         next();
     });
     BlogPostSchema.methods.toJSON = function () {
         let obj = this.toObject();
         delete obj.__v;
         return obj
     };
     module.exports.BlogPostModel = mongoose.model('Post', BlogPostSchema);
    

    The post.js file defines the data model to insert and update blog posts into the MongoDB database.

  4. Create a user.js file

     $ nano user.js
    

    Add the following content to the user.js file:

     let mongoose = require('mongoose');
     let Schema = mongoose.Schema;
     const crypto = require('crypto');
     let UserSchema = new Schema({
         firstName: String,
         lastName: String,
         salt: {
             type: String,
             required: true
         },
         isActive: {type: Boolean, default: true},
         dateCreated: {type: Date, default: Date.now},
         role:{type:String,
         required: true},
         email: String,
         hashedPassword: {
             type: String,
             required: true,
         },
     });
     UserSchema.methods.toJSON = function () {
         let obj = this.toObject();
         delete obj.hashedPassword;
         delete obj.__v;
         delete obj.salt;
         return obj
     };
    
     UserSchema.virtual('id')
         .get(function () {
             return this._id;
         });
     UserSchema.virtual('password')
         .set(function (password) {
             this.salt = crypto.randomBytes(32).toString('base64');
             this.hashedPassword = this.encryptPassword(password, this.salt);
         })
         .get(function () {
             return this.hashedPassword;
         });
    
     UserSchema.methods.encryptPassword = function (password, salt) {
         return crypto.createHmac('sha1', salt).update(password).digest('hex');
     };
     UserSchema.methods.checkPassword = function (password) {
         return this.encryptPassword(password, this.salt) === this.hashedPassword;
     };
     module.exports.UserModel = mongoose.model('User', UserSchema);
    

    The user.js file defines the data model to create and retrieve user data from the MongoDB database.

  5. The auth directory

    Create the auth directory

     $ mkdir auth
    

    Navigate to it

     $ cd auth
    

    The auth directory creates the data model for creating and revoking the JWT token.

    Run the following command to create a new jwt-token.js file inside the auth directory:

     $ nano jwt-token.js
    

    Add the following content to the jwt-token.js file:

     class JwtToken {
         constructor(token) {
             this.token = token;
         }
     }
     module.exports = JwtToken;
    

    The above code defines the JwtToken class to initialize the Jwt token.

    Run the following command to create a new file named revoked-token.js inside the auth directory:

     $ nano revoked-token.js
    

    Add the following content to the revoked-token.js file:

     let mongoose = require('mongoose');
     let Schema = mongoose.Schema;
    
     let RevokedTokenScheme = new Schema({
         token: String,
         date: {type: Date, default: Date.now}
     });
     module.exports.RevokedTokenModel = mongoose.model('RevokedToken', RevokedTokenScheme);
    

    The above code defines the RevokedTokenSchema model to revoke the token when the token is expired.

Create the Manager Functions

In this section, you create the manager functions for authenticating, creating API responses, and validating API format.

  1. Create a auth.js file

    Navigate to the app directory

     $ cd $PROJECT_DIRECTORY/app
    

    Create a new directory named manager

     $ mkdir manager
    

    Navigate to the manager directory:

     $ cd manager
    

    Run the following command to create a new file named auth.js:

     $ nano auth.js
    

    Add the following content to it:

     const BaseAutoBindedClass = require(APP_BASE_PACKAGE_PATH + 'base-autobind');
     const JwtRsStrategy = require(APP_AUTH_STRATEGY + 'jwt-rs');
     const SecretKeyAuth = require(APP_AUTH_STRATEGY + 'secret-key');
     const CredentialsAuth = require(APP_AUTH_STRATEGY + 'credentials');
     const ExtractJwt = require("passport-jwt").ExtractJwt;
     const JwtToken = require(APP_MODEL_PATH + 'auth/jwt-token');
     const RevokedToken = require(APP_MODEL_PATH + 'auth/revoked-token').RevokedTokenModel;
     const ForbiddenError = require(APP_ERROR_PATH + 'forbidden');
    
     class AuthManager extends BaseAutoBindedClass {
         constructor() {
             super();
             this._passport = require('passport');
             this._strategies = [];
             this._jwtTokenHandler = require('jsonwebtoken');
             this._setupStrategies();
             this._setPassportStrategies();
         }
    
         _setupStrategies() {
             // Init JWT strategy
             let jwtOptions = this._provideJwtOptions();
             let secretKeyAuth = new SecretKeyAuth({secretKey: this._provideSecretKey()});
             let jwtRs = new JwtRsStrategy(jwtOptions, this._verifyRevokedToken);
             this._strategies.push(jwtRs);
             this._strategies.push(new CredentialsAuth());
             this._strategies.push(secretKeyAuth);
         }
    
         _verifyRevokedToken(token, payload, callback) {
             RevokedToken.find({token: token}, function (err, docs) {
                 if (docs.length) {
                     callback.onFailure(new ForbiddenError("Token has been revoked"));
                 } else {
                     callback.onVerified(token, payload);
                 }
             });
         }
    
         extractJwtToken(req) {
             return ExtractJwt.fromAuthHeader()(req);
         }
    
         _provideJwtOptions() {
             let config = global.config;
             let jwtOptions = {};
             jwtOptions.extractJwtToken = ExtractJwt.fromAuthHeader();
             jwtOptions.privateKey = this._provideJwtPrivateKey();
             jwtOptions.publicKey = this._provideJwtPublicKey();
             jwtOptions.issuer = config.jwtOptions.issuer;
             jwtOptions.audience = config.jwtOptions.audience;
             return jwtOptions;
         }
    
         _provideJwtPublicKey() {
             const fs = require('fs');
             return fs.readFileSync(CONFIG_BASE_PATH + 'secret/jwt-key.pub', 'utf8');
         }
    
         _provideJwtPrivateKey() {
             const fs = require('fs');
             return fs.readFileSync(CONFIG_BASE_PATH + 'secret/jwt-key.pem', 'utf8');
         }
    
         _provideSecretKey() {
             const fs = require('fs');
             return fs.readFileSync(CONFIG_BASE_PATH + 'secret/secret.key', 'utf8');
         }
    
         providePassport() {
             return this._passport;
         }
    
         getSecretKeyForStrategy(name) {
             for (let i = 0; i < this._strategies.length; i++) {
                 let strategy = this._strategies[i];
                 if (strategy && strategy.name === name) {
                     return strategy.provideSecretKey();
                 }
             }
         }
    
         _setPassportStrategies() {
             let passport = this._passport;
             this._strategies.forEach(function (strategy) {
                 passport.use(strategy);
             });
         }
    
         signToken(strategyName, payload, options) {
             let key = this.getSecretKeyForStrategy(strategyName);
             switch (strategyName) {
                 case 'jwt-rs-auth':
                     return new JwtToken(
                         this._jwtTokenHandler.sign(payload,
                             key,
                             options)
                     );
                 default:
                     throw new TypeError("Cannot sign toke for the " + strategyName + " strategy");
             }
         }
     }
     exports = module.exports = new AuthManager();
    

    The auth.js file gives basic functions for authenticating tasks such as extractJwtToken or getSecretKeyForStrategy.

  2. Create a new file named response.js

     $ nano response.js
    

    Add the following content to it:

     const HttpStatus = require('http-status-codes');
     const BasicResponse = {
         "success": false,
         "message": "",
         "data": {}
     };
    
     class ResponseManager {
         constructor() {
    
         }
    
         static get HTTP_STATUS() {
             return HttpStatus;
         }
    
         static  getDefaultResponseHandler(res) {
             return {
                 onSuccess: function (data, message, code) {
                     ResponseManager.respondWithSuccess(res, code || ResponseManager.HTTP_STATUS.OK, data, message);
                 },
                 onError: function (error) {
                     ResponseManager.respondWithError(res, error.status || 500, error.message || 'Unknown error');
                 }
             };
         }
    
         static  getDefaultResponseHandlerData(res) {
             return {
                 onSuccess: function (data, message, code) {
                     ResponseManager.respondWithSuccess(res, code || ResponseManager.HTTP_STATUS.OK, data, message);
                 },
                 onError: function (error) {
                     ResponseManager.respondWithErrorData(res, error.status, error.message, error.data);
                 }
             };
         }
    
         static  getDefaultResponseHandlerError(res, successCallback) {
             return {
                 onSuccess: function (data, message, code) {
                     successCallback(data, message, code);
                 },
                 onError: function (error) {
                     ResponseManager.respondWithError(res, error.status || 500, error.message || 'Unknown error');
                 }
             };
         }
    
         static  getDefaultResponseHandlerSuccess(res, errorCallback) {
             return {
                 onSuccess: function (data, message, code) {
                     ResponseManager.respondWithSuccess(res, code || ResponseManager.HTTP_STATUS.OK, data, message);
                 },
                 onError: function (error) {
                     errorCallback(error);
                 }
             };
         }
    
         static generateHATEOASLink(link, method, rel) {
             return {
                 link: link,
                 method: method,
                 rel: rel
             }
         }
    
         static respondWithSuccess(res, code, data, message = "", links = []) {
             let response = Object.assign({}, BasicResponse);
             response.success = true;
             response.message = message;
             response.data = data;
             response.links = links;
             res.status(code).json(response);
         }
    
         static respondWithErrorData(res, errorCode, message = "", data = "", links = []) {
             let response = Object.assign({}, BasicResponse);
             response.success = false;
             response.message = message;
             response.data = data;
             response.links = links;
             res.status(errorCode).json(response);
         }
    
         static respondWithError(res, errorCode, message = "", links = []) {
             let response = Object.assign({}, BasicResponse);
             response.success = false;
             response.message = message;
             response.links = links;
             res.status(errorCode).json(response);
         }
    
     }
     module.exports = ResponseManager;
    

    The response.js file creates functions to set up default handling responses for the APIs such as respondWithSuccess or responseWithErrorData.

  3. Create a new file named validation.js

     $ nano validation.js
    

    Add the following content to it:

     const BaseAutoBindedClass = require(APP_BASE_PACKAGE_PATH + 'base-autobind');
     const expressValidator = require('express-validator');
     class ValidationManager extends BaseAutoBindedClass {
         constructor() {
             super();
         }
    
             provideDefaultValidator() {
             return expressValidator({
                 errorFormatter: ValidationManager.errorFormatter
             })
         }
    
         static errorFormatter(param, msg, value) {
             let namespace = param.split('.'),
                 root = namespace.shift(),
                 formParam = root;
    
             while (namespace.length) {
                 formParam += '[' + namespace.shift() + ']';
             }
             return {
                 param: formParam,
                 msg: msg,
                 value: value
             };
         }
     }
     module.exports = ValidationManager;
    

    The validation.js file creates functions that handle default validation for the API.

Create the Error Handling Functions

When working with APIs, error handling mechanism helps to give meaningful message to the clients so that they can understand the reason why the API request is failed. In this section, you create error handling functions to do that.

  1. The err directory

    Navigate to the app directory

     $ cd ..
    

    Create the err directory

     $ mkdir error
    

    The error directory stores the error handling files.

  2. Create a new file named already-exists.js

    Navigate to the error directory

     $ cd error
    

    Create the already-exists.js file

     $ nano already-exists.js
    

    Add the following content to the already-exists.js file then save it:

     'use strict';
     const BaseError = require(APP_ERROR_PATH + 'base');
    
     class AlreadyExistsError extends BaseError {
         constructor(message) {
             super(message, 409);
         }
     }
    
     module.exports = AlreadyExistsError;
    

    The already-exist.js file creates an error message and status code when the clients create a new user with the existing email.

  3. Create a new file named base.js

     $ nano base.js
    

    Add the following content to the base.js file then save it:

     'use strict';
     class BaseError extends Error {
         constructor(message, status) {
    
             // Calling parent constructor of base Error class.
             super(message);
    
             // Capturing stack trace, excluding constructor call from it.
             Error.captureStackTrace(this, this.constructor);
    
             // Saving class getName in the property of our custom error as a shortcut.
             this.name = this.constructor.name;
    
             // You can use any additional properties you want.
             // I'm going to use preferred HTTP status for this error types.
             // `500` is the default value if not specified.
             this.status = status || 500;
    
         }
     }
    
     module.exports = BaseError;
    

    The base.js file creates the default API response with unknown error.

  4. Create a new file named forbidden.js

     $ nano forbidden.js
    

    Add the following content to the forbiden.js file then save it:

     'use strict';
     const BaseError = require(APP_ERROR_PATH + 'base');
    
     class ForbiddenError extends BaseError {
         constructor(message) {
             super(message, 403);
         }
     }
    
     module.exports = ForbiddenError;
    

    The forbidden.js file helps to show foribidden error when the clients do not have permission to do certain tasks.

  5. Create a new file named invalid-payload.js

     $ nano invalid-payload.js
    

    Add the following content to the invalid-payload.js file then save it:

     'use strict';
     const BaseError = require(APP_ERROR_PATH + 'base');
    
     class InvalidPayloadError extends BaseError {
         constructor(message) {
             super(message, 400);
         }
     }
    
     module.exports = InvalidPayloadError;
    

    The invalid-payload.js file shows an error to the clients when they send the payload that is invalid.

  6. Create a new file named not-found.js

     $ nano not-found.js
    

    Add the following content to the not-found.js file then save it:

     'use strict';
     const BaseError = require(APP_ERROR_PATH + 'base');
    
     class NotFoundError extends BaseError {
         constructor(message) {
             super(message, 404);
         }
     }
    
     module.exports = NotFoundError;
    

    The not-found.js file shows error to the clients when the URL path they input does not exist.

  7. Create a new file named unauthorized.js

     $ nano unauthorized.js
    

    Add the following content to the unauthorized.js then save it:

     'use strict';
     const BaseError = require(APP_ERROR_PATH + 'base');
    
     class UnauthorizedError extends BaseError {
         constructor(message) {
             super(message, 401);
         }
     }
    
     module.exports = UnauthorizedError;
    

    The unauthorized.js file shows an error to the clients when the token is invalid.

  8. Create a new file named validation.js

     $ nano validation.js
    

    Add the following content to the validation.js file then save it:

     'use strict';
     const BaseError = require(APP_ERROR_PATH + 'base');
    
     class ValidationError extends BaseError {
         constructor(message) {
             super(message, 400);
         }
     }
    
     module.exports = ValidationError;
    

    The validation.js file checks if there is any invalid information in the API request.

Create the Base Class

  1. Create a new directory called base inside the app directory

    Navigate to the app directory

     $ cd ..
    

    Create the base directory

     $ mkdir base
    
  2. Create a new file named base-autobind.js

    Navigate to the base directory

     $ cd base
    

    Create the base-autobind.js file

     $ nano base-autobind.js
    

    Add the following content to it:

     const autoBind = require('auto-bind');
    
     class BaseAutoBindedClass {
         constructor() {
             autoBind(this);
         }
     }
     module.exports = BaseAutoBindedClass;
    

    The base-autobind.js class creates auto binding class that other classes can inherit from it. Auto-binding allows you to create a new function using existing function or change the function context.

Jaeger Tracing

The OpenTelemetry library gives the SDK that supports automatically tracing for Node.js services. To trace which requests have been made inside an API request, you need to register the instrumentation type for the requests.

For example, you can trace for the HTTP requests, Express requests, and Mongo database requests by creating the NodeSDK variable instance with definition for instrumentations as [getNodeAutoInstrumentations()] like the one below:

    const sdk = new NodeSDK({
    resource: new Resource({
        [SemanticResourceAttributes.SERVICE_NAME]: `${process.env.SERVICE_NAME}`,
    }),
    instrumentations: [ getNodeAutoInstrumentations()],
    spanProcessor: new BatchSpanProcessor(traceExporter),
    });
  1. Navigate to the root directory of the project

     $ cd $PROJECT_DIRECTORY
    
  2. Create a new file named tracing.js

     $ nano tracing.js
    
  3. Add the following content to the tracing.js file

     const { OTLPTraceExporter } = require( '@opentelemetry/exporter-trace-otlp-http');
     const { Resource } = require('@opentelemetry/resources');
     const { BatchSpanProcessor } = require( '@opentelemetry/sdk-trace-base');
     const { NodeSDK } = require('@opentelemetry/sdk-node');
     const { SemanticResourceAttributes } = require ('@opentelemetry/semantic-conventions');
     const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');
    
     const traceExporter = new OTLPTraceExporter({
     url: 'http://localhost:4318/v1/traces',
     });
    
     const sdk = new NodeSDK({
     resource: new Resource({
         [SemanticResourceAttributes.SERVICE_NAME]: `${process.env.SERVICE_NAME}`,
     }),
     instrumentations: [ getNodeAutoInstrumentations()],
     spanProcessor: new BatchSpanProcessor(traceExporter),
     });
    
     (async () => {
     try {
         await sdk.start();
         console.log('Tracing started.');
     } catch (error) {
         console.error(error);
     }
     })();
    
     // For local development to stop the tracing using Control+c
     process.on('SIGINT', async () => {
     try {
         await sdk.shutdown();
         console.log('Tracing finished.');
     } catch (error) {
         console.error(error);
     } finally {
         process.exit(0);
     }
     });
    

    In the above code, you tell opentelemetry to trace HTTP requests, Express requests and MongoDB requests and send the traces to the Jaeger backend service.

Create the Authen Methods

The Authen method creates methods to apply authentication mechanisms for the users.

  1. The authstrategy directory

    Navigate to the app directory

     $ cd app
    

    Create the authstrategy directory

     $ mkdir authstrategy
    
  2. Navigate to the authstrategy directory

     $ cd authstrategy
    
  3. Create a new file named base-auth.js

     $ nano base-auth.js
    

    As the name suggests, the base-auth.js creates base methods for applying authentication.

    Add the following content to the base-auth.js file and save it:

     const BasePassportStrategy = require('passport-strategy');
    
     class BaseAuthStrategy extends BasePassportStrategy {
         constructor() {
             super();
         }
    
         _initStrategy() {
             throw new Error("Not Implemented");
         }
    
         authenticate(req) {
             throw new Error("Not Implemented");
         }
    
         authenticate(req, options) {
             throw new Error("Not Implemented");
         }
    
         get name() {
             throw new Error("Not Implemented");
         }
    
         provideOptions() {
             throw new Error("Not Implemented");
         }
    
         provideSecretKey() {
             throw new Error("Not Implemented");
         }
     }
    
     exports = module.exports = BaseAuthStrategy;
    

    The above code defines prototypes for methods such as provideSecretKey or provideOptions.

  4. Create a new file named credentials.js

     $ nano credentials.js
    

    Add the following content to the credentials.js file then save it:

     const LocalAuthStrategy = require('passport-local').Strategy;
     const UserModel = require(APP_MODEL_PATH + 'user').UserModel;
     const UnauthorizedError = require(APP_ERROR_PATH + 'unauthorized');
     const NotFoundError = require(APP_ERROR_PATH + 'not-found');
    
     class CredentialsAuthStrategy extends LocalAuthStrategy {
         constructor() {
             super(CredentialsAuthStrategy.provideOptions(), CredentialsAuthStrategy.handleUserAuth);
         }
    
         get name() {
             return 'credentials-auth';
         }
    
         static handleUserAuth(username, password, done) {
             UserModel.findOne({email: username}, function (err, user) {
                 if (err) {
                     return done(err);
                 }
                 if (!user) {
                     return done(new NotFoundError("User not found"), false);
                 }
                 if (!user.checkPassword(password)) {
                     return done(new UnauthorizedError("Invalid credentials"), false);
                 }
                 return done(null, user);
             });
         }
    
         static provideOptions() {
             return {
                 usernameField: 'email',
                 passReqToCallback: false,
                 passwordField: 'password',
                 session: false
             };
         }
    
         getSecretKey() {
             throw new Error("No key is required for this type of auth");
         }
     }
     exports = module.exports = CredentialsAuthStrategy;
    

    The credentials.js defines methods that validate the user credentials.

  5. Create a new file named jwt-rs.js

     $ nano jwt-rs.js
    

    Add the following content to the jwt-rs.js file then save it:

     const passport = require('passport-strategy')
         , jwt = require('jsonwebtoken');
    
     const BaseAuthStrategy = require(APP_AUTH_STRATEGY + 'base-auth');
    
     class JwtRsStrategy extends BaseAuthStrategy {
         constructor(options, verify) {
             super();
             this._options = options;
             this._customVerifier = verify;
             this._initStrategy();
         }
    
         _initStrategy() {
             passport.Strategy.call(this);
             let options = this.provideOptions();
    
             if (!options) {
                 throw new TypeError('JwtRsStrategy requires options');
             }
             this._privateKey = options.privateKey;
             if (!this._privateKey) {
                 throw new TypeError('JwtRsStrategy requires a private key');
             }
             this._publicKey = options.publicKey;
             if (!this._publicKey) {
                 throw new TypeError('JwtRsStrategy requires a public key');
             }
    
             this._extractJwtToken = options.extractJwtToken;
             if (!this._extractJwtToken) {
                 throw new TypeError('JwtRsStrategy requires a function to parse jwt from requests');
             }
             this._verifyOpts = {};
    
             if (options.issuer) {
                 this._verifyOpts.issuer = options.issuer;
             }
    
             if (options.audience) {
                 this._verifyOpts.audience = options.audience;
             }
    
             if (options.algorithms) {
                 this._verifyOpts.algorithms = options.algorithms;
             }
    
             if (options.ignoreExpiration != null) {
                 this._verifyOpts.ignoreExpiration = options.ignoreExpiration;
             }
         }
    
         get name() {
             return 'jwt-rs-auth';
         }
    
         provideSecretKey() {
             return this._privateKey;
         }
    
         authenticate(req, callback) {
             let self = this;
    
             let token = self._extractJwtToken(req);
    
             if (!token) {
                 return callback.onFailure(new Error("No auth token provided"));
             }
             // Verify the JWT
             JwtRsStrategy._verifyDefault(token, this._publicKey, this._verifyOpts, function (jwt_err, payload) {
                 if (jwt_err) {
                     return callback.onFailure(jwt_err);
                 } else {
                     try {
                         // If custom verifier was set delegate the flow control
                         if (self._customVerifier) {
                             self._customVerifier(token, payload, callback);
                         }
                         else {
                             callback.onVerified(token, payload);
                         }
                     } catch (ex) {
                         callback.onFailure(ex);
                     }
                 }
             });
         }
    
    
         provideOptions() {
             return this._options;
         }
    
         static _verifyDefault(token, publicKey, options, callback) {
             return jwt.verify(token, publicKey, options, callback);
         }
     }
    
     module.exports = JwtRsStrategy;
    

    The jwt-rs.js creates methods that verify the user provided token.

  6. Create a new file named secret-key.js

     $ nano secret-key.js
    

    Add the following content to the secret-key.js file then save it:

     const BaseAuthStrategy = require(APP_AUTH_STRATEGY + 'base-auth');
     const InvalidPayloadError = require(APP_ERROR_PATH + 'invalid-payload');
     const UnauthorizedError = require(APP_ERROR_PATH + 'unauthorized');
    
     class SecretKeyAuthStrategy extends BaseAuthStrategy {
         constructor(options) {
             super();
             this._options = options;
             this._initStrategy();
         }
    
         static get AUTH_HEADER() {
             return "Authorization";
         }
    
         _initStrategy() {
    
         }
    
         get name() {
             return 'secret-key-auth';
         }
    
         static _extractKeyFromHeader(req) {
             return req.headers[SecretKeyAuthStrategy.AUTH_HEADER.toLowerCase()];
         }
    
         _verifyCredentials(key) {
             return key === this.provideSecretKey();
         }
    
         authenticate(req, callback) {
             let secretKey = SecretKeyAuthStrategy._extractKeyFromHeader(req);
             if (!secretKey) {
                 return callback.onFailure(new InvalidPayloadError("No auth key provided"));
             }
             if (this._verifyCredentials(secretKey)) {
                 return callback.onVerified();
             } else {
                 return callback.onFailure(new UnauthorizedError("Invalid secret key"));
             }
         }
    
         provideSecretKey() {
             return this._options.secretKey;
         }
    
         provideOptions() {
             return this._options;
         }
     }
    
     module.exports = SecretKeyAuthStrategy;
    

    The secret-key.js file creates methods to verify the secret key of the user.

Create the Handler Methods

In this section, you create the code that implements the handler methods

  1. The handler directory

    Navigate to the app directory

     $ cd ..
    

    Create the handler directory

     $ mkdir handler
    

    The handler directory stores the functions that handle the business logic of the application service.

  2. Navigate to the handler directory

     $ cd handler
    
  3. Create a new file named user.js

     $ nano user.js
    

    The user.js file defines the createNewUser method which will be used by the API for creating new users.

  4. Add the following content to the user.js then save it

     const UserModel = require(APP_MODEL_PATH + 'user').UserModel;
     const AlreadyExistsError = require(APP_ERROR_PATH + 'already-exists');
     const ValidationError = require(APP_ERROR_PATH + 'validation');
    
     class UserHandler {
         constructor() {
             this._validator = require('validator');
         }
    
         static get USER_VALIDATION_SCHEME() {
             return {
                 'firstName': {
                     notEmpty: true,
                     isLength: {
                         options: [{min: 2, max: 15}],
                         errorMessage: 'First getName must be between 2 and 15 chars long'
                     },
                     errorMessage: 'Invalid First Name'
                 },
                 'lastName': {
                     notEmpty: true,
                     isLength: {
                         options: [{min: 2, max: 15}],
                         errorMessage: 'Lastname must be between 2 and 15 chars long'
                     },
                     errorMessage: 'Invalid First Name'
                 },
                 'email': {
                     isEmail: {
                         errorMessage: 'Invalid Email'
                     },
                     errorMessage: "Invalid email provided"
                 },
                 'password': {
                     notEmpty: true,
                     isLength: {
                         options: [{min: 6, max: 35}],
                         errorMessage: 'Password must be between 6 and 35 chars long'
                     },
                     errorMessage: 'Invalid Password Format'
                 }
    
             };
         }
    
         createNewUser(req, callback) {
             let data = req.body;
             let validator = this._validator;
             req.checkBody(UserHandler.USER_VALIDATION_SCHEME);
             req.getValidationResult()
                 .then(function (result) {
                     if (!result.isEmpty()) {
                         let errorMessages = result.array().map(function (elem) {
                             return elem.msg;
                         });
                         throw new ValidationError('There are validation errors: ' + errorMessages.join(' && '));
                     }
                     return new UserModel({
                         firstName: validator.trim(data.firstName),
                         lastName: validator.trim(data.lastName),
                         email: validator.trim(data.email),
                         password: validator.trim(data.password),
                         role:validator.trim(data.role)
                     });
                 })
                 .then((user) => {
                     return new Promise(function (resolve, reject) {
                         UserModel.find({email: user.email}, function (err, docs) {
                             if (docs.length) {
                                 reject(new AlreadyExistsError("User already exists"));
                             } else {
                                 resolve(user);
                             }
                         });
                     });
                 })
                 .then((user) => {
                     user.save();
                     return user;
                 })
                 .then((saved) => {
                     callback.onSuccess(saved);
                 })
                 .catch((error) => {
                     callback.onError(error);
                 });
         }
    
     }
    
     module.exports = UserHandler;
    

    In the above code, you implement logic for creating new user, such as validating the user email, the user firstName or lastName or checking if the user is existed or not.

  5. Create a new file named post.js

     $ nano post.js
    

    The post.js file creates methods to create new blog posts and get all blogs.

    Add the following content to the post.js file then save it:

     const BlogPostModel = require(APP_MODEL_PATH + 'post').BlogPostModel;
     const ValidationError = require(APP_ERROR_PATH + 'validation');
     const NotFoundError = require(APP_ERROR_PATH + 'not-found');
     const BaseAutoBindedClass = require(APP_BASE_PACKAGE_PATH + 'base-autobind');
    
     class BlogPostHandler extends BaseAutoBindedClass {
         constructor() {
             super();
             this._validator = require('validator');
         }
    
         static get BLOG_POST_VALIDATION_SCHEME() {
             return {
                 'title': {
                     notEmpty: true,
                     isLength: {
                         options: [{min: 10, max: 150}],
                         errorMessage: 'Post title must be between 2 and 150 chars long'
                     },
                     errorMessage: 'Invalid post title'
                 },
                 'content': {
                     notEmpty: true,
                     isLength: {
                         options: [{min: 50, max: 3000}],
                         errorMessage: 'Post content must be between 150 and 3000 chars long'
                     },
                     errorMessage: 'Invalid post content'
                 },
                 'authorId': {
                     isMongoId: {
                         errorMessage: 'Invalid Author Id'
                     },
                     errorMessage: "Invalid email provided"
                 }
             };
         }
    
         createNewPost(req, callback) {
             let data = req.body;
             let validator = this._validator;
             req.checkBody(BlogPostHandler.BLOG_POST_VALIDATION_SCHEME);
             req.getValidationResult()
                 .then(function (result) {
                     if (!result.isEmpty()) {
                         let errorMessages = result.array().map(function (elem) {
                             return elem.msg;
                         });
                         throw new ValidationError('There are validation errors: ' + errorMessages.join(' && '));
                     }
                     return new BlogPostModel({
                         title: validator.trim(data.title),
                         content: validator.trim(data.content),
                         authorId: data.authorId,
                     });
                 })
                 .then((user) => {
                     user.save();
                     return user;
                 })
                 .then((saved) => {
                     callback.onSuccess(saved);
                 })
                 .catch((error) => {
                     callback.onError(error);
                 });
         }
    
         getAllPosts(req, callback) {
             let data = req.body;
             new Promise(function (resolve, reject) {
                 BlogPostModel.find({}, function (err, posts) {
                     if (err !== null) {
                         reject(err);
                     } else {
                         resolve(posts);
                     }
                 });
             })
                 .then((posts) => {
                     callback.onSuccess(posts);
                 })
                 .catch((error) => {
                     callback.onError(error);
                 });
         }
     }
    
     module.exports = BlogPostHandler;
    

    In the above code, you implement the logic to check the validity of the post before saving it to the MongoDB database and retrieving all the posts when user request it.

  6. Create a new file named auth.js

     $ nano auth.js
    

    Add the following content to the auth.js file then save it:

     const RevokedToken = require(APP_MODEL_PATH + 'auth/revoked-token').RevokedTokenModel;
     const NotFoundError = require(APP_ERROR_PATH + 'invalid-payload');
     const BaseAutoBindedClass = require(APP_BASE_PACKAGE_PATH + 'base-autobind');
     let crypto = require('crypto');
     const SHA_HASH_LENGTH = 64;
     const ForbiddenError = require(APP_ERROR_PATH + 'forbidden');
    
     class AuthHandler extends BaseAutoBindedClass {
         constructor() {
             super();
             this._jwtTokenHandler = require('jsonwebtoken');
             this._authManager = require(APP_MANAGER_PATH + 'auth');
         }
    
         issueNewToken(req, user, callback) {
             let that = this;
             if (user) {
                 let userToken = that._authManager.signToken("jwt-rs-auth", that._provideTokenPayload(user), that._provideTokenOptions());
                 callback.onSuccess(userToken);
             }
             else {
                 callback.onError(new NotFoundError("User not found"));
             }
         }
    
         _hashToken(token) {
             return crypto.createHash('sha256').update(token).digest('hex');
         }
    
         checkIfHashedTokenMatches(token, hashed) {
             let hashedValid = this._hashToken(token);
             return hashedValid === hashed;
         }
    
    
         _provideTokenPayload(user) {
             return {
                 id: user.id,
                 scope: 'default',
                 role: user.role
             };
         }
    
         _provideTokenOptions() {
             let config = global.config;
             return {
                 expiresIn: "10 days",
                 audience: config.jwtOptions.audience,
                 issuer: config.jwtOptions.issuer,
                 algorithm: config.jwtOptions.algorithm
             };
         }
    
    
     }
    
     module.exports = AuthHandler;
    

    The auth.js creates the issueNewToken method to create a new token for a logged in user.

Create the Controller Methods

In this section, you create the controller methods for every API. The controller methods will read the user input from the API request, then creates the response for the API request.

  1. The controller directory

    Navigate to the app directory

     $ cd ..
    

    Create the controller directory

     $ mkdir controller
    
  2. Navigate to the controller directory

     $ cd controller
    
  3. Create a new file named auth.js

     $ nano auth.js
    

    Add the following content to it:

     const BaseController = require(APP_CONTROLLER_PATH + 'base');
     const AuthHandler = require(APP_HANDLER_PATH + 'auth');
    
     class AuthController extends BaseController {
         constructor() {
             super();
             this._authHandler = new AuthHandler();
             this._passport = require('passport');
         }
    
         // Request token by credentials
         create(req, res, next) {
             let responseManager = this._responseManager;
             let that = this;
             this.authenticate(req, res, next, (user) => {
                 that._authHandler.issueNewToken(req, user, responseManager.getDefaultResponseHandler(res));
             });
         }
    
         authenticate(req, res, next, callback) {
             let responseManager = this._responseManager;
             this._passport.authenticate('credentials-auth', function (err, user) {
                 if (err) {
                     responseManager.respondWithError(res, err.status || 401, err.message || "");
                 } else {
                     callback(user);
                 }
             })(req, res, next);
         }
    
         authorize(req, res, next){
             let responseManager = this._responseManager;
             let that = this;
             this._passport.authenticate('jwt-rs-auth', {
                 onVerified: function (token, user) {
                     if(user.role!=="WRITE"){
                         res.status(403).json("Does not have permission to create a new blog");
                     }else{
                         res.status(200).json("Able to create a new blog");
                     }
                 },
                 onFailure: function (error) {
                     responseManager.respondWithError(res, error.status || 401, error.message);
                 }
             })(req, res, next);
         }
    
         authen(req, res, next) {
             let responseManager = this._responseManager;
             this._passport.authenticate('jwt-rs-auth', {
                 onVerified: function () {
                     res.status(200).json("Token is valid")
                 },
                 onFailure: function (error) {
                     responseManager.respondWithError(res, error.status || 401, error.message);
                 }
             })(req, res, next);
         }
     }
    
     module.exports = AuthController;
    

    The auth.js file defines controller methods for both authentication and authorization APIs.

  4. Create a new file named base.js

     $ nano base.js
    

    Add the following content to the base.js file then save it:

     const ResponseManager = require(APP_MANAGER_PATH + 'response');
     const BaseAutoBindedClass = require(APP_BASE_PACKAGE_PATH + 'base-autobind');
    
     class BaseController extends BaseAutoBindedClass {
         constructor() {
             super();
             if (new.target === BaseController) {
                 throw new TypeError("Cannot construct BaseController instances directly");
             }
             this._responseManager = ResponseManager;
         }
    
         getAll(req, res) {
    
         }
    
         get(req, res) {
    
         }
    
         create(req, res) {
    
         }
    
         authenticate(req, res, callback) {
    
         }
     }
     module.exports = BaseController;
    

    The base.js file defines the base methods that other controller classes will use.

  5. The post.js file

     $ nano post.js
    

    Add the following content to the post.js file then save it:

     const axios = require("axios");
     const BaseController = require(APP_CONTROLLER_PATH + 'base');
     const PostHandler = require(APP_HANDLER_PATH + 'post');
     class PostController extends BaseController {
         constructor() {
             super();
             this._postHandler = new PostHandler();
             this._passport = require('passport');
         }
    
         getAll(req, res, next) {
             axios.post("http://localhost:3001/verify", {
         },
         {
             headers: {
             Authorization: req.headers.authorization
         }
             })
             .catch(function(error){
                 req.status('401').json('Token is invalid');
             })
         .then((response) => {
         console.log(response);
         this._postHandler.getAllPosts(req, this._responseManager.getDefaultResponseHandler(res));
         });
         }
    
         create(reqCreate, resCreate, next) {
    
     axios.post("http://localhost:3001/verify", {
         },
         {
             headers: {
             Authorization: reqCreate.headers.authorization
         }
             })
             .catch(function(error){
                 resCreate.status('401').json('Token is invalid');
             })
         .then((response) => {
         console.log(response);
         axios.post("http://localhost:3004/authorize", {
         },
         {
             headers: {
             Authorization: reqCreate.headers.authorization
         }
             })
             .catch(function(error){
                 resCreate.status('403').json('User does not have permission to create blog post');
             }).then((response)=>{
                 console.log(response);
                 this._postHandler.createNewPost(reqCreate, this._responseManager.getDefaultResponseHandler(resCreate));
             })
         });
         }
    
     }
    
     module.exports = PostController;
    

    The post.js file creates controller methods for creating and retrieving API content.

  6. The user.js file

     $ nano user.js
    

    Add the following content to the user.js file then save it:

     const BaseController = require(APP_CONTROLLER_PATH + 'base');
     const UserHandler = require(APP_HANDLER_PATH + 'user');
    
     const util = require("util");
    
     class UserController extends BaseController {
         constructor() {
             super();
             this._authHandler = new UserHandler();
             this._passport = require('passport');
         }
    
         create(req, res) {
             let responseManager = this._responseManager;
             this.authenticate(req, res, () => {
                 this._authHandler.createNewUser(req, responseManager.getDefaultResponseHandler(res));
             });
         }
    
         authenticate(req, res, callback) {
             let responseManager = this._responseManager;
             this._passport.authenticate('secret-key-auth', {
                 onVerified: callback,
                 onFailure: function (error) {
                     responseManager.respondWithError(res, error.status || 401, error.message);
                 }
             })(req, res);
         }
    
     }
    
     module.exports = UserController;
    

    The user.js file defines controller methods for the user service.

Create the Route Files

For the clients to access the API, you need to create the routing definition for every API.

  1. The route directory

     $ cd ..
     $ mkdir route
    

    Navigate to the route directory

     $ cd route
    

    The route directory contains routing paths for all the APIs.

  2. The v1 directory

    As a best practice, you should version your API. The API routes are located in the v1 directory.

    Create a new directory named v1 inside the route directory

     $ mkdir v1
    
  3. The index.js file

    Create a new file named index.js inside the route directory

     $ nano index.js
    

    Add the following content to it:

     const express = require('express'),
         router = express.Router();
     // API V1
     router.use('/v1', require(APP_ROUTE_PATH + 'v1'));
    
     module.exports = router;
    

    The index.js file is the entry point of the route which tells the application to use the routes inside the v1 directory.

  4. The authentication.js file

    Navigate to the v1 directory

     $ cd v1
    

    Create the authentication.js file

     $ nano authentication.js
    

    Add the following content to it:

     const router = require('express').Router();
     const AuthController = require(APP_CONTROLLER_PATH + 'auth');
     let authController = new AuthController();
    
     router.post('/', authController.create);
     router.post('/verify', authController.authen);
    
     module.exports = router;
    

    The authentication.js file defines routes for authentication APIs.

  5. The authorization.js file

    Create the authorization.js file:

     $ nano authorization.js
    

    Add the following content to it:

     const router = require('express').Router();
     const AuthController = require(APP_CONTROLLER_PATH + 'auth');
     let authController = new AuthController();
    
     router.post('/authorize', authController.authorize);
    
     module.exports = router;
    

    The authorization.js file defines the route for the authorization API.

  6. The post.js file

    Create the post.js file

     $ nano post.js
    

    Add the following content to it

     const router = require('express').Router();
     const PostController = require(APP_CONTROLLER_PATH + 'post');
     let postController = new PostController();
    
     router.get('/',postController.getAll);
     router.post('/', postController.create);
    
     module.exports = router;
    

    The post.js file defines the routes for the blog APIs.

  7. The user.js file

    Create the user.js file

     $ nano user.js
    

    Add the following content to it

     const router = require('express').Router();
     const UserController = require(APP_CONTROLLER_PATH + 'user');
     let userController = new UserController();
    
     router.post('/', userController.create);
    
     module.exports = router;
    

    The user.js file defines the routes for the user APIs.

Create the User Service Entry Point

You use pm2 to manage the backend services of the demo application. For pm2 to do that, you need to create the entry point for each service.

  1. The userSvc.js file

    Navigate to the root directory of the project

     $ cd $PROJECT_DIRECTORY
    

    Create the userSvc.js file

     $ nano userSvc.js
    
  2. Add the following content to the userSvc.js file

     require('./tracing.js');
     global.APP_ROOT_PATH = __dirname + '/app/';
     // Set other app paths
     require('./config/global-paths');
     // Set config variables
     global.config = require('./config');
     const express = require('express');
     const app = express();
     // Include dependencies
     const bodyParser = require('body-parser');
     const mongoose = require('mongoose');
     mongoose.set("strictQuery", true);
     const ValidationManager = require(APP_MANAGER_PATH + 'validation');
     const authManager = require(APP_MANAGER_PATH + 'auth');
     const validationManager = new ValidationManager();
     // Connect to DB
     mongoose.Promise = global.Promise;
     mongoose.connect(config.db.MONGO_CONNECT_URL);
     // Use json formatter middleware
     app.use(bodyParser.json());
     app.use(authManager.providePassport().initialize());
     // Set Up validation middleware
     app.use(validationManager.provideDefaultValidator());
     // Setup routes
    
     const ROUTE_V1_PATH = APP_ROOT_PATH + 'route/' + "v1/";
     app.use('/', require(ROUTE_V1_PATH + 'user'));
    
     app.listen(process.env.PORT, function(){
         console.log("App is running on", process.env.PORT);
     });
    

Create the Authen Service Entry Point

  1. Create a new file named authenSvc.js at the root directory of the project

     $ nano authenSvc.js
    
  2. Add the following content to the file and save it

     require('./tracing.js');
     global.APP_ROOT_PATH = __dirname + '/app/';
     // Set other app paths
     require('./config/global-paths');
     // Set config variables
     global.config = require('./config');
     const express = require('express');
     const app = express();
     // Include dependencies
     const bodyParser = require('body-parser');
     const mongoose = require('mongoose');
     mongoose.set("strictQuery", true);
     const ValidationManager = require(APP_MANAGER_PATH + 'validation');
     const authManager = require(APP_MANAGER_PATH + 'auth');
     const validationManager = new ValidationManager();
     // Connect to DB
     mongoose.Promise = global.Promise;
     mongoose.connect(config.db.MONGO_CONNECT_URL);
     // Use json formatter middleware
     app.use(bodyParser.json());
     app.use(authManager.providePassport().initialize());
     // Set Up validation middleware
     app.use(validationManager.provideDefaultValidator());
     // Setup routes
    
     const ROUTE_V1_PATH = APP_ROOT_PATH + 'route/' + "v1/";
     app.use('/', require(ROUTE_V1_PATH + 'authentication'));
    
     app.listen(process.env.PORT, function(){
         console.log("App is running on", process.env.PORT);
     });
    

    The above code defines the entrypoint for the authentication service so that it can make a connection to the MongoDB database or sending traces to the Jaeger service.

Create the Author Service Entry Point

  1. Create a new file named authorSvc.js at the root of the project

     $ nano authorSvc.js
    
  2. Add the following content to the file and save it:

     require('./tracing.js');
     global.APP_ROOT_PATH = __dirname + '/app/';
     // Set other app paths
     require('./config/global-paths');
     // Set config variables
     global.config = require('./config');
     const express = require('express');
     const app = express();
     // Include dependencies
     const bodyParser = require('body-parser');
     const mongoose = require('mongoose');
     mongoose.set("strictQuery", true);
     const ValidationManager = require(APP_MANAGER_PATH + 'validation');
     const authManager = require(APP_MANAGER_PATH + 'auth');
     const validationManager = new ValidationManager();
     // Connect to DB
     mongoose.Promise = global.Promise;
     mongoose.connect(config.db.MONGO_CONNECT_URL);
     // Use json formatter middleware
     app.use(bodyParser.json());
     app.use(authManager.providePassport().initialize());
     // Set Up validation middleware
     app.use(validationManager.provideDefaultValidator());
     // Setup routes
    
     const ROUTE_V1_PATH = APP_ROOT_PATH + 'route/' + "v1/";
     app.use('/', require(ROUTE_V1_PATH + 'authorization'));
    
     app.listen(process.env.PORT, function(){
         console.log("App is running on", process.env.PORT);
     });
    

    The above code defines the entrypoint for the authorization service so that it can make a connection to the MongoDB database or sending traces to the Jaeger service.

Create the Blog Service Entry Point

  1. Create a new file named blogSvc.js at the root of the project

     $ nano blogSvc.js
    
  2. Add the following content to the file then save it

     require('./tracing.js');
     global.APP_ROOT_PATH = __dirname + '/app/';
     // Set other app paths
     require('./config/global-paths');
     // Set config variables
     global.config = require('./config');
     const express = require('express');
     const app = express();
     // Include dependencies
     const bodyParser = require('body-parser');
     const mongoose = require('mongoose');
     mongoose.set("strictQuery", true);
     const ValidationManager = require(APP_MANAGER_PATH + 'validation');
     const authManager = require(APP_MANAGER_PATH + 'auth');
     const validationManager = new ValidationManager();
     // Connect to DB
     mongoose.Promise = global.Promise;
     mongoose.connect(config.db.MONGO_CONNECT_URL);
     // Use json formatter middleware
     app.use(bodyParser.json());
     app.use(authManager.providePassport().initialize());
     // Set Up validation middleware
     app.use(validationManager.provideDefaultValidator());
     // Setup routes
    
     const ROUTE_V1_PATH = APP_ROOT_PATH + 'route/' + "v1/";
     app.use('/', require(ROUTE_V1_PATH + 'post'));
    
     app.listen(process.env.PORT, function(){
         console.log("App is running on", process.env.PORT);
     });
    

    The above code defines the entrypoint for the blog management service so that it can make a connection to the MongoDB database or sending traces to the Jaeger service.

Trace API Requests With Jaeger

In this section, you test how the API tracing works with the help of Jaeger.

  1. Start the application services

    Run the following command to start all the backend services

     $ pm2 start
    
  2. Create a new user with WRITE permission

    Run the following command to create a new user with WRITE permission

     $ curl --location 'localhost:3002' \
     --header 'Authorization: example' \
     --header 'Content-Type: application/json' \
     --data-raw '    {
         "firstName": "User",
         "lastName": "Write",
         "email": "userwithwrite@gmail.com",
         "password":"userwithwrite",
         "role":"WRITE"
         }'
    

    You should see the following response as below:

     {"success":true,"message":"","data":{"firstName":"User","lastName":"Write","isActive":true,"role":"WRITE","email":"userwithwrite@gmail.com","_id":"651f32c7e9c1fef066874ea4","dateCreated":"2023-10-05T22:03:51.324Z"},"links":[]}
    
  3. Authenticate the user to get the token

    Run the following content to authenticate the user.

     $ curl --location 'localhost:3001' \
     --header 'Content-Type: application/json' \
     --data-raw '    {
         "email": "userwithwrite@gmail.com",
         "password":"userwithwrite"
         }'
    

    You should see the similar output in the console as the one below:

     {"success":true,"message":"","data":{"token":"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjY1MWYzMmM3ZTljMWZlZjA2Njg3NGVhNCIsInNjb3BlIjoiZGVmYXVsdCIsInJvbGUiOiJXUklURSIsImlhdCI6MTY5NjU0MzU2NywiZXhwIjoxNjk3NDA3NTY3LCJhdWQiOiJhcGkuYmxvZy5kb25hbGQubmV0IiwiaXNzIjoiYXBpQGRvbmFsZC5uZXQifQ.WkPkbAhXvO5SNJvqDjt8NEHFMC6VCKe4F8CNdQj8GjsuuLe6FUOVz5cHDRkE8tSOdmsv4KakfWuP939qLAlAzz40AMCpULvbXGS70nuzAUIaayrYJg59rRdcubKhmjoXgEQrGIAcFMwqJWDKo981Emb98KTxG_mJX70K9MmHSGo"},"links":[]}
    

    Notice that you have the user token value in the response.

  4. Create a new blog post using the token above for authentication

    Run the following command to create a new blog post. Replace the eyJh.. token value with your own value.

     $ curl --location 'localhost:3003' \
     --header 'Authorization: JWT eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjY1MWM4ZTYyNzZkZDA0M2U5NTM0MzRkMiIsInNjb3BlIjoiZGVmYXVsdCIsInJvbGUiOiJSRUFEIiwiaWF0IjoxNjk2NDYxOTI3LCJleHAiOjE2OTczMjU5MjcsImF1ZCI6ImFwaS5ibG9nLmRvbmFsZC5uZXQiLCJpc3MiOiJhcGlAZG9uYWxkLm5ldCJ9.Vz0xyTTxtGI_CLvtB0hHbe9GCcWLSBTQefFAHrcu3fawmhYjubEKthhLVdXUJKDJt57dCn0g1fOJubRUfTLzYU8SkUVJOS-qIEauaWeqIkOtd7Mt0NGRvIU8NQ3hSGlNuycFPv9wk5ULTYsYfn287Qz6hmNYc56IlNYStpiwF78' \
     --header 'Content-Type: application/json' \
     --data '{
         "title": "A great post",
         "content": "A great post man. Please read it carefully. A great post man. Please read it carefully. A great post man. Please read it carefully. ",
         "authorId": "63eef3ed7aa97bdc8b7f40a1"
     }'
    

    You should see a similar response as the one below:

     {"success":true,"message":"","data":{"title":"A great post","content":"A great post man. Please read it carefully. A great post man. Please read it carefully. A great post man. Please read it carefully.","authorId":"63eef3ed7aa97bdc8b7f40a1","_id":"651f374cd72a8b96eda7abc5","dateCreated":"2023-10-05T22:23:08.982Z","dateModified":"2023-10-05T22:23:08.982Z"},"links":[]}
    
  5. Trace the API request

    Open up a browser window and navigate to http://localhost:16686/ URL. You should see the Jaeger dashboard page.

    Jaeger dashboard page

    Then search for traces of the blogSvc service.

    Traces of blogSvc service

    Click the first trace. You should see the inner requests of the API request to create a new blog post.

    Inner requests for authentication, authorization, and inserting data to MongoDB inside the API creating new post

    In the inner requests lists, you can see the requests for verifying the user token, authorizing the user permission,and inserting data into the MongoDB database.

Conclusion

Through the article, you learned about how to apply distributed tracing using Jaeger for a Node.js application. To learn more about how to apply distributed tracing with different approaches, visit the How to use OpenTelemetry with Streamlit applications or How to install and use Istio on Kubernetes Engine articles.

More Information

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment