The Perl Toolchain Summit needs more sponsors. If your company depends on Perl, please support this very important event.

NAME

HealthCheck::WritingADiagnostic - How to write a diagnostic

VERSION

version v1.6.0

What is a HealthCheck Diagnostic?

A health check diagnostic is a specific test that is focused on one area of an application's health. This can mean multiple things, like checking if a file exists or if the app can connect to a database.

In technical terms, a health check diagnostic is a subroutine that returns a hashref adhering to the Health Check Standard format. This hashref is commonly known as the HealthCheck Result, or Result.

Put simply, this just means that a diagnostic must return a Result consisting of the status key. Any other key is optional, but can help provide additional context. For example, adding a message in the info key of the Result gives a more human-readable description of the test.

Thus, a valid Result that could be returned by a diagnostic can look something like this:

    {   status => "OK",
        info   => "Connecting to the database",
    }

Diagnostic methods

The HealthCheck::Diagnostic module provides a base for writing custom checks with a guarantee that the returned Result conforms to the Health Check Standard. When creating a diagnostic, a few different methods can be overridden.

new

This is the basic constructor method. It just returns the blessed object, so overriding this method allows for default values in the instance.

Set a default label on the diagnostic instance:

    sub new {
        my ($class, @params) = @_;

        # Allows your constructor to take a hashref or even-sized list
        # of parameters similar to the parent.
        my %params = @params == 1 && ( ref $params[0] || '' ) eq 'HASH'
            ? %{ $params[0] } : @params;

        return $class->SUPER::new(
            label => 'default_label',
            %params,
        );
    }

Note that the constructor is overridden to take in a hashref or an even-sized list of parameters. This is the default behavior on the original constructor, and should be included in any subclasses for consistency.

run

This is where the check is normally implemented. It runs the test and returns a Result. This method would most-likely be overridden with the actual test logic.

    sub run { return { status => 'OK' } }

See the note in "check" regarding throwing exceptions in this method.

summarize

This performs extra logic if the diagnostic Result has results. This will squash the status values in the nested structure, and validate that all the Results meet the specification in the Health Check Standard.

If any of the values in the Result are invalid, an OK status is converted to UNKNOWN and the validation error is appended to the info.

Here is an example of a "run" override that uses summarize:

    sub run {
        my $result1 = {
            status => 'OK',
            info   => 'The test passed',
        };
        my $result2 = {
            status => 'CRITICAL',
            info   => 'The test failed',
        };

        return {
            info    => 'Uses summarize',
            results => [ $result1, $result2 ],
        };
    }

Which returns this Result after "check" is called on the diagnostic:

    {   status  => 'CRITICAL',
        info    => 'Uses summarize',
        results => [ $result1, $result2 ],
    }

Note that summarize is called by the default "check" method, so special treatment must be made if overridding "check" in any way.

check

This is the default method that is called by the HealthCheck, which will "summarize" the Result returned by "run".

If the diagnostic needs to handle validation and summarization, then check would most-likely be overridden to handle these two operations.

Parameter requirements can be enforced on the diagnostic. This type of validation is done as follows:

    sub check {
        my ($self, %params ) = @_;

        return {
            status => 'UNKNOWN',
            info   => 'Missing required `file` key',
        } unless $params{file};

        my $res = $self->SUPER::check( %params );
        return $res;
    }

Without calling the parent "check" method, it is helpful to instead call "summarize" since the id and label are passed through that method.

One special note to keep in mind is this method will trap any exceptions during the call to "run", set the status to CRITICAL and put the exception message into the info key of the Result. Despite this feature, "run" should be designed to return a Result in most cases.

Parameters may need to be generated on-the-fly with a passed-in callback. This anonymous function requires proper exception handling.

    if( ref $params{api} eq 'CODE' ) {
        local $@;
        $params{api} = eval {
            local $SIG{__DIE__};
            $params{api}->();
        };
        return {
            status => 'CRITICAL',
            info   => "Error retrieving api from callback: $@",
        } if $@;
    }

Writing class checks and instance checks

There are two different ways to create a diagnostic.

In general, it is not difficult in supporting both instance-based and class-based checks, so consider using the "Multi checks" pattern wherever possible.

Instance checks

Instance checks are diagnostics that are designed to be used on an instance. This implementation requires an instance with the instance values being used in the check.

When used on an instance, "summarize" will copy the id, label, and tags from the instance to the Result if not already included.

Here is an example instance-only implementation module:

    package HealthCheck::Diagnostic::TempCheck;
    use parent 'HealthCheck::Diagnostic';

    sub check_temp { .. } # Returns a hashref using $_[1] as temp limit.

    sub run {
        my ($self, %params) = @_;
        return $self->check_temp( $self->{temp_limit} );
    };

    1;

Here is an example using that module:

    my $diagnostic = HealthCheck::Diagnostic::TempCheck->new(
        temp_limit => '22',
    );
    $health_check->register( $diagnostic );
    $health_check->check;

It might be beneficial to override the "check" method in the instance-only diagnostic module for that restriction:

    sub check {
        my ($self, @args ) = @_;
        croak( "check cannot be called as a class method" )
            unless ref $self;
        return $self->SUPER::check( @args );
    }

Class checks

Class check diagnostics are designed so that the diagnostic does not need to be an instance. Class diagnostics pay special attention to the parameters that are passed to the "check" and "run" methods.

It might be worthwhile to disable calling "new" for class-only diagnostics.

Here is an example of the same diagnostic listed above as a class check:

    package HealthCheck::Diagnostic::TempCheck;
    use parent 'HealthCheck::Diagnostic';

    # Don't allow instantiating an instance
    use Carp;
    sub new { croak(__PACKAGE__ . " does not support being an instance.") }

    sub check_temp { ... } # Returns a hashref using $_[1] as temp limit.

    sub run {
        my ($class, %params) = @_;
        return $class->check_temp( $params{temp_limit} );
    }

    1;

All parameters passed to the HealthCheck instance's check are passed the diagnostic's "check", which are then passed to "run". Here is an example using that module in a health-check:

    $health_check->register( 'HealthCheck::Diagnostic::TempCheck' );
    $health_check->check( temp_limit => 65 );

Multi checks

It is possible, and ideal, to support both instance and class checks in the same module. The diagnostic just needs to be designed so that it can handle that:

    package HealthCheck::Diagnostic::TempCheck;
    use parent 'HealthCheck::Diagnostic';

    sub check_temp { ... } # Returns a hashref using $_[1] as temp limit.

    sub check {
        my ($self, %params) = @_;
        if ( ref $self ) {
            # Default all instance values as arguments to the `run` method.
            $params{$_} = $self->{$_}
                foreach grep { ! defined $params{$_} } keys %$self;
        }
        return $self->SUPER::check( %params );
    }

    sub run {
        my ($self, %params) = @_;
        return $self->check_temp( $params{temp_limit} );
    }

    1;

Then, the diagnostic can be used as either an instance or a class in a health check:

    # Register a class check that uses all the defaults.
    $health_check->register( 'HealthCheck::Diagnostic::TempCheck' );

    # Register an instance check.
    $diagnostic = HealthCheck::Diagnostic::TempCheck->new(
        id          => 'night_temp',
        label       => 'Temperature is cool enough to sleep in',
        temp_limit  => 70, # Overridden by the value passed to `check`
    );
    $health_check->register( $diagnostic );

    # Run the checks, pretending the current temperature is 72-degrees.
    $health_check->check();

The results from that last check are displayed below. If the class-based check defined a default temperature limit, the UNKNOWN sub-result status may have changed.

    {   status  => 'CRITICAL',
        results => [
            {
                status => 'UNKNOWN',
                info   => 'The temperature limit is unknown',
            },
            {
                id     => 'night_temp',
                label  => 'Temperature is cool enough to sleep in',
                status => 'CRITICAL',
                info   => 'The temperature is too hot',
            },
        ],
    }

Now, run the check again, by overriding the default temperature limit:

    # Run the checks, pretending the current temperature is 72-degrees.
    $health_check->check( temp_limit => 77 );

The results from the check call are displayed below. Notice that everything is OK, since the temp_limit is 77 in both of the registered diagnostics.

    {   status  => 'OK',
        results => [
            {
                status => 'OK',
                info   => 'The temperature is just right',
            },
            {
                id     => 'night_temp',
                label  => 'Temperature is cool enough to sleep in',
                status => 'OK',
                info   => 'The temperature is just right',
            },
        ],
    }

More in-depth example

This example shows a check for a physical system. This sensor system can alert when to open or close the window. There are three sensors: inside, outside, and the window status. The inside sensor is used to get the indoor temperature. The outside sensor is used to get the outdoor temperature and weather. The window sensor is used to check if the window is open.

The diagnostic is initialized with several different sensors and the ideal temperatures.

    my $diagnostic = HealthCheck::Diagnostic::WindowStatus->new(
        inside  => My::Sensor::Inside->new,
        outside => My::Sensor::Outside->new,
        window  => My::Sensor::Window->new,

        desired_min => 20,
        desired_max => 23,
    );

The implementation for that check might look something like this:

    package HealthCheck::Diagnostic::WindowStatus;
    use parent "HealthCheck::Diagnostic";
    use strict;
    use warnings;

    sub run {
        my ($self) = @_;

        # Gather some data that can be returned for graphing
        my @data = (
            {   label => "Inside Temperature",
                value => $self->{inside}->temp,
            },
            {   label => "Outside Temperature",
                value => $self->{outside}->temp,
            },
            {   label => "Currently Raining",
                value => $self->{outside}->is_raining,
            },
            {   label => "Window is Open",
                value => $self->{window}->is_open,
            },
        );

        my %res = ( status = "OK", data = \@data );

        # If it's currently raining, need to close the windows
        if ( $self->{outside}->is_raining ) {
            if ( $self->current_state eq "open" ) {
                $res{status} = "CRITICAL";
                $res{info}   = "Close the window, it's raining!";
            }
            return \%res;
        }

        # Otherwise, see if the desired state matches the current state.
        my $desired_state = $self->desired_state;
        if ( $desired_state ne $self->current_state ) {
            $res{status} = "WARNING";
            $res{info}   = "\u$desired_state the window!";
        }

        return \%res;
    }

    sub current_state { shift->{window}->is_open ? "open" : "close" }

    sub desired_state {
        my ($self) = @_;

        my $inside  = $self->{inside}->temp;
        my $outside = $self->{outside}->temp;

        # If the weather is nice outside, open the window
        if (    $outside >= $self->{desired_min}
            and $outside <= $self->{desired_max} )
        {
            return "open";
        }

        # If the outside temp is in the correct
        # direction of our desired temp, open the window.
        my $cold_inside    = $inside < $self->{desired_min};
        my $hot_inside     = $inside > $self->{desired_max};
        my $warmer_outside = $inside < $outside;
        my $cooler_outside = $inside > $outside;

        if (   ( $cold_inside and $warmer_outside )
            or ( $hot_inside  and $cooler_outside ) )
        {
            return "open";
        }

        return "close";
    }

    1;

AUTHOR

Grant Street Group <developers@grantstreet.com>

COPYRIGHT AND LICENSE

This software is Copyright (c) 2017 - 2020 by Grant Street Group.

This is free software, licensed under:

  The Artistic License 2.0 (GPL Compatible)