DEV Community

Renato Santos
Renato Santos

Posted on

[en-UN] A multipurpose microservices' chassis pattern implementation for Spring Boot

Repositories

Discalimer

This project was and is being developed in my free time. This means I'll do my best to solve any issues, but a few issues are to be expected. This also means there is a lot to be done, and a lot that could be better. That being said, this is currently in use in a handful of professional microservices without any issues. I'll continue to use it as it evolves, and I don't expect any downsides to it. Be it as it may, a test suite is needed. Until a comprehensive set of tests is written, this project's version will be labeled as less than 1.0.0.

Also, this project does not currently support NIO as it uses ThreadLocal to control the request context. A simple solution is to add methods to take a snapshot of the context and reinitialize after the NIO code. A more automatic solution would require greater effort. Be it as it may, the snapshot is also not supported yet.

What is this project?

This is an implementation of a microservice's chassis pattern for spring boot applications. It deals with a few common concerns for distributed (or not so distributed) applications:

  • Logging
  • Metrics and monitoring
  • Stamp coupling
  • Data validation
  • Data transformation

Motivation

I created this project based on the experiences I had while developing microservices in the last couple of years or so, and also based on the reading I've done so far. It's not uncommon for application needs to be ignored in favor of domain behavior, and the little code that exists is generally duplicated in many places. Also, I have seen my fair share of anemic models, and while I understand it's never so simple to get rid of data holders, I strive to use them the least possible.

The whole framework is based on the idea that all that happens within an application request is connected to its current context. Data validation is not static, and neither is data transformation. The current context is available to the application anytime and can be used to transform and validate data, among other things.

Usage

To use this project, you need to update your pom.xml if using Maven

<dependency>
    <groupId>io.github.renatols-jf</groupId>
    <artifactId>spboot-chassis</artifactId>
    <version>0.0.2</version>
</dependency>
Enter fullscreen mode Exit fullscreen mode

or your build.gradle if using Gradle

implementation group: 'io.github.renatols-jf', name: 'spboot-chassis', version: '0.0.2'
Enter fullscreen mode Exit fullscreen mode

This is a Spring Boot framework, and it will need to access Spring-managed objects and dependency injection. A new interaction mechanism will be provided in future releases, but currently, this project's main package needs to be scanned by Spring. This is done by adding the ComponentScan annotation to the application, as in:

@SpringBootApplication
@ComponentScan("io.github.renatolsjf")
public class DemoApplication {
Enter fullscreen mode Exit fullscreen mode

@SpringBootApplication adds @ComponentScan for the application's main package, but I've seen issues with this when adding another package. So you might need to add it again, as in:

@SpringBootApplication
@ComponentScan("com.example.demo")
@ComponentScan("io.github.renatolsjf")
public class DemoApplication {
Enter fullscreen mode Exit fullscreen mode

Request

Requestis a unit of behavior. Everything that happens in the application should be within a request. It automatically provides a means to log the request, update application metrics, and some other useful application behavior. The idea is to think of a request as a unit of processing, no matter the entry point. An HTTPS call should be mapped to a request, just as a message consumed from a queue.

Request is an abstract class and needs to be extended to provide any functionality. The idea is for the superclass to control application behavior, while the subclass controls domain behavior.

The domain logic is to be implemented in the method doProcess() - it should NEVER be called directly, instead, process() should. At this point, you might want to access Spring-managed objects, such as services. There are two ways of doing so:

  • this.requestResource(MyService.class) in which you provide the class you are expecting. This is a syntatic sugar available only inside requests.
  • AppRegistry.getResource(MyService.class) in which you provide the class you are expecting. This can be called anywhere.

After a request is finished, it must set its RequestOutcome. If no exception is thrown, the request is deemed successful, and RequestOutcome.SUCCESS is initialized automatically. There are two methods used to realize the final outcome in the case of an exception:Request#resolveError and Request#doResolveError - both receive a Throwable as a parameter.

The idea is to identify the outcome based on the exception thrown. Request#resolveError is called first. It checks if the Throwable is an instance of ValidationException, in which case it sets the outcome as RequestOutcome.CLIENT_ERROR. If it is not, it defers the decision toRequest#doResolveError, which is abstract and must be implemented. In the case thatRequest#doResolveError return nulls (this SHOULD NOT happen), Request#resolveErrorsets the outcome as RequestOutcome.SERVER_ERROR.

There are a few request constructors available, but we will approach only the most complete:

public Request(String operation, String transactionId, String correlationId, List<String> projection,
                   Map<String, String> requestContextEntries)
Enter fullscreen mode Exit fullscreen mode

operation

Operation is the name given to the request or unit of behavior. While we might have some more generic request implementations, it is expected that the subclass initializes this value without it actually being one of its constructor parameters:

public CalculateMarginRequest(String transactionId, String correlationId, List<String> projection,
                   Map<String, String> requestContextEntries) extends Request {
    super("CALCULATE_MARGIN", transactionId, correlationId, projection, requestContextEntries);                  
}
Enter fullscreen mode Exit fullscreen mode

This operation name will be the same used elsewhere to assert validations and transformations, so it's probably a good idea to use a constant.

transactionId

A unique identifier given to the transaction. The idea is to serve as an identifier so a transaction can be traced through the various services. If a null transactionId is provided, a UUID4 will be created.

correlationId

An external identifier given by an application outside the scope of the internal services. It enables the identification of every transaction or request served with that corelationId.

projection

Projection exists as a means to tackle stamp coupling. There are services that generate big payloads as a response, and clients that need only a specific portion of that response. This could lead to traffic overcharges, the need to map unnecessary fields, among other issues.

Each request supports a list of field names that will be automatically filtered before the response is given. Nestesd fields are represented with dots. So, if a request returns the following json:

{
   "aField":"aValue",
   "anotherField":true,
   "anObject":{
      "f1":12,
      "f2":null
   },
   "aList":[
      {
         "f3":false,
         "f4":"Andrew"
      },
      {
         "f3":true,
         "f4":"Ryan"
      }
   ]
}
Enter fullscreen mode Exit fullscreen mode

the projection ["aField", "anObject.f2", "aList.f3"] will yield:

{
   "aField":"aValue",
   "anObject":{
      "f2":null
   },
   "aList":[
      {
         "f3":false
      },
      {
         "f3":true
      }
   ]
}
Enter fullscreen mode Exit fullscreen mode

requestContextEntries

We might want to log information that is not available to the current service, but is used to identify messages in the service chain. Initializing this map will make every entry* in it be logged with each message during the request duration. The entries can also be supplied as a String, separating key from value using : and separating entries using ;, as in aKey:aValue;anotherKey:anotherValue. It's highly recommended to useEntryResolverto do conversions if needed, though.

*Entries are logged in their own fields; exportation of fields other than message
depends on the logging configuration.

Context

Context is the source of information for the current processing/request. It stores information like the operation in execution and the transactionId. It is used by the framework to initiate transformations and validations. It also stores information like elapsed time by type, which will be treated later in this document.

It is initialized automatically as soon as a request object is created and it's detroyed as soon as a request finishes. A context can be obtained anywhere calling Context.forRequest(). Inside a request, a context is also an attribute and can be accessed directly by calling context. As a rule of thumb, we don't want to initialize the context manually elsewhere, so calling Context.initialize outside of a request will result in an error. You also cannot call it manually inside a request, as it will result in an error since the context already exists.

If you absolutely need to create a context outside of a request, the class in which you will be doing so needs to be annotated with@ContextCreator. This will enable a context to be created. If a context already exists, an error will still be thrown.

Logging

Logging can be done by requesting an ApplicationLogger from the context: ApplicationLogger logger = Context.forRequest().createLogger(). With an ApplicationLogger instance, you can use the default logging levels to log information, as is: logger.info(message, param1, param2).log(). A few observations are in order:

  • Any log level provides two arguments: a message and an object varargs. Any varargs present will replace {} inside the message.logger.info("{} is greater than {}", number2, number1).logwill result in "2 is greater than 1" being logged.

  • ApplicationLogger#error also provides an exception argument. This will result in the stack trace being logged. Currently, framework exceptions are logged with their stack traces. A future release will allow this to be disabled.

  • The logging level methods do not actually log the information but rather wait for the log() call. That is because we might want to add some extra information to the log. In a future release, the need of log() will be removed when extra information is not needed. Besides the fields present in the context, information related only to that message can also be logged:

    • Context.forRequest().createLogger().log(message).attach("name", "Andrew").log()will create a new field named "name" only for this message.
    • Context.forRequest().createLogger().log(message).attachMap(aMap).log()will create as many new fields as keys available in the map. This method also supports a String varargs to log only the desired fields.
    • Context.forRequest().createLogger().log(message).attachObject(anObject).log()will create as many fields as are available in the object. Again, you could filter the object providing only the keys you desire to log, but there is another approach for objects. You can annotate any field in a Class that you don't want to log as @Classified. This will create automatic transformations that will be applied to the field before exportation. You need to provide a ClassifiedCypher implementation to the annotation. This strategy is used by the request as it logs its information. You can create yours or use one of the available (if none is specified,IgnoringCypher will be used):
      1. HiddenClassifiedCypher which will print only if the field has a value or not.
      2. IgnoringCypher which will completely ignore that field.

Fields added via attach will NOT be exported in their own fields. Instead, they will be grouped in a field called context. A future release will provide a configuration to change this behavior.

        Context.forRequest().withRequestContextEntry("fixedField", "Present in all messages!")
                .createLogger()
                .info("A message")
                .attach("aField", "aValue")
                .attach("anotherField", "anotherValue")
                .log();

        Context.forRequest()
                .createLogger()
                .info("A  second message")
                .attach("aThridField", "aThirdValue")
                .log();
Enter fullscreen mode Exit fullscreen mode

will log:

{"@timestamp":"2024-02-21T15:25:36.382-03:00","message":"A message","logger_name":"com.example.demo.DemoRequest","level":"INFO","context":"{\"anotherField\":\"anotherValue\",\"aField\":\"aValue\"}","fixedField":"Present in all messages!","operationTimes":"{internal=8, total=8}","operation":"DEMO_OPERATION","transactionId":"c52fa6c6-a5d9-4a39-961c-f832f232da57","elapsedTime":"8","application":"demo-application"}
{"@timestamp":"2024-02-21T15:25:36.382-03:00","message":"A  second message","logger_name":"com.example.demo.DemoRequest","level":"INFO","context":"{\"aThridField\":\"aThirdValue\"}","fixedField":"Present in all messages!","operationTimes":"{internal=8, total=8}","operation":"DEMO_OPERATION","transactionId":"c52fa6c6-a5d9-4a39-961c-f832f232da57","elapsedTime":"8","application":"demo-application"}
Enter fullscreen mode Exit fullscreen mode

Rendering

Media and Renderable are the main components of rendering. Every request terminates with render information, even if there is nothing to render. One of the main goals of the rendering framework is to avoid the creation of DTOs. You should terminate Request#doProcesswith one of the following:

  • Media.ofRenderable(aRenderable) providing a single instance of Renderable to render.
  • Media.ofCollection(aRenderableCollection) providing a collection of Renderables to render.
  • Media.empty() to render nothing.

Renderable

Renderable denotes an object that can be rendered, it governs what and how is rendered or exported. There two ways to mark a class as renderable:implementing Renderable or implementing FieldRenderable.

Renderable provides a render method in which the desired information is exported:

package com.example.demo;

import io.github.renatolsjf.chassis.rendering.Media;
import io.github.renatolsjf.chassis.rendering.Renderable;

public class Greeter implements Renderable {

    private final String name;

    public Greeter(String name) {
        this.name = name;
    }

    public String greet() {
        return "Hi! My name is: " + this.name;
    }

    @Override
    public Media render(Media media) {
        return media.print(name, this.name)
                .print("greeting", this.greet());
    }

}
Enter fullscreen mode Exit fullscreen mode

Rendering the above class (calling Media.ofRenderable(new Greeter("Andrew")).render()) will yield:

{
   "name": "Andrew",
   "greeting": "Hi! My name is Andrew"
}
Enter fullscreen mode Exit fullscreen mode

You can also render nested renderables of collections of renderables:

package com.example.demo;

import io.github.renatolsjf.chassis.rendering.Media;
import io.github.renatolsjf.chassis.rendering.Renderable;

import java.util.ArrayList;
import java.util.List;

public class Greeter implements Renderable {

    private final String name;
    List<Echo> echoes = new ArrayList<>();

    public Greeter(String name) {
        this.name = name;
        this.echoes.add(new Echo());
        this.echoes.add(new Echo());
    }

    public String greet() {
        return "My name is: " + this.name;
    }

    @Override
    public Media render(Media media) {
        return media.print(name, this.name)
                .print("greeting", this.greet())
                .forkCollection("echoes", this.echoes);
    }

}

class Echo implements Renderable {
    @Override
    public Media render(Media media) {
        return media.print("echo", "what they said");
    }
}
Enter fullscreen mode Exit fullscreen mode

Rendering the above class (calling Media.ofRenderable(new Greeter("Andrew")).render()) will yield:

{
   "name":"Andrew",
   "greeting":"Hi! My name is Andrew",
   "echoes":[
      {
         "echo":"what they said"
      },
      {
         "echo":"what they said"
      }
   ]
}
Enter fullscreen mode Exit fullscreen mode

A single Renderable can be nested using Media#forkRenderable instead of Media#forkCollection

FieldRenderable

FieldRenderable does not require an implementation for Renderable#render. It walks the object inheritance tree and prints each class' fields up to Object.class

The rendering can be customized using @RenderConfig. RenderConfig supports the following attributes:

  • operation: indicates to which operations this configuration applies. It can be empty, a single operation, or multiple operations. A field can have more than one RenderConfig. If that's the case, the most suitable configuration will be applied. That is the first configuration which has the current operation listed or a configuration which has no operations configured.
  • policy (@RenderPolicy): indicates whether the field will be rendered or ignored. This can be configured viaRenderPolicy.Policy as in @RenderConfig(policy = @RenderPolicy(RenderPolicy.Policy.RENDER))or @RenderConfig(policy = @RenderPolicy(RenderPolicy.Policy.IGNORE)). The default behavior is to render.
  • alias(@RenderAlias): provides an alias for the current field:@RenderConfig(alias = @RenderAlias("anAlias"))
  • transformer(@RenderTransform): provides an implementation of RenderTransformer that will transform the field value (RenderTransformer MUST have a public no args constructor and be a public class):@RenderConfig(transformer = @RenderTransform(MyTransformer.class))

Currently, there is no way to print nested renderables or a collection of nested renderables - this will be add in a future release. They should be configured not to be printed or some sort of workaround should be done. You can override the FieldRenderable default behavior as follows:

package com.example.demo;

import io.github.renatolsjf.chassis.rendering.FieldRenderable;
import io.github.renatolsjf.chassis.rendering.Media;
import io.github.renatolsjf.chassis.rendering.Renderable;
import io.github.renatolsjf.chassis.rendering.config.RenderConfig;
import io.github.renatolsjf.chassis.rendering.config.RenderPolicy;
import io.github.renatolsjf.chassis.rendering.config.RenderTransform;

import java.util.ArrayList;
import java.util.List;

public class Greeter implements FieldRenderable {

    private final String name;
    @RenderConfig(policy = @RenderPolicy(RenderPolicy.Policy.IGNORE))
    List<Echo> echoes = new ArrayList<>();

    public Greeter(String name) {
        this.name = name;
        this.echoes.add(new Echo());
        this.echoes.add(new Echo());
    }

    public String greet() {
        return "My name is: " + this.name;
    }

    @Override
    public Media render(Media media) {
        return FieldRenderable.super.render(media)
                .forkCollection("echoes", this.echoes);
    }

}

class Echo implements Renderable {
    @Override
    public Media render(Media media) {
        return media.print("echo", "what they said");
    }
}
Enter fullscreen mode Exit fullscreen mode

Rendering the above class (calling Media.ofRenderable(new Greeter("Andrew")).render()) will yield:

{
   "name":"Andrew",
   "echoes":[
      {
         "echo":"what they said"
      },
      {
         "echo":"what they said"
      }
   ]
}
Enter fullscreen mode Exit fullscreen mode

Media

It's the means by which the information will be recorded. You never create a Media object, instead you call Media.ofRenderable, Media.ofCollection, orMedia.empty. A call to Media#render actually exports the object information.
This is an area that can be improved upon. Render returns an Object, which is either a Map for single instances or a List for collections. Spring Boot will return this without any issues, but a more powerful abstraction is a good idea.

Transforming

Transforming is the means by which an output is transformed into another. This is NOTthe same as @RenderTransform from @RenderConfig. @RenderTransform applies to a single field, while this operation applies to the whole data. It can be called manually or initialized automatically according to the context. Currently, the only context transformation available is the projection one.

Transformations can be done using
TransformingPath, an implementation of MediaTransformer, and MediaContent. Currently, very little support exists for this type of transformation. This is a part of the framework that needs to evolve, so I'll not write more information on this topic for now. You are probably better off not using it at the moment.

Validation

Any class that implements Validatable can be validated simply by calling myValidatableInstance.validate(). This will trigger an object lookup for any nested Validatable objects to validate all of them.

To configure validations, you have to annotate Validatable fields or methods with @Validation. A field or a method can have multiple validations configured. A Validation accepts the following parameters:

  • operation: indicates to which operations this validation applies. It can be empty, a single operation, or multiple operations. If a field or method has more than one Validation, all of those that have no operation configured or that have the current operation in its operation's list, will be applied.

  • nullable(@Nullable): indicates whether a field or return of method can be null. Accepted values are:

    • CAN_BE_NULL - @Validation(nullable = @Nullable(Nullable.NullableType.CAN_BE_NULL)):in which the value can either be null or not.
    • CANT_BE_NULL - @Validation(nullable = @Nullable(Nullable.NullableType.CANT_BE_NULL)):in which the value cannot be null.
    • MUST_BE_NULL - @Validation(nullable = @Nullable(Nullable.NullableType.CAN_BE_NULL)):in which the value must be null.
  • minimum(@Minimum): indicates the minimum value of the field - @Validation(minimum = @Minimum(10)). Currently, a minimum value of 0 disables the validation. In a future release, this will be changed to Integer.MIN_VALUE.

  • oneOf (@OneOf): indicates that a field's value must be equal to one of the provided values -@Validation(oneOf = @OneOf({"Ryan", "Andrew"})). Currently, it supports onlyString values, but it will be expanded in a future release.

  • pattern(@Pattern): indicates that a String must match a provided Regex -@Validation(pattern = @Pattern("^(?!000|666)[0-8][0-9]{2}-(?!00)[0-9]{2}-(?!0000)[0-9]{4}$"))

Every validation type also accepts a message parameter, which will override the default error message in case of a validation error.

API integrations - HTTP(s) calls

Each HTTP request should be made using the RestOperation class. To create a RestOperation you should call RestOperation#createproviding the following parameters:

  • group: It's used to identify to whom the service being called belongs. It's the first of a three-layered identification. I generally use the company or team responsible for the service.

  • service: It's used to identify to whom the service being called belongs to. It's the second of a three-layered identification. I generally use the actual service name being called.

  • operation: It's used to identify to whom the service being called belongs to. It's the third of a three-layered identification. I generally use a name for the operation being requested, e.g., Authorization.

  • uri: self-explanatory; the URI being called.

  • headers: A Map<String, String> with header information. Can be null.

  • body: A Map<String, Object> with body information. Can be null.

  • HttpMethod: The desired HTTP method.

RestOperation#create can also be replaced with RestOperation#get, RestOperation#post, RestOperation#patch, RestOperation#put or RestOperation#delete - these do not need the HttpMethod parameter.

To make the HTTP call, simply call RestOperation#call. Two implementations are available: one that accepts a return type and one that accepts a return type and an error type. If no return type is expected, a null value can be passed. If an spefic error type is not expected, using the RestOperation#call without the error type will result in errors being initialized in a simple Map. In the event of an error, either a ClientErrorOperationException or a ServerErrorOperationException will be thrown. Both are implementations of StatusRestOperationException, which provides getBody() to get the parsed error.

Using RestOperation provides automatic logging and metrics creation for the call. These will be configurable in a future release.

Monitoring

This framework exports metrics to Prometheus automatically using the Spring actuator. It provides a facade for metrics, which will reflect in the Spring/Micrometer MeterRegistry.

Metric types and creation

Metrics are created with the help of MetricRegistry - not the same as MeterRegistry from Spring/Micrometer. There is no need to store references to the created metrics. Each time you try to build a Metric, the MetricRegistry will either create a new one or return an existing Metric in case it's the same type and has the same name and same tags as the one requested. Currently, there are 4 metrics that can be created:

  • Counter: A counter, as the name says, counts something. As long as the application is running, its value never resets.
package com.example.demo;

import io.github.renatolsjf.chassis.Chassis;

public class MetricDemo {

    public MetricDemo() {

        /*
        Increases the value by 1
         */
        Chassis.getInstance().getMetricRegistry().createBuilder("a_counter")
                .withTag("aTagName", "aTagValue")
                .buildCounter()
                .inc();
        /*
        Increases the value by the desired amount
         */
        Chassis.getInstance().getMetricRegistry().createBuilder("a_counter")
                .withTag("aTagName", "aTagValue")
                .buildCounter()
                .inc(2d);

        /*
        Exported to Prometheus as:
        a_counter_total{aTagName="aTagValue",} 3.0
         */
    }

}
Enter fullscreen mode Exit fullscreen mode
  • Gauge: A gauge stores a value that can be changed as desired.
package com.example.demo;

import io.github.renatolsjf.chassis.Chassis;

public class MetricDemo {
    public MetricDemo() {
        /*
        Increases the value by 1
         */
        Chassis.getInstance().getMetricRegistry().createBuilder("a_gauge")
                .withTag("aTagName", "aTagValue")
                .buildGauge()
                .inc();
        /*
        Increases the value by the desired amount
         */
        Chassis.getInstance().getMetricRegistry().createBuilder("a_gauge")
                .withTag("aTagName", "aTagValue")
                .buildGauge()
                .inc(2d);

        /*
        Decreases the value by the desired amount
         */
        Chassis.getInstance().getMetricRegistry().createBuilder("a_gauge")
                .withTag("aTagName", "aTagValue")
                .buildGauge()
                .dec();

        /*
        Decreases the value by the desired amount
         */
        Chassis.getInstance().getMetricRegistry().createBuilder("a_gauge")
                .withTag("aTagName", "aTagValue")
                .buildGauge()
                .dec(2d);

        /*
        Sets the value to the current timestamp
         */
        Chassis.getInstance().getMetricRegistry().createBuilder("a_gauge")
                .withTag("aTagName", "aTagValue")
                .buildGauge()
                .setToCurrentTime();

        /*
        Sets the value to the desired amount
         */
        Chassis.getInstance().getMetricRegistry().createBuilder("a_gauge")
                .withTag("aTagName", "aTagValue")
                .buildGauge()
                .set(2d);

        /*
        Expord to Prometheus as:
        a_gauge{aTagName="aTagValue",} 2.0
         */

    }
}
Enter fullscreen mode Exit fullscreen mode
  • TrackingGauge: A TrackingGauge is fundamentally the same as a Gauge. It differs in the way the measurement is done. You don't change its value manually. Instead, it tracks an ObservableTask object.
package com.example.demo;

import io.github.renatolsjf.chassis.Chassis;
import io.github.renatolsjf.chassis.monitoring.ObservableTask;

public class MetricDemo {
    public MetricDemo() {

        /*
        Tracks the desired task
         */
        Chassis.getInstance().getMetricRegistry().createBuilder("a_tracking_gauge")
                .withTag("aTagName", "aTagValue")
                .buildTrackingGauge()
                .track(() -> 2); //or .track(new Task());

        /*
        Tracks the desired task with a WeakReference. As soon as the WeakReference is cleared
        the metric will measure 0. Since the new task is not referenced anywhere else,
        this metric will default to 0 as soon as the garbage collector runs.
         */
        Chassis.getInstance().getMetricRegistry().createBuilder("a_tracking_gauge")
                .withTag("aTagName", "aTagValue")
                .buildTrackingGauge()
                .weakTrack(new Task()); //or .weakTrack(() -> 2);

        /*
        Exported to Prometheus as:
        a_tracking_gauge{aTagName="aTagValue",} 100.0
         */

    }
}

class Task implements ObservableTask {
    @Override
    public double getCurrentValue() {
        return 100;
    }
}
Enter fullscreen mode Exit fullscreen mode
  • Histogram: histogram counts distributions along a defined range. It can be used to calculate quantiles on Prometheus.
package com.example.demo;

import io.github.renatolsjf.chassis.Chassis;

public class MetricDemo {
    public MetricDemo() {

        /*
        Creates a histogram with bucket values of 0.1, 0.2, 0.5, 1, 5, and 10
         */
        Chassis.getInstance().getMetricRegistry().createBuilder("a_histogram")
                .withTag("aTagName", "aTagValue")
                .buildHistogram(.1d, .2d, .5d, 1d, 5d, 10d)
                .observe(.255d);

        /*
        Exported to Prometheus as:
        a_histogram_bucket{aTagName="aTagValue",le="0.1",} 0.0
        a_histogram_bucket{aTagName="aTagValue",le="0.2",} 0.0
        a_histogram_bucket{aTagName="aTagValue",le="0.5",} 1.0
        a_histogram_bucket{aTagName="aTagValue",le="1.0",} 1.0
        a_histogram_bucket{aTagName="aTagValue",le="5.0",} 1.0
        a_histogram_bucket{aTagName="aTagValue",le="10.0",} 1.0
        a_histogram_bucket{aTagName="aTagValue",le="+Inf",} 1.0
        a_histogram_count{aTagName="aTagValue",} 1.0
        a_histogram_sum{aTagName="aTagValue",} 0.255
         */

    }
}
Enter fullscreen mode Exit fullscreen mode

Built-in metrics

Each time a request is executed, a few pre-defined metrics are created as the operation runs. If a request should not count towards application health, as the health request itself does not, the request class should be annotated with @HealthIgnore. If the request does not have that annotation, the following metrics will be generated:

  • A Gauge called operation_active_requests. It stores how many requests are running. It increases as soon as a request starts and decreases as soon as the request finishes. It uses the current operation as a tag.

  • A Gauge called operation_health. It stores the current health of a given operation. It uses the current operation as a tag.

  • A Histogram, named operation_request_time. It stores the time taken for each request, in milliseconds, in buckets. It uses as a tag: the current operation, the outcomeof the request (success, client_error, or server_error), and the timer_type. More information on timer_type is provided in Timing operations and timer classification section.

  • If a RestOperation is executed, a Histogram named integration_request_time. It stores the time taken for each HTTP call, in milliseconds, in buckets. It uses as a tag: the group for the RestOperation, the service for the RestOperationthe operation for the RestOperation, outcome which is either an Http Status Code orconnection_error, and type, which is always rest.

Here is how those metrics are exported to Prometheus:

operation_health{operation="DEMO_OPERATION",} 100.0
operation_active_requests{operation="DEMO_OPERATION",} 0.0
operation_request_time_bucket{operation="DEMO_OPERATION",outcome="success",timer_type="internal",le="200.0",} 1.0
operation_request_time_bucket{operation="DEMO_OPERATION",outcome="success",timer_type="internal",le="500.0",} 1.0
operation_request_time_bucket{operation="DEMO_OPERATION",outcome="success",timer_type="internal",le="1000.0",} 1.0
operation_request_time_bucket{operation="DEMO_OPERATION",outcome="success",timer_type="internal",le="2000.0",} 1.0
operation_request_time_bucket{operation="DEMO_OPERATION",outcome="success",timer_type="internal",le="5000.0",} 1.0
operation_request_time_bucket{operation="DEMO_OPERATION",outcome="success",timer_type="internal",le="10000.0",} 1.0
operation_request_time_bucket{operation="DEMO_OPERATION",outcome="success",timer_type="internal",le="+Inf",} 1.0
operation_request_time_bucket{operation="DEMO_OPERATION",outcome="success",timer_type="internal",} 1.0
operation_request_time_bucket{operation="DEMO_OPERATION",outcome="success",timer_type="internal",} 10.0
operation_request_time_max{operation="DEMO_OPERATION",outcome="success",timer_type="internal",} 10.0
integration_request_time_bucket{group="GOOGLE",operation="SEARCH",outcome="200",service="SEARCH",type="rest",le="200.0",} 0.0
integration_request_time_bucket{group="GOOGLE",operation="SEARCH",outcome="200",service="SEARCH",type="rest",le="500.0",} 0.0
integration_request_time_bucket{group="GOOGLE",operation="SEARCH",outcome="200",service="SEARCH",type="rest",le="1000.0",} 1.0
integration_request_time_bucket{group="GOOGLE",operation="SEARCH",outcome="200",service="SEARCH",type="rest",le="2000.0",} 1.0
integration_request_time_bucket{group="GOOGLE",operation="SEARCH",outcome="200",service="SEARCH",type="rest",le="5000.0",} 1.0
integration_request_time_bucket{group="GOOGLE",operation="SEARCH",outcome="200",service="SEARCH",type="rest",le="10000.0",} 1.0
integration_request_time_bucket{group="GOOGLE",operation="SEARCH",outcome="200",service="SEARCH",type="rest",le="+Inf",} 1.0
integration_request_time_count{group="GOOGLE",operation="SEARCH",outcome="200",service="SEARCH",type="rest",} 1.0
integration_request_time_sum{group="GOOGLE",operation="SEARCH",outcome="200",service="SEARCH",type="rest",} 760.0
integration_request_time_max{group="GOOGLE",operation="SEARCH",outcome="200",service="SEARCH",type="rest",} 760.0
Enter fullscreen mode Exit fullscreen mode

Built-in health information

The application has a default HealthRequest that exports health information in json. It exports health percentage, request count, quantiles for the time taken for each type, and result count by type. It does so by each operation, and also aggregates as application information. The application health is not an average. Instead, it reflects the health of the worst operation.

{
   "application":{
      "health":100.0,
      "load":{
         "requestCount":1,
         "requestTime":{
            "internal":{
               "quantiles":{
                  "0.5":13,
                  "0.95":13,
                  "0.99":13
               }
            }
         }
      },
      "result":{
         "success":1,
         "clientError":0,
         "serverError":0
      }
   },
   "operations":[
      {
         "name":"DEMO_OPERATION",
         "health":100.0,
         "load":{
            "requestCount":1,
            "requestTime":{
               "internal":{
                  "quantiles":{
                     "0.5":13,
                     "0.95":13,
                     "0.99":13
                  }
               }
            }
         },
         "result":{
            "success":1,
            "clientError":0,
            "serverError":0
         }
      }
   ]
}
Enter fullscreen mode Exit fullscreen mode

Timing operations and timer classification

The framework enables us to classify the time taken while processing an operation. The classification is done using a simple String as a Tag. To measure the time for a block of code, we use TimedOperation.

A TimedOperation will record the time it took automatically to the Context. You can tag the TimedOperation however you like, using new TimedOperation(myTag), or using one of two pre-defined tags: http TimedOperation.http(), used for HTTP calls, and db TimedOperation.db(), used for database calls.

TimedOperation.http() is used internally in RestOperation, and TimedOperation.db() has to manually wrap a database call. It's not uncommon for database calls to be automatic, having only the interface for the Repository created. To avoid wrapping every call to the repository in TimedOperation, you can create a delegate for such cases:

package com.example.demo;

import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;
import java.util.Optional;

public interface DemoRepositoryDelegate extends JpaRepository<Demo, String> {
    List<Demo> findAllByStatus(Demo.Status status);
    Optional<Demo> findByAField(String aFieldValue);
}
Enter fullscreen mode Exit fullscreen mode
package com.example.demo;

import io.github.renatolsjf.chassis.monitoring.timing.TimedOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Repository;

import java.util.List;
import java.util.Optional;

@Repository
public class DemoRepository {

    @Autowired
    private DemoRepositoryDelegate demoRepositoryDelegate;

    public List<Demo> findUnfinishedDemos() {
        return TimedOperation.<List<Demo>>db()
                .execute(() -> this.demoRepositoryDelegate.findAllByStatus(Demo.Status.UNFINISHED));
    }

    public Optional<Demo> findByAField(String aFieldValue) {
        return TimedOperation.<Optional<Demo>>db()
                .execute(() -> this.demoRepositoryDelegate.findByAField(aFieldValue));
    }

    public void saveDemo(Demo demoData) {
        TimedOperation.db().run(() -> this.demoRepositoryDelegate.save(demoData));
    }

    public void deleteDemo(Demo demoData) {
        TimedOperation.db().run(() -> this.demoRepositoryDelegate.delete(demoData));
    }

}

Enter fullscreen mode Exit fullscreen mode

A timed operation can be executed in two ways: run and execute. run expects a java.lang.Runnable and has no return type, while execute expects a TimedOperation.Executable and returns whatever type the generic call was made with. In a future release, TimedOperation.Executable will be dropped in favor of java.concurrent.Callable.

To run:

new TimedOperation("aTagValue").run(() -> System.out.println("I ran!"));
Enter fullscreen mode Exit fullscreen mode

or

TimedOperation.db().run(() -> System.out.println("Pretend I am a database call!"));
Enter fullscreen mode Exit fullscreen mode

To execute:

List<String> aList = new TimedOperation<List<String>>("aTagValue").execute(() -> Collections.emptyList());
Enter fullscreen mode Exit fullscreen mode

or

List<String> aList = TimedOperation.<List<String>>http().execute(() -> Collections.emptyList()); //Pretend this is an HTTP request!
Enter fullscreen mode Exit fullscreen mode

A negligible difference (of a few milliseconds) can be expected between the final log, prometheus output, and the health request output. That happens because these calculations happen at the end of the request, but the timer is still ticking. We could stop the timer as soon as the domain logic is over for this wrap-up to use a stopped timer, but this is part of the request after all. Be it as it may, a future release will include a configuration to stop the timer.

Configuration

Although a configuration module exists and is accessible via Chassis.getInstance().getConfig(), currently no changes to the configurations can be made. Be it as it may, the following configurations are in use:

  • useCallingClassNameForLogging: Defaults to true. Governs whether the stack trace will be used or not to initialize the logging class. When creating an ApplicationLogger, you can provide a class or not to be exported as the logger name. In case where no class is provided, as in Context.forRequest().createLogger(), this configuration will be used to initialize the class. If it's true, the class will be the calling class. If it's false, it will always be ApplicatonLogger.

  • printLoggingContextAsJson: Defaults to true. Governs whether the context field present in log messages will be exported as json.

  • allowDefaultLoggingAttributesOverride: Defaults fo false. Governs whether automatic logging attributes, such as transactionId, can have their value replaced with a call for Context#withRequestContextEntry as in Context.forRequest.withRequestContextEntry("transactionId", aNewValue).

  • validatorFailOnExecutionError: Defaults to true. Governs whether an unexpected error in a validation attempt results in an exception. Every validation that fails for not matching the annotations for the current operation will result in an exception. For whatever reason, an exception can happen in the middle of the validation attempt. Let's say a method that is being validated throws an exception. If this configuration is true, the validation will not happen, and an exception will be thrown. If this configuration is false, the validation will not happen, but it will be ignored, and if no other validation fails, the Validatablewould be deemed valid.

  • forbidUnauthorizedContextCreation: Defatuls to true. Context.initialize can't be called anywhere to create a Context. Allowing the Context creation is error-prone and, in almost all cases, not needed. If this configuration is true, unless the class in which Context.initialize is being called is annotated with @ContextCreator, an exception will be thrown.

  • allowContextCorrelationIdUpdate: Defaults to true. Governs whether a correlationId can be updated after the context has been initialized.

  • monitoringRequestDurationRanges: Defaults to new double[]{200, 500, 1000, 2000, 5000, 10000}. Govern the default Histogram buckets for request duration in milliseconds.

  • exportRequestDurationMetricByType: Defaults to true. Governs whether Histogram metrics for request duration will be tagged with timer types.

  • healthTimeWindowDuration: Defaults to 5 minutes. Governs the maximum age of requests to be used in health calculations.

Next Steps

A few future updates have been thought of. Having said that, this does not neither represent an order of priority nor a commitment. These are just a few items that might be done in the future.

  • Enable configuration changes.
  • Create a configuration to not log stack traces.
  • Create a configuration to log attached fields in their own fields.
  • Allow for FieldRenderable to print nested Renderable objects.
  • Replace @Minimum ignore value from 0 to Integer.MIN_VALUE
  • Allow for @OneOf to accept values other than String.
  • Create further validations, such as the maximum value permitted, and the minimum and the maximum size.
  • Create a configuration to have some level of control in automatic logs and metrics.
  • Remove the need to call .log() for messages that have no attachment.
  • Evolve the way this framework interacts with Spring to remove the need for @ComponentScan
  • Replace TimedOperation.Executable with java.concurrent.Callable.
  • Create a configuration to calculate application health as a media instead of worst.
  • Allow extra tags in automatic metrics.
  • Create a configuration to stop the timer as soon as the domain logic is over (Request#doProcess)
  • Create a summary type metric;
  • Create some kind of label structure to override default names for metrics, tags, and logging fields.
  • Allow request duration metrics to be collected in measurements different from milliseconds.
  • Create mechanism to work with Java NIO.
  • Create a configuration to not swallow rendering transformation errors, which is currently being done.
  • Create a configuration to control whether an exception in a request will be wrapped in a RequestException that holds the original exception, and the outcome of the request.
  • Implement distributed tracing.
  • Implement default validators.
  • Implement yml configuration for things like validations, and more.
  • Create a circuit breaker or failsafe structure that can be used to wrap calls.
  • Implement structure to enable A/B testing.
  • Enable external configuration initialization, be it from yml or from some tool.
  • Evolve rendering transformation
  • Dynamic validation rule loading from a datasource.
  • Cross-field or cross-concern validation.
  • Creation of behavior flows using yml or other configuration tools. Possibly another project that uses this one as lib.

Top comments (0)