File Coverage

blib/lib/MFab/Plugins/Datadog.pm
Criterion Covered Total %
statement 140 193 72.5
branch 15 40 37.5
condition 7 25 28.0
subroutine 17 20 85.0
pod 12 12 100.0
total 191 290 65.8


line stmt bran cond sub pod time code
1             package MFab::Plugins::Datadog 1.0;
2              
3             =encoding utf8
4              
5             =head1 NAME
6              
7             MFab::Plugins::Datadog - Mojolicious plugin for Datadog APM integration
8              
9             =head1 SYNOPSIS
10              
11             # In your Mojolicious application
12             push(@{ $self->plugins->namespaces }, 'MFab::Plugins');
13              
14             $app->plugin('Datadog', {
15             enabled => "true",
16             service => "MyApp",
17             serviceEnv => "production"
18             });
19              
20             =head1 DESCRIPTION
21              
22             This module provides seamless integration between Mojolicious web applications and Datadog's Application Performance Monitoring (APM) system. It automatically instruments your Mojolicious application to send distributed traces and metrics to Datadog, giving you visibility into:
23              
24             =over 4
25              
26             =item * HTTP request/response cycles
27              
28             =item * Route matching and dispatch timing
29              
30             =item * Controller action execution
31              
32             =item * Custom application spans
33              
34             =back
35              
36             The plugin automatically propagates trace context across service boundaries using Datadog's trace headers, enabling distributed tracing across your microservices architecture.
37              
38             =head1 CUSTOM SPANS
39              
40             When you want to measure a specific function, you can use the startSpan/endSpan functions to mark the start and end points:
41              
42             sub customHandler {
43             my($c) = @_;
44             my $span = startSpan($c->tx, "TestCode::customHandler", "customHandler");
45             # do work here
46             sleep(10);
47             endSpan($span);
48             }
49              
50             =head1 NOTES
51              
52             =over 4
53              
54             =item * If the worker is killed through a heartbeat failure, the spans for that worker won't be sent
55              
56             =item * Websockets only generate a mojolicious-transaction span
57              
58             =back
59              
60             =head1 FUNCTIONS
61              
62             =cut
63              
64 2     2   1438072 use Mojo::Base qw(Mojolicious::Plugin -signatures);
  2         5  
  2         20  
65              
66 2     2   14305 use Time::HiRes qw(gettimeofday tv_interval);
  2         5  
  2         21  
67 2     2   1641 use Crypt::Random;
  2         45596  
  2         124  
68 2     2   19 use Math::Pari;
  2         8  
  2         16  
69              
70 2     2   391 use Exporter 'import';
  2         5  
  2         8391  
71             our @EXPORT_OK = qw(startSpan endSpan);
72              
73             # Keep track of all outstanding transactions
74             my(%transactions);
75              
76             =head2 register()
77              
78             Register Mojolicious plugin and hook into the application - called by the Mojolicious framework
79              
80             It accepts the following config items
81              
82             =over 4
83              
84             =item * "datadogHost" - the datadog agent host, also looks in the ENV for "DD_AGENT_HOST", defaults to "localhost"
85              
86             =item * "enabled" - if "true", traces are sent to datadog, also looks in the ENV for "DD_TRACE_ENABLED", defaults to "false"
87              
88             =item * "service" - the value to send to datadog for the service name, defaults to "MFab::Plugins::Datadog"
89              
90             =item * "serviceEnv" - the value to send to datadog for the service environment, defaults to "test"
91              
92             =back
93              
94             =cut
95              
96 1     1 1 476740 sub register ($self, $app, $args) {
  1         25  
  1         3  
  1         7  
  1         3  
97 1 50       6 if(not $args->{service}) {
98 1         4 $args->{service} = "MFab::Plugins::Datadog";
99             }
100 1         7 $args->{datadogHost} = configItem($args->{datadogHost}, "DD_AGENT_HOST", "localhost");
101 1         6 $args->{enabled} = configItem($args->{enabled}, "DD_TRACE_ENABLED", "false") eq "true";
102 1         5 $args->{datadogURL} = "http://".$args->{datadogHost}.":8126/v0.3/traces";
103 1   50     7 $args->{serviceEnv} = $args->{serviceEnv} || "test";
104              
105 1 50       3 if($args->{enabled}) {
106 1         10 $app->hook(around_action => \&aroundActionHook);
107 1         22 $app->hook(after_dispatch => \&afterDispatchHook);
108 1     1   17 $app->hook(after_build_tx => sub ($tx, $app) { afterBuildTxHook($tx, $app, $args) });
  1         6  
  1         14224  
  1         15  
  1         3  
  1         3  
109             }
110             }
111              
112             =head2 datadogId()
113              
114             Generate a 64 bit integer that JSON::XS will serialize as an integer
115              
116             =cut
117              
118 6     6 1 192283 sub datadogId () {
  6         13  
119 6         31 my $id = Crypt::Random::makerandom(Size => 64, Strength => 0);
120 6         50643 return Math::Pari::pari2iv($id);
121             }
122              
123             =head2 configItem($config_host)
124              
125             Get the config item from: the app setting, the environment variable, or use the default
126              
127             =cut
128              
129 2     2 1 5 sub configItem ($appSetting, $envName, $default) {
  2         6  
  2         46  
  2         4  
  2         4  
130 2 100       6 if(not $appSetting) {
131 1   33     9 $appSetting = $ENV{$envName} || $default;
132             }
133 2         9 return $appSetting;
134             }
135              
136             =head2 setTraceId($c, $connection_data)
137              
138             Set the traceid in the connection data
139              
140             =cut
141              
142 3     3 1 11 sub setTraceId ($tx, $connection_data) {
  3         5  
  3         5  
  3         4  
143 3 100       11 if(defined $connection_data->{traceid}) {
144 2         4 return;
145             }
146              
147 1         4 $connection_data->{traceid} = $tx->req->headers->header("x-datadog-trace-id");
148 1 50       32 if($connection_data->{traceid}) {
149 0         0 $connection_data->{traceid} = int($connection_data->{traceid});
150             } else {
151 1         4 $connection_data->{traceid} = datadogId();
152 1         6 $tx->req->headers->header("x-datadog-trace-id" => $connection_data->{traceid});
153             }
154             }
155              
156             =head2 aroundActionHook()
157              
158             The around_action hook - wrapped around the action
159              
160             =cut
161              
162 1     1 1 8013 sub aroundActionHook ($next, $c, $action, $last) {
  1         2  
  1         7  
  1         2  
  1         2  
  1         2  
163 1   50     4 my $connection_data = $transactions{$c->tx} || {};
164              
165 1         14 $connection_data->{action_start} = [gettimeofday()];
166 1         3 $connection_data->{action_spanid} = datadogId();
167 1         3 $connection_data->{current_spanid} = $connection_data->{dispatch_spanid};
168 1         1 $connection_data->{after_dispatch} = 0;
169              
170 1         5 setTraceId($c->tx, $connection_data);
171              
172 1         48 $connection_data->{parentid} = $c->tx->req->headers->header("x-datadog-parent-id");
173 1 50       16 if($connection_data->{parentid}) {
174 0         0 $connection_data->{parentid} = int($connection_data->{parentid});
175             }
176 1         11 $c->tx->req->headers->header("x-datadog-parent-id" => $connection_data->{action_spanid});
177              
178 1         25 my $retval = $next->();
179              
180 1         78 $connection_data->{action_duration} = tv_interval($connection_data->{action_start});
181 1         10 $connection_data->{is_sync} = $connection_data->{after_dispatch};
182              
183 1         3 my $route = $c->match->endpoint;
184             # "/" doesn't have a pattern
185 1   33     8 $connection_data->{pattern} = $route->pattern->unparsed || $c->req->url;
186              
187 1         31 return $retval;
188             }
189              
190             =head2 afterDispatchHook()
191              
192             The after_dispatch hook - called after the request is finished the sync stage of processing, more async processing can happen after this
193              
194             =cut
195              
196 1     1 1 483 sub afterDispatchHook ($c) {
  1         3  
  1         2  
197 1   50     2 my $connection_data = $transactions{$c->tx} || {};
198              
199 1 50       8 if(not defined($connection_data->{action_start})) {
200 0         0 $connection_data->{action_start} = [gettimeofday()];
201             }
202              
203 1         3 setTraceId($c->tx, $connection_data);
204              
205 1         2 $connection_data->{after_dispatch} = 1;
206 1         3 $connection_data->{current_spanid} = $connection_data->{dispatch_spanid};
207 1         5 $connection_data->{dispatch_duration} = tv_interval($connection_data->{action_start});
208             }
209              
210             =head2 afterBuildTxHook()
211              
212             The after_build_tx hook - called after the transaction is built but before it is parsed
213              
214             =cut
215              
216 1     1 1 3 sub afterBuildTxHook ($tx, $app, $args) {
  1         3  
  1         2  
  1         3  
  1         2  
217 1         5 my $connection_data = {
218             spans => [],
219             };
220 1         5 $transactions{$tx} = $connection_data;
221 1         5 $connection_data->{tx_spanid} = datadogId();
222 1         4 $connection_data->{dispatch_spanid} = datadogId();
223 1         4 $connection_data->{current_spanid} = $connection_data->{tx_spanid};
224 1         4 $connection_data->{build_tx_start} = [gettimeofday()];
225              
226 1     1   1858 $tx->on(finish => sub ($tx) {
  1         3  
  1         2  
227             # websockets skip dispatch & action hooks
228 1         5 setTraceId($tx, $connection_data);
229 1         6 $connection_data->{url} = $tx->req->url->path;
230 1         31 $connection_data->{method} = $tx->req->method;
231 1         57 $connection_data->{code} = $tx->res->code;
232 1         15 $connection_data->{tx_duration} = tv_interval($connection_data->{build_tx_start});
233 1         23 submitDatadog($app, $connection_data, $args);
234 1         46 $transactions{$tx} = undef;
235 1         14 });
236             }
237              
238             =head2 startSpan($tx, $name, $resource, [$parent_id])
239              
240             Start a new span, associates it to the transaction via $tx
241              
242             =cut
243              
244 0     0 1 0 sub startSpan ($tx, $name, $resource, $parent_id = undef) {
  0         0  
  0         0  
  0         0  
  0         0  
  0         0  
245             # we don't have a transaction, so we can't send this span
246 0 0       0 if(not defined($tx)) {
247             return {
248 0         0 "no_tx" => 1,
249             };
250             }
251 0   0     0 my $connection_data = $transactions{$tx} || {};
252             my $span = {
253             "name" => $name,
254             "resource" => $resource,
255             "start" => [gettimeofday()],
256             "span_id" => datadogId(),
257             "parent_id" => $parent_id || $connection_data->{current_spanid},
258 0   0     0 "type" => "web",
259             "meta" => {},
260             };
261 0         0 push(@{$connection_data->{spans}}, $span);
  0         0  
262 0         0 return $span;
263             }
264              
265             =head2 endSpan($span, [$error_message])
266              
267             End a span, optional error message
268              
269             =cut
270              
271 0     0 1 0 sub endSpan ($span, $error_message = undef) {
  0         0  
  0         0  
  0         0  
272 0 0       0 if($span->{no_tx}) {
273 0         0 return;
274             }
275             # we've already submitted this span, likely due to a timeout
276 0 0 0     0 if($span->{meta}{"mojolicious.unclosed"} or ref($span->{start}) ne "ARRAY") {
277 0         0 return;
278             }
279 0         0 $span->{duration} = durationToDatadog(tv_interval($span->{start}));
280 0         0 $span->{start} = timestampToDatadog($span->{start});
281 0 0       0 if($error_message) {
282 0         0 $span->{error} = 1;
283 0         0 $span->{meta} = { "error.message" => "$error_message" };
284             }
285             }
286              
287             =head2 timestampToDatadog($timestamp)
288              
289             Datadog wants number of nanoseconds since the epoch
290              
291             =cut
292              
293 3     3 1 5 sub timestampToDatadog ($timestamp) {
  3         5  
  3         5  
294 3 50       7 if (not defined($timestamp)) {
295 0         0 return undef;
296             }
297 3         29 return $timestamp->[0] * 1000000000 + $timestamp->[1] * 1000;
298             }
299              
300             =head2 durationToDatadog($duration)
301              
302             Datadog wants duration in nanoseconds
303              
304             =cut
305              
306 3     3 1 5 sub durationToDatadog ($duration) {
  3         4  
  3         5  
307 3 50       8 if (not defined($duration)) {
308 0         0 return undef;
309             }
310 3         20 return int($duration * 1000000000);
311             }
312              
313             =head2 submitDatadog($app, $connection_data, $args)
314              
315             Submit spans to datadog agent
316              
317             =cut
318              
319 1     1 1 2 sub submitDatadog ($app, $connection_data, $args) {
  1         2  
  1         2  
  1         2  
  1         2  
320 1   33     6 my $pattern = $connection_data->{pattern} || $connection_data->{url};
321 1   50     10 my $is_sync = $connection_data->{is_sync} || 0;
322             my %meta = (
323             "env" => $args->{serviceEnv},
324             "api.endpoint.route" => $pattern,
325             "http.path_group" => $connection_data->{pattern},
326             "http.method" => $connection_data->{method},
327             "http.url_details.path" => $connection_data->{url},
328 1         28 "process_id" => "$$",
329             "language" => "perl",
330             "mojolicious.sync" => "$is_sync",
331             );
332 1 50       6 if(defined($connection_data->{code})) {
333 1         5 $meta{"http.status_code"} = "".$connection_data->{code};
334             }
335              
336 1         3 my @spans = @{$connection_data->{spans}};
  1         3  
337              
338 1         4 for my $span (@spans) {
339 0         0 $span->{meta}{env} = $meta{env};
340 0         0 $span->{meta}{process_id} = "$$";
341 0         0 $span->{meta}{language} = "perl";
342 0         0 $span->{trace_id} = $connection_data->{traceid};
343 0 0       0 if(not defined($span->{duration})) {
344 0         0 $span->{duration} = durationToDatadog(tv_interval($span->{start}));
345 0         0 $span->{start} = timestampToDatadog($span->{start});
346 0         0 $span->{meta}{"mojolicious.unclosed"} = "true";
347 0         0 $span->{error} = 1;
348 0         0 $span->{meta} = { "error.message" => "Span was not finished when it was sent to the platform" };
349             }
350              
351 0         0 $span->{service} = $args->{service};
352             }
353              
354 1 50       4 if(defined($connection_data->{action_duration})) {
355             push(@spans,
356             {
357             "duration" => durationToDatadog($connection_data->{action_duration}),
358             "meta" => \%meta,
359             "name" => "mojolicious-action",
360             "resource" => $pattern,
361             "service" => $args->{service},
362             "span_id" => $connection_data->{action_spanid},
363             "start" => timestampToDatadog($connection_data->{action_start}),
364             "trace_id" => $connection_data->{traceid},
365             "parent_id" => $connection_data->{tx_spanid},
366 1         17 "type" => "web",
367             });
368             }
369              
370 1 50       5 if($connection_data->{after_dispatch}) {
371             push(@spans,
372             {
373             "duration" => durationToDatadog($connection_data->{dispatch_duration}),
374             "meta" => \%meta,
375             "name" => "mojolicious-dispatch",
376             "resource" => $pattern,
377             "service" => $args->{service},
378             "span_id" => $connection_data->{dispatch_spanid},
379             "start" => timestampToDatadog($connection_data->{action_start}),
380             "trace_id" => $connection_data->{traceid},
381             "parent_id" => $connection_data->{tx_spanid},
382 1         8 "type" => "web",
383             }
384             );
385             }
386              
387             my $tx_span = {
388             "duration" => durationToDatadog($connection_data->{tx_duration}),
389             "meta" => \%meta,
390             "name" => "mojolicious-transaction",
391             "resource" => $pattern,
392             "service" => $args->{service},
393             "span_id" => $connection_data->{tx_spanid},
394             "start" => timestampToDatadog($connection_data->{build_tx_start}),
395             "trace_id" => $connection_data->{traceid},
396 1         6 "type" => "web",
397             };
398 1 50       4 if($connection_data->{parentid}) {
399 0         0 $tx_span->{"parent_id"} = $connection_data->{parentid};
400             }
401 1         2 push(@spans, $tx_span);
402              
403 0     0     $app->ua->put($args->{datadogURL}, json => [ \@spans ], sub ($ua, $tx) {
  0            
  0            
  0            
404 0 0         if($tx->res->is_error) {
405 0           $app->log->error("HTTP Error sending to datadog: ".$tx->res->code." ".$tx->res->body);
406 0           return;
407             }
408             # Errors without a HTTP status code like connection refused & timeout
409 0 0         if($tx->res->error) {
410 0           $app->log->error("Error sending to datadog: ".$tx->res->error->{message});
411 0           return;
412             }
413 1         22 });
414             }
415              
416             1;