Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feature Request]: Static context type/shape scope (With an implementation example) #2545

Open
mriioos opened this issue Feb 12, 2025 · 1 comment
Labels
Feature Request Request for new functionality to support use cases not already covered Needs Investigation

Comments

@mriioos
Copy link

mriioos commented Feb 12, 2025

🔎 Search Terms

static, context, static context, context type, static context type, context scope, static scope

The vision

The key point of this feature would be to be able to create a logger that accepts an object of a certain predefined type as context before the extra metadata you want to add. The objective is to be able to define what shape has the metadata you are going to add to the log before knowing the actual instance of the metadata.

For example (I'll show later how this can be implemented), I have defined a logger that can accept an Express.Request object as context before logging, so I can use it like:

requestLogger.with(req).warn({ extra_content : 'extra_content' })

And it would automatically retrieve the relevant information from the req object and add it to the metadata before logging.

The way to define this static context logger would need two things:

  • The type you want the context to be (Only available in TypeScript) as a template type (that extends object because that is what the logger accepts)
  • A function that accepts an instance of that type as a parameter and defines how data is going to be extracted from the instance before logging (by default, all content of the instance)
    As an example with an Express.Request context:
// A logger that can use an Express.Request instance to add as a context
const reqLogger = staticContexTypeLogger<Request>((req : Request) => ({
    req : {
        method: req.method,
        url: req.url,
        headers: req.headers,
        body: req.body
    }
}));

// Logging making use of the predefined context
requestLogger.with(req).warn({ extra_content : 'extra_content' })
/* Example result
{
    req : {
        method: ‘VERB’,
        url: ‘/your/endpoint’,
        headers: {
            ‘Accept’ : ‘application/json’
        },
        body: {
            user_id : 42
        }
    },
    extra_context : ‘extra_context’
}
*/

// Logging without making use of the predefined context (Not recomended)
requestLogger.warn({ extra_content : 'extra_content' })

Notice I have also added the possibility to ignore the context if you want, tho not really recommended because it looses the whole point.

I also made a version where you don't have to use the word 'with' in yourStaticLogger.with(instance).warn(...) to make use of this feature, you could just call yourStaticLogger(instance).warn(...), but it felt more readable to add the 'with' word. Also it let me add the possibility to not add the context instance at a certain point and just call yourStaticLogger.warn(...), tho as said before, this is not very important.

What is important and I'm sure you want to know if you have reached this point of the article is the actual implementation of the function that lets you do this magic trick.
I have never contributed to an open source project, but I feel it could be time to start doing it. Since I'm not sure how to do it or which is the correct way of doing it, I thought that starting a discussion on the topic I'd like to contribute to would be a good way, I hope I got it right.

So this is the code I have come up with:

// This function is used to create a logger (actually a function that 
// returns a logger) that requires a context of a defined type T. 
// This means that it makes it mandatory to provide a certain context to log.
export function staticLogger<T extends object = object>(context_data_extractor? : (data : T) => object){
    
    // Returning a function that accepts an initial metadata object
    // keeps the capability of creating a scoped logger while still
    // been able to have a static type context.
    return (scope : (object | null)) => {

        const childLogger = logger.child(scope || {});

        return Object.assign(childLogger, {
            with : (context : T) => childLogger.child(context_data_extractor ? context_data_extractor(context) : context)
        });
    }
}

// The logger instance object in this case would be the result of a winston.createLogger(...). 

I know it is not THE GREATEST or BIGGEST contribution, but I think it could be a great addition to the project and I'd like to contribute to it if you like it too, let me know your thoughts on it, no hard feelings if you don't like it :).

Use case

As shown before it could be used to give a shape to the scope of a logger rather than a simple static data that gets always added.

Any use case where you have instances of a type of object that repeats in different places and can give context to your logs but you don't want to be unpacking it every time you log.

In the above use case, after definining how the req object unpacks, you can call the log from many parts of you application with a simple requestLogger.with(req).warn({ extra_content : 'extra_content' }), where req can be any instance of Express.Request.

Below there is a real world example where this could be used.

How it would be done normally:

/**
 * This middleware filters any stripe-signed request. If signature is incorrect, it will return a 403 Forbidden.
 */

import { NextFunction, Request, Response } from 'express';

import { stripe } from '../backend/payment';

import default_logger from '../backend/logging';
const logger = default_logger.child({ service : 'middleware/stripeFilter' });

import ENV from '../utils/config';

export default function stripeFilter(event_types? : string[]){

    return (req: Request, res: Response, next: NextFunction) => {

        // Check if the request has a Stripe Signature
        const signature = req.headers['stripe-signature'];
        
        if(!signature || !req.body){
            res.status(401).send('Unauthorized. Missing Stripe Signature');
            logger.warn('401 Unauthorized. Missing Stripe Signature', {
                req : {
                    method: req.method,
                    ip : req.headers['x-forwarded-for'] || req.ip,
                    url : req.url,
                    headers : req.headers,
                    body : req.body
                }
            });
            return;
        }

        // Verify the Stripe Signature and assign value to the body
        try{
            req.body = stripe.webhooks.constructEvent(req.body, signature, ENV.STRIPE_SUBSCRIPTIONS_WEBHOOK_SECRET);

            if(event_types && !event_types.includes(req.body.type)){
                res.status(422).send('Unprocessable entity. Invalid Stripe Event Type');
                logger.warn('422 Unprocessable entity. Invalid Stripe Event Type', {
                    req : {
                        ip : req.headers['x-forwarded-for'] || req.ip,
                        url : req.url,
                        headers : req.headers,
                        body : req.body
                    },
                    context : {
                        filtered_event_types : event_types
                    }
                });
                return;
            }
            
            next();
        }
        catch(_error){
            res.status(403).send('Forbidden. Invalid Stripe Signature');
            logger.warn('403 Forbidden. Invalid Stripe Signature', {
                req : {
                    ip : req.headers['x-forwarded-for'] || req.ip,
                    url : req.url,
                    headers : req.headers,
                    body : req.body
                },
                context : {
                    signature : signature
                }
            });
            return;
        }
    }
}

With this feature (Note: the provided implementation works in real enviroments, it's not an imagined example)

/**
 * This middleware filters any stripe-signed request. If signature is incorrect, it will return a 403 Forbidden.
 */

import { NextFunction, Request, Response } from 'express';

import { stripe } from '../backend/payment';

import { reqLogger } from '../backend/logging';
const logger = reqLogger({ service : 'middleware/stripeFilter' });

import ENV from '../utils/config';

export default function stripeFilter(event_types? : string[]){

    return (req: Request, res: Response, next: NextFunction) => {

        // Check if the request has a Stripe Signature
        const signature = req.headers['stripe-signature'];
        
        if(!signature || !req.body){
            res.status(401).send('Unauthorized. Missing Stripe Signature');
            logger.with(req).warn('401 Unauthorized. Missing Stripe Signature');
            return;
        }

        // Verify the Stripe Signature and assign value to the body
        try{
            req.body = stripe.webhooks.constructEvent(req.body, signature, ENV.STRIPE_SUBSCRIPTIONS_WEBHOOK_SECRET);

            if(event_types && !event_types.includes(req.body.type)){
                res.status(422).send('Unprocessable entity. Invalid Stripe Event Type');
                logger.with(req).warn('422 Unprocessable entity. Invalid Stripe Event Type', {
                    context : {
                        filtered_event_types : event_types
                    }
                });
                return;
            }
            
            next();
        }
        catch(_error){
            res.status(403).send('Forbidden. Invalid Stripe Signature');
            logger.with(req).warn('403 Forbidden. Invalid Stripe Signature', {
                context : {
                    signature : signature
                }
            });
            return;
        }
    }
}

// Where request logger is defined as (../backend/logging/index.ts)
import { Request } from 'express';

import logger from './instance';

// This function is used to create a logger (actually a function that 
// returns a logger) that requires a context of a defined type T. 
// This means that it makes it mandatory to provide a certain context to log.
export function staticLogger<T extends object = object>(context_data_extractor? : (data : T) => object){
    
    // Returning a function that accepts an initial metadata object
    // keeps the capability of creating a scoped logger while still
    // been able to have a static type context.
    return (scope : (object | null)) => {

        const childLogger = logger.child(scope || {});

        return Object.assign(childLogger, {
            with : (context : T) => childLogger.child(context_data_extractor ? context_data_extractor(context) : context)
        });
    }
}

export const reqLogger = staticContextTypeLogger<Request>((req : Request) => ({
    req : {
        method: req.method,
        ip : req.headers['x-forwarded-for'] || req.ip,
        url: req.url,
        headers: req.headers,
        body: req.body
    }
}));

Notice how a lot of boilerplate dissapears, leaving a clean scope with easily added context

Additional information

I know it can probably have efficiency improvements and that functions could be named differently in order to be more integrated with the project, but I'm sure someone more familiarized with it can aim to the write place to integrate them.

The mayor flaw I see to this is that it creates a function that returns a logger, which means it would be creating new child logger every time, idk how cost efficient that is but I feel like child loggers are not designed for this purpouse, therefore I suggest it should be more like a native function or overload, like another way to create child loggers that accept a shape and a static context rather than just a static context.

It would also be nice if this could be chainable, to be able to create a static context type logger from another static context type logger.

Also I'll apologize for my english, I believe I have explained it correctly but, as english is not my main language I may have misspelled some things or wrongly expressed them, let me know!

@mriioos mriioos added Feature Request Request for new functionality to support use cases not already covered Needs Investigation labels Feb 12, 2025
@mriioos mriioos changed the title [Feature Request]: Static context type scope [Feature Request]: Static context type/shape scope Feb 12, 2025
@mriioos mriioos changed the title [Feature Request]: Static context type/shape scope [Feature Request]: Static context type/shape scope staticContextLogger.with(contextInstance).info(additionalMeta) Feb 13, 2025
@mriioos mriioos changed the title [Feature Request]: Static context type/shape scope staticContextLogger.with(contextInstance).info(additionalMeta) [Feature Request]: Static context type/shape scope (With an implementation example) Feb 13, 2025
@mriioos
Copy link
Author

mriioos commented Feb 19, 2025

I have been using my implementation for some days now and I have to say the idea works really well, I have made some minor adjustments and types improvements, but I believe that with some integration effort and a clean, recursive solution, this could be a great addition to the project

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Feature Request Request for new functionality to support use cases not already covered Needs Investigation
Projects
None yet
Development

No branches or pull requests

1 participant