File Coverage

blib/lib/PAGI/Middleware/Healthcheck.pm
Criterion Covered Total %
statement 65 82 79.2
branch 15 24 62.5
condition 9 15 60.0
subroutine 11 12 91.6
pod 1 1 100.0
total 101 134 75.3


line stmt bran cond sub pod time code
1             package PAGI::Middleware::Healthcheck;
2              
3 1     1   581 use strict;
  1         2  
  1         34  
4 1     1   4 use warnings;
  1         1  
  1         38  
5 1     1   4 use parent 'PAGI::Middleware';
  1         1  
  1         5  
6 1     1   44 use Future::AsyncAwait;
  1         1  
  1         5  
7 1     1   36 use JSON::MaybeXS ();
  1         2  
  1         1285  
8              
9             =head1 NAME
10              
11             PAGI::Middleware::Healthcheck - Health check endpoint middleware
12              
13             =head1 SYNOPSIS
14              
15             use PAGI::Middleware::Builder;
16              
17             my $app = builder {
18             enable 'Healthcheck',
19             path => '/health',
20             checks => {
21             database => sub { check_db_connection() },
22             cache => sub { check_redis() },
23             };
24             $my_app;
25             };
26              
27             =head1 DESCRIPTION
28              
29             PAGI::Middleware::Healthcheck provides a health check endpoint for
30             load balancers and monitoring systems. Returns JSON status information.
31              
32             =head1 CONFIGURATION
33              
34             =over 4
35              
36             =item * path (default: '/health')
37              
38             Path for the health check endpoint.
39              
40             =item * live_path (optional)
41              
42             Separate path for liveness probe (always returns 200 if server is running).
43              
44             =item * ready_path (optional)
45              
46             Separate path for readiness probe (runs all checks).
47              
48             =item * checks (optional)
49              
50             Hashref of named health checks. Each check is a coderef that returns
51             true (healthy) or false (unhealthy), or throws an exception.
52              
53             =item * include_details (default: 1)
54              
55             Include individual check results in response.
56              
57             =back
58              
59             =cut
60              
61             sub _init {
62 4     4   9 my ($self, $config) = @_;
63              
64 4   50     18 $self->{path} = $config->{path} // '/health';
65 4         9 $self->{live_path} = $config->{live_path};
66 4         5 $self->{ready_path} = $config->{ready_path};
67 4   100     14 $self->{checks} = $config->{checks} // {};
68 4   50     13 $self->{include_details} = $config->{include_details} // 1;
69             }
70              
71             sub wrap {
72 4     4 1 25 my ($self, $app) = @_;
73              
74 4     4   87 return async sub {
75 4         7 my ($scope, $receive, $send) = @_;
76 4 50       9 if ($scope->{type} ne 'http') {
77 0         0 await $app->($scope, $receive, $send);
78 0         0 return;
79             }
80              
81 4         7 my $path = $scope->{path};
82              
83             # Liveness probe (just check server is responding)
84 4 100 66     12 if (defined $self->{live_path} && $path eq $self->{live_path}) {
85 1         4 await $self->_send_live($send);
86 1         45 return;
87             }
88              
89             # Readiness probe (run all checks)
90 3 50 33     6 if (defined $self->{ready_path} && $path eq $self->{ready_path}) {
91 0         0 await $self->_send_ready($send);
92 0         0 return;
93             }
94              
95             # Main health check endpoint
96 3 100       8 if ($path eq $self->{path}) {
97 2         5 await $self->_send_health($send);
98 2         96 return;
99             }
100              
101             # Not a health check path, pass through
102 1         3 await $app->($scope, $receive, $send);
103 4         27 };
104             }
105              
106 1     1   1 async sub _send_live {
107 1         2 my ($self, $send) = @_;
108              
109 1         6 my $body = JSON::MaybeXS::encode_json({ status => 'ok' });
110              
111 1         7 await $send->({
112             type => 'http.response.start',
113             status => 200,
114             headers => [
115             ['Content-Type', 'application/json'],
116             ['Content-Length', length($body)],
117             ['Cache-Control', 'no-cache, no-store'],
118             ],
119             });
120 1         42 await $send->({
121             type => 'http.response.body',
122             body => $body,
123             more => 0,
124             });
125             }
126              
127 0     0   0 async sub _send_ready {
128 0         0 my ($self, $send) = @_;
129              
130 0         0 my ($healthy, $results) = $self->_run_checks();
131              
132 0 0       0 my $response = {
133             status => $healthy ? 'ok' : 'error',
134             };
135 0 0       0 $response->{checks} = $results if $self->{include_details};
136              
137 0         0 my $body = JSON::MaybeXS::encode_json($response);
138 0 0       0 my $status = $healthy ? 200 : 503;
139              
140 0         0 await $send->({
141             type => 'http.response.start',
142             status => $status,
143             headers => [
144             ['Content-Type', 'application/json'],
145             ['Content-Length', length($body)],
146             ['Cache-Control', 'no-cache, no-store'],
147             ],
148             });
149 0         0 await $send->({
150             type => 'http.response.body',
151             body => $body,
152             more => 0,
153             });
154             }
155              
156 2     2   3 async sub _send_health {
157 2         4 my ($self, $send) = @_;
158              
159 2         4 my ($healthy, $results) = $self->_run_checks();
160              
161 2 100       6 my $response = {
162             status => $healthy ? 'ok' : 'error',
163             timestamp => time(),
164             };
165 2 100 66     6 $response->{checks} = $results if $self->{include_details} && keys %{$self->{checks}};
  2         6  
166              
167 2         21 my $body = JSON::MaybeXS::encode_json($response);
168 2 100       3 my $status = $healthy ? 200 : 503;
169              
170 2         12 await $send->({
171             type => 'http.response.start',
172             status => $status,
173             headers => [
174             ['Content-Type', 'application/json'],
175             ['Content-Length', length($body)],
176             ['Cache-Control', 'no-cache, no-store'],
177             ],
178             });
179 2         105 await $send->({
180             type => 'http.response.body',
181             body => $body,
182             more => 0,
183             });
184             }
185              
186             sub _run_checks {
187 2     2   4 my ($self) = @_;
188              
189 2         3 my %results;
190 2         2 my $all_healthy = 1;
191              
192 2         3 for my $name (sort keys %{$self->{checks}}) {
  2         10  
193 2         4 my $check = $self->{checks}{$name};
194 2         4 my $result = { status => 'ok' };
195              
196 2         3 eval {
197 2         4 my $ok = $check->();
198 2 100       6 unless ($ok) {
199 1         2 $result->{status} = 'error';
200 1         2 $all_healthy = 0;
201             }
202             };
203 2 50       4 if ($@) {
204 0         0 $result->{status} = 'error';
205 0         0 $result->{message} = "$@";
206 0         0 $result->{message} =~ s/\s+$//;
207 0         0 $all_healthy = 0;
208             }
209              
210 2         4 $results{$name} = $result;
211             }
212              
213 2         4 return ($all_healthy, \%results);
214             }
215              
216             1;
217              
218             __END__