Search code examples
javascripttypescriptdecoratorsubclassmethod-modifier

How to extend/wrap/intercept/decorate a class (with custom functionality like tracing)


Question

What is the current (~2024) solution to extend/wrap/intercept/decorate a typescript class with additional behaviour to separate concerns?

There are various questions regarding wrapper classes, various concepts like bind and Proxy, iteration of prototype functions, (deprecated) npm helpers , etc. All of these solutions come from various 'ages' of typescript and javascript.

What I mean

I have a business class, lets say a service:

class MyService{

    public async getSomething(): Promise<string> {
        return "Hello World!";
    }

    public async getSomethingElse(): Promise<string> {
        return "Hello Someting else";
    }

    public async longRunningTask(): Promise<string> {
    
        let r1 = await this.getSomething();
        console.log(r1);

        let r2 = await this.getSomethingElse();
        console.log(r2);
    

        return "All done"
    }
}

It contains business logic and does its job.

Now I would like to extend the class by other aspects like logging or tracing. The open telemetry library provides sample code on how to achieve telemetry, but makes even simple services very hard to read or to understand.

Here would be the same service business logic with telemetry interweaved, now much more complicated, because it mixes two concerns in one location:

class MyServiceWithTelemetry{

    public async getSomething(): Promise<string> {
       
        return trace.startActiveSpan('getSomething', async (span) => {

            span.setAttribute("specific to getSomething", "1234");
            try
            {
                return "Hello World!";
            }
            finally{
                span.end();
            }

        });
    }

    public async getSomethingElse(): Promise<string> {
        
        return trace.startActiveSpan('getSomethingElse', async (span) => {
            
            span.setAttribute("specific to getSomethingElse", "ELSE");
            try
            {
                return "Hello Someting else";
            }
            finally{
                span.end();
            }

        });
    }

    public async longRunningTask(): Promise<string> {
    
        return trace.startActiveSpan('longRunningTask', async (span) => {

            span.setAttribute("GUID", "1234");
            try
            {
                let r1 = await this.getSomething();
                console.log(r1);

                let r2 = await this.getSomethingElse();
                console.log(r2);
            
                return "All done"
            }
            finally{
                span.end();
            }
        });
    }
}

There is a lot more going on here, then the business logic itself. Everything is more nested, everything needs an additional try ... finally to end a span.

My intention here is to be able to add more tracing/telemetry behaviour than "just add a console.log to every method".

In other languages I probably would extend MyService, override all the methods I need with telemetry code and have them then call the base class to perform the business logic.

AFAIK ts/js is very different from compiled languages (and even from interpreted one) with no specific virtual members due to dynamic dispatch, _proto_ prototype existence, etc.

Is there a recommended ts/js native approach to the separation of concerns above?


Solution

  • The OP'S main problem seems to be, finding a way of how to augment already implemented functionality with the least possible code repetition and the least possible effort. Especially the OP's tracing example shows that once in a while developers are in need of reliable method-modifier abstractions which do wrap additional code around other functions/methods in ways that could be described as around, before, after, afterThrowing and afterFinally. Such modifiers in addition need to reliably deal with method-context and asynchronism.

    Note ... method-modification has nothing to do with Aspect Oriented Programming (AOP) because the former lacks any of the latter's necessary abstraction layers which are Joinpoint, Pointcut and Advice.

    A very quick implementation tries to demonstrate possible implementations of around and afterThrowing (at Function.prototype) together with just one possible way of theirs usage ...

    ... here as around-handler functions, which each target a special MyService-method and implement the method's additional trace-specific functionality around its related, but already modified, MyService-method. This other modification did wrap the targeted original MyService-method into try-catch functionality.

    async function traceGetSomething(proceed, handler, args) {
      return trace.startActiveSpan('getSomething', async (span) => {
    
        span.setAttribute('specific to getSomething', '1234');
    
        // - a modified version of the original `getSomething` method
        //   wrapped into try-catch functionality which always returns.
        // - the `result` value will be either `getSomething`'s return
        //   value or whatever value the exception handler returns.
        const result = await proceed(args);
    
        span.end();
    
        return result;
      });
    }
    async function traceGetSomethingElse(proceed, handler, args) {
      return trace.startActiveSpan('getSomethingElse', async (span) => {
    
        span.setAttribute('specific to getSomething', 'ELSE');
    
        const result = await proceed(args);
        span.end();
    
        return result;
      });
    }
    async function traceLongRunningTask(proceed, handler, args) {
      return trace.startActiveSpan('longRunningTask', async (span) => {
    
        span.setAttribute('GUID', '1234');
    
        const result = await proceed(args);
        span.end();
    
        return result;
      });
    }
    async function handleException(exception, args) {
      // - within a service's `this` context
      //   implement the handling of `exception`.
    }
    
    // - facory function which provides glue-code
    //   for the creation of a `MyService` instance
    //   with wrapped around error handling and
    //   tracing behavior.
    function createTelemetryTracingService() {
      const service = new MyService;
    
      service.getSomething = service.getSomething
        .afterThrowing(handleException, service)
        .around(traceGetSomething, service);
    
      service.getSomethingElse = service.getSomethingElse
        .afterThrowing(handleException, service)
        .around(traceGetSomethingElse, service);
    
      service.longRunningTask = service.longRunningTask
        .afterThrowing(handleException, service)
        .around(traceLongRunningTask, service);
    
      return service;
    }
    
    
    (async () => {
      console.log('+++ MyService Test Start +++');
    
      const service = new MyService();
    
      const something = await service.getSomething();
      const somethingElse = await service.getSomethingElse();
    
      const longRunningResult = await service.longRunningTask();
    
      console.log({
        something, somethingElse, longRunningResult,
      });
      console.log('+++ MyService Test End +++\n\n');
    
    
      console.log('\n+++ TracingService Test Start +++');
    
      const tracingService = createTelemetryTracingService();
    
      const somethingTraced = await tracingService.getSomething();
      const somethingElseTraced = await tracingService.getSomethingElse();
    
      const longRunningResultTraced = await tracingService.longRunningTask();
    
      console.log({
        somethingTraced, somethingElseTraced, longRunningResultTraced,
      });
      console.log('+++ TracingService Test End +++');
    })();
    .as-console-wrapper { min-height: 100%!important; top: 0; }
    <script>
    // module ... 'utils'
    
    function getInternalTypeSignature(value) {
      return Object.prototype.toString.call(value).trim();
    }
    
    function getFunctionSignature(value) {
      return Function.prototype.toString.call(value).trim();
    }
    function getFunctionName(value) {
      return Object.getOwnPropertyDescriptor(value, 'name').value;
      // return value.name;
    }
    
    function getInternalTypeName(value) {
      const regXInternalTypeName = /^\[object\s+(?<name>.*)]$/;
    
      let { name } = regXInternalTypeName.exec(
        getInternalTypeSignature(value),
      )?.groups;
    
      if (name === 'Object') {
        const { constructor } = Object.getPrototypeOf(value);
        if (
          typeof constructor === 'function' &&
          getFunctionSignature(constructor).startsWith('class ')
        ) {
          name = getFunctionName(constructor);
        }
      } else if (name === 'Error') {
        name = getFunctionName(Object.getPrototypeOf(value).constructor);
      }
      return name;
    }
    
    function isFunction(value) {
      return (
        typeof value === 'function' &&
        typeof value.call === 'function' &&
        typeof value.apply === 'function'
      );
    }
    
    function isAsyncType(value) {
      const typeName = !!value && getInternalTypeName(value);
      return (
        typeName &&
        (typeName === 'AsyncFunction' || typeName === 'AsyncGeneratorFunction')
      );
    }
    </script>
    
    <script>
    // module ... 'modifier/around'
    
    function around(handler, target) {
      const context = target ?? null;
      const proceed = this;
    
      return (
        isFunction(handler) &&
        isFunction(proceed) && (
    
          (isAsyncType(handler) || isAsyncType(proceed)) && (
    
            async function aroundType(...argumentArray) {
              return await handler.call(
                context,
                proceed,
                handler,
                argumentArray,
              );
            }
          ) || (
            function aroundType(...argumentArray) {
              return handler.call(
                context,
                proceed,
                handler,
                argumentArray,
              );
            }
          )
        )
      ) || proceed;
    }
    
    Reflect.defineProperty(Function.prototype, 'around', {
      configurable: true,
      writable: true,
      value: around,
    });
    </script>
    
    <script>
    // module ... 'modifier/afterThrowing'
    
    function afterThrowing(handler, target) {
      const context = target ?? null;
      const proceed = this;
    
      return (
        isFunction(handler) &&
        isFunction(proceed) && (
    
          (isAsyncType(handler) || isAsyncType(proceed)) && (
    
            async function afterThrowingType(...argumentArray) {
              let result;
              try {
    
                result = await proceed.apply(context, argumentArray);
    
              } catch (exception) {
    
                result = await handler.call(context, exception, argumentArray);
              }
              return result;
            }
          ) || (
            function afterThrowingType(...argumentArray) {
              let result;
              try {
    
                result = proceed.apply(context, argumentArray);
    
              } catch (exception) {
    
                result = handler.call(context, exception, argumentArray);
              }
              return result;
            }
          )
        )
      ) || proceed;
    }
    
    Reflect.defineProperty(Function.prototype, 'afterThrowing', {
      configurable: true,
      writable: true,
      value: afterThrowing,
    });
    </script>
    
    <script>
    // mocked module ... 'trace'
    
      const trace = (() => {
    
        class TracingSpan {
          #spanTitle;
    
          constructor(titleValue) {
            this.#spanTitle = String(titleValue);
    
            console.log(`\n- tracing starts for "${ this.#spanTitle }"`);
          }
    
          setAttribute(key, value) {
            this[String(key)] = value;
    
            console.log({ [ String(key) ]: value });
          }
          end() {
            console.log(`# tracing ends for "${ this.#spanTitle }"`);
          }
        }
    
        async function startActiveSpan(title, tracingCallback) {
          let result;
    
          if (isFunction(tracingCallback)) {
    
            result = await tracingCallback(
              new TracingSpan(
                String(title)
              )
            );
          }
          return result;
        }
    
        return {
          startActiveSpan,
        };
    
      })();
    </script>
    
    <script>
    // module ... 'my-service'
    
    class MyService {
      async getSomething() {
        return new Promise(resolve =>
          setTimeout(resolve, 500, 'Hello World!')
        );
      }
      async getSomethingElse() {
        return new Promise(resolve =>
          setTimeout(resolve, 500, 'Hello Something else.')
        );
      }
      async longRunningTask() {
        let r1 = await this.getSomething();
        console.log('longRunningTask ... r1 ...', r1);
    
        let r2 = await this.getSomethingElse();
        console.log('longRunningTask ... r2 ...', r2);
    
        return new Promise(resolve =>
          setTimeout(resolve, 500, 'All done.')
        );
      }
    }
    </script>

    Edit ... regarding the OP's comment ...

    I understand the idea, thank you @PeterSeliger. But I am fairly intimidated by the verbosity and required complexity to write such system. Tracing as a side effect, should ideally not introduce side effects and breaking existing code. Your prototype implementation tells me, that there is a lot of expertise, added complexity, design and testing necessary to decorate methods that way. Which however helps me, framing my current issue better. – Samuel

    In case one does not want to rely on the above posted modifier and glue-code implementing factory based solution, one still can choose the path of decorated subclassing.

    Then TracingService would extend MyService.

    For convenience reasons one would choose for the former constructor's arguments signature an additional trace parameter as this constructor's first argument.

    The provided trace instance would be stored as private property.

    The prototypal methods of the latter (the extended) class need to be reimplemented by the former (the derived) class, but the implementation would apply delegation via super based method invocations.

    Thus, one just needs to write the tracing part, the code-reuse comes with delegation.

    class TracingService extends MyService {
      #trace;
    
      constructor(trace, ...args) {
        super(...args);
    
        this.#trace = trace;
      }
    
      async getSomething() {
        return this.#trace.startActiveSpan('getSomething', async (span) => {
          span.setAttribute('specific to getSomething', '1234');
    
          let result;
          try {
            result = await super.getSomething();
          } catch(exception) {}
          span.end();
    
          return result;
        });
      }
      async getSomethingElse() {
        return this.#trace.startActiveSpan('getSomethingElse', async (span) => {
          span.setAttribute('specific to getSomething', 'ELSE');
    
          let result;
          try {
            result = await super.getSomethingElse();
          } catch(exception) {}
          span.end();
    
          return result;
        });
      }
      async longRunningTask() {
        return this.#trace.startActiveSpan('longRunningTask', async (span) => {
          span.setAttribute('GUID', '1234');
    
          let result;
          try {
            result = await super.longRunningTask();
          } catch(exception) {}
          span.end();
    
          return result;
        });
      }
    }
    
    
    (async () => {
      console.log('+++ MyService Test Start +++');
    
      const service = new MyService();
    
      const something = await service.getSomething();
      const somethingElse = await service.getSomethingElse();
    
      const longRunningResult = await service.longRunningTask();
    
      console.log({
        something, somethingElse, longRunningResult,
      });
      console.log('+++ MyService Test End +++\n\n');
    
    
      console.log('\n+++ TracingService Test Start +++');
    
      const tracingService = new TracingService(trace);
    
      const somethingTraced = await tracingService.getSomething();
      const somethingElseTraced = await tracingService.getSomethingElse();
    
      const longRunningResultTraced = await tracingService.longRunningTask();
    
      console.log({
        somethingTraced, somethingElseTraced, longRunningResultTraced,
      });
      console.log('+++ TracingService Test End +++');
    })();
    .as-console-wrapper { min-height: 100%!important; top: 0; }
    <script>
    // mocked module ... 'trace'
    
      const trace = (() => {
    
        class TracingSpan {
          #spanTitle;
    
          constructor(titleValue) {
            this.#spanTitle = String(titleValue);
    
            console.log(`\n- tracing starts for "${ this.#spanTitle }"`);
          }
    
          setAttribute(key, value) {
            this[String(key)] = value;
    
            console.log({ [ String(key) ]: value });
          }
          end() {
            console.log(`# tracing ends for "${ this.#spanTitle }"`);
          }
        }
    
        async function startActiveSpan(title, tracingCallback) {
          let result;
    
          if (typeof tracingCallback === 'function') {
    
            result = await tracingCallback(
              new TracingSpan(
                String(title)
              )
            );
          }
          return result;
        }
    
        return {
          startActiveSpan,
        };
    
      })();
    </script>
    
    <script>
    // module ... 'my-service'
    
    class MyService {
      async getSomething() {
        return new Promise(resolve =>
          setTimeout(resolve, 500, 'Hello World!')
        );
      }
      async getSomethingElse() {
        return new Promise(resolve =>
          setTimeout(resolve, 500, 'Hello Something else.')
        );
      }
      async longRunningTask() {
        let r1 = await this.getSomething();
        console.log('longRunningTask ... r1 ...', r1);
    
        let r2 = await this.getSomethingElse();
        console.log('longRunningTask ... r2 ...', r2);
    
        return new Promise(resolve =>
          setTimeout(resolve, 500, 'All done.')
        );
      }
    }
    </script>