File Coverage

blib/lib/Dancer2/Plugin/OpenTelemetry.pm
Criterion Covered Total %
statement 67 69 97.1
branch 14 20 70.0
condition 8 15 53.3
subroutine 11 11 100.0
pod 0 1 0.0
total 100 116 86.2


line stmt bran cond sub pod time code
1             package Dancer2::Plugin::OpenTelemetry;
2             # ABSTRACT: Use OpenTelemetry in your Dancer2 app
3              
4             our $VERSION = '0.003';
5              
6 1     1   1535707 use strict;
  1         3  
  1         39  
7 1     1   3 use warnings;
  1         2  
  1         54  
8 1     1   5 use experimental 'signatures';
  1         2  
  1         14  
9              
10 1     1   872 use Dancer2::Plugin;
  1         20105  
  1         14  
11 1     1   4014 use OpenTelemetry -all;
  1         3  
  1         16  
12 1     1   1261 use OpenTelemetry::Constants -span;
  1         1  
  1         8  
13              
14 1     1   1283 use constant BACKGROUND => 'otel.plugin.dancer2.background';
  1         2  
  1         1158  
15              
16 1     1 0 2628 sub BUILD ( $plugin, @ ) {
  1         3  
  1         1  
17             my %tracer = %{
18 1         2 $plugin->config->{tracer} // {
19 1   50     42 name => otel_config('SERVICE_NAME') // 'dancer2',
      50        
20             },
21             };
22              
23 16         39 $plugin->app->add_hook(
24             Dancer2::Core::Hook->new(
25             name => 'before',
26 16     16   1178648 code => sub ( $app ) {
  16         27  
27 16         124 my $req = $app->request;
28              
29             # Make sure we only handle each request once
30             # This protects us against duplicating efforts in the
31             # event of eg. a `forward` or a `pass`.
32 16 100       88 return if $req->env->{+BACKGROUND};
33              
34             # Since our changes to the current context are global,
35             # we try to store a copy of the previous "background"
36             # context to restore it after we are done
37             # As long as we do this, these global changes _should_
38             # be invisible to other well-behaved applications that
39             # rely on this context and are using dynamically as
40             # appropriate.
41 14         151 $req->env->{+BACKGROUND} = otel_current_context;
42              
43 14         752 my $url = URI->new(
44             $req->scheme . '://' . $req->host . $req->uri
45             );
46              
47 14         2284 my $method = $req->method;
48 14         154 my $route = $req->route->spec_route;
49 14         140 my $agent = $req->agent;
50 14         3439 my $query = $url->query;
51 14         246 my $version = $req->protocol =~ s{.*/}{}r;
52              
53             # https://opentelemetry.io/docs/specs/semconv/http/http-spans/#setting-serveraddress-and-serverport-attributes
54 14         189 my $hostport;
55 14 100       57 if ( my $fwd = $req->header('forwarded') ) {
56 4         195 my ($first) = split ',', $fwd, 2;
57 4 50 33     53 $hostport = $1 // $2 if $first =~ /host=(?:"([^"]+)"|([^;]+))/;
58             }
59              
60 14   66     458 $hostport //= $req->header('x-forwarded-proto')
      66        
61             // $req->header('host');
62              
63 14         804 my ( $host, $port ) = $hostport =~ /(.*?)(?::([0-9]+))?$/g;
64              
65             my $context = otel_propagator->extract(
66             $req,
67             undef,
68 0         0 sub ( $carrier, $key ) { scalar $carrier->header($key) },
69 14         83 );
70              
71 14 50       904 my $span = otel_tracer_provider->tracer(%tracer)->create_span(
    100          
    50          
    100          
72             name => $method . ' ' . $route,
73             parent => $context,
74             kind => SPAN_KIND_SERVER,
75             attributes => {
76             'http.request.method' => $method,
77             'network.protocol.version' => $version,
78             'url.path' => $url->path,
79             'url.scheme' => $url->scheme,
80             'http.route' => $route,
81             'client.address' => $req->address,
82             # 'client.port' => ..., # TODO
83             $host ? ( 'server.address' => $host ) : (),
84             $port ? ( 'server.port' => $port ) : (),
85             $agent ? ( 'user_agent.original' => $agent ) : (),
86             $query ? ( 'url.query' => $query ) : (),
87             },
88             );
89              
90             # Normally we would set this with `dynamically`, to ensure
91             # that any previous context was restored after the fact.
92             # However, that requires us to be have a scope that wraps
93             # around the entire request, and Dancer2 does not have such
94             # a hook.
95             # We can do that with the Plack middleware, but that has no
96             # way to hook into the Dancer2 router at span-creation time,
97             # so we have no way to generate a low-cardinality span name
98             # early enough for it to be used in a sampling decision.
99 14         30861 otel_current_context
100             = otel_context_with_span( $span, $context );
101             },
102 1         121 ),
103             );
104              
105 13         29 $plugin->app->add_hook(
106             Dancer2::Core::Hook->new(
107             name => 'after',
108 13     13   25714 code => sub ( $res ) {
  13         25  
109             return unless my $context
110 13 50       171 = delete $plugin->app->request->env->{+BACKGROUND};
111              
112 13         145 my $span = otel_span_from_context;
113 13         1260 my $code = $res->status;
114              
115 13 50       1381 if ($code >= 500) {
116 0         0 $span->set_status(SPAN_STATUS_ERROR);
117             }
118              
119             $span
120 13         66 ->set_attribute( 'http.response.status_code' => $code )
121             ->end;
122              
123 13         3450 otel_current_context = $context;
124             },
125 1         414 ),
126             );
127              
128 1         2 $plugin->app->add_hook(
129             Dancer2::Core::Hook->new(
130             name => 'on_route_exception',
131 1     1   656 code => sub ( $, $error ) {
  1         1  
132             return unless my $context
133 1 50       9 = delete $plugin->app->request->env->{+BACKGROUND};
134              
135 1         10 my ($message) = split /\n/, "$error", 2;
136 1         7 $message =~ s/ at \S+ line \d+\.$//a;
137              
138 1   50     4 otel_span_from_context
139             ->record_exception($error)
140             ->set_status( SPAN_STATUS_ERROR, $message )
141             ->set_attribute(
142             'error.type' => ref $error || 'string',
143             'http.response.status_code' => 500,
144             )
145             ->end;
146              
147 1         309 otel_current_context = $context;
148             },
149 1         289 ),
150             );
151             }
152              
153             1;