diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..4b624ee --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,67 @@ +name: Test + +on: + pull_request: + branches: + - main + push: + branches: + - main + +jobs: + build: + strategy: + fail-fast: false + matrix: + os: [ ubuntu-latest ] + + # All supported Perl versions except latest. + perl: [ + '5.32', '5.34', '5.36', + ] + + # Variants of the latest Perl. + include: + - os: macos-latest + perl: '5.38' + + # This is effectively our normal one: all features and cover. + - name: ' (all)' + os: ubuntu-latest + perl: '5.38' + cover: true + + runs-on: ${{ matrix.os }} + + name: v${{ matrix.perl }} on ${{ matrix.os }}${{ matrix.name }} + + steps: + - uses: actions/checkout@v2 + + - uses: shogo82148/actions-setup-perl@v1 + with: + perl-version: ${{ matrix.perl }} + + # FIXME: Why do we need to install M:B:T manually + # if cpanm --showdeps correctly reports it as a dependency? + - name: Install dependencies + run: | + cpanm --installdeps -n . + cpanm -n Module::Build::Tiny + + - if: ${{ matrix.cover }} + run: | + cpanm -n Devel::Cover::Report::Coveralls + + - name: Build + run: | + perl Build.PL + perl Build build + + - if: ${{ matrix.cover }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: cover -report Coveralls -test + + - if: ${{ !matrix.cover }} + run: perl Build test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..73f19c5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +*~ +*.swp +*.swo +.build/ +cover_db/ + +# Build artifacts +Build +blib/ +MYMETA.* +_build_params +Dancer2-Plugin-OpenTelemetry* diff --git a/cpanfile b/cpanfile new file mode 100644 index 0000000..26f7542 --- /dev/null +++ b/cpanfile @@ -0,0 +1,9 @@ +requires 'Dancer2'; +requires 'Feature::Compat::Try'; +requires 'OpenTelemetry', '0.010'; +requires 'Syntax::Keyword::Dynamically'; + +on test => sub { + requires 'Test2::MojoX'; + requires 'Test2::V0'; +}; diff --git a/dist.ini b/dist.ini new file mode 100644 index 0000000..de800b8 --- /dev/null +++ b/dist.ini @@ -0,0 +1,9 @@ +name = Dancer2-Plugin-OpenTelemetry +author = José Joaquín Atria +license = Perl_5 +copyright_holder = José Joaquín Atria +copyright_year = 2023 + +version = 0.001 + +[@Basic] diff --git a/lib/Dancer2/Plugin/OpenTelemetry.pm b/lib/Dancer2/Plugin/OpenTelemetry.pm new file mode 100644 index 0000000..5a45310 --- /dev/null +++ b/lib/Dancer2/Plugin/OpenTelemetry.pm @@ -0,0 +1,146 @@ +package Dancer2::Plugin::OpenTelemetry; + +use strict; +use warnings; +use experimental 'signatures'; + +use Dancer2::Plugin; +use OpenTelemetry -all; +use OpenTelemetry::Constants -span; + +use constant BACKGROUND => 'otel.plugin.dancer2.background'; + +sub BUILD ( $plugin, @ ) { + my %tracer = %{ + $plugin->config->{tracer} // { + name => otel_config('SERVICE_NAME') // 'dancer2', + }, + }; + + $plugin->app->add_hook( + Dancer2::Core::Hook->new( + name => 'before', + code => sub ( $app ) { + my $req = $app->request; + + # Make sure we only handle each request once + # This protects us against duplicating efforts in the + # event of eg. a `forward` or a `pass`. + return if $req->env->{+BACKGROUND}; + + # Since our changes to the current context are global, + # we try to store a copy of the previous "background" + # context to restore it after we are done + # As long as we do this, these global changes _should_ + # be invisible to other well-behaved applications that + # rely on this context and are using dynamically as + # appropriate. + $req->env->{+BACKGROUND} = otel_current_context; + + my $url = URI->new( + $req->scheme . '://' . $req->host . $req->uri + ); + + my $method = $req->method; + my $route = $req->route->spec_route; + my $agent = $req->agent; + my $query = $url->query; + my $version = $req->protocol =~ s{.*/}{}r; + + # https://opentelemetry.io/docs/specs/semconv/http/http-spans/#setting-serveraddress-and-serverport-attributes + my $hostport; + if ( my $fwd = $req->header('forwarded') ) { + my ($first) = split ',', $fwd, 2; + $hostport = $1 // $2 if $first =~ /host=(?:"([^"]+)"|([^;]+))/; + } + + $hostport //= $req->header('x-forwarded-proto') + // $req->header('host'); + + my ( $host, $port ) = $hostport =~ /(.*?)(?::([0-9]+))?$/g; + + my $context = otel_propagator->extract( + $req, + undef, + sub ( $carrier, $key ) { scalar $carrier->header($key) }, + ); + + my $span = otel_tracer_provider->tracer(%tracer)->create_span( + name => $method . ' ' . $route, + parent => $context, + kind => SPAN_KIND_SERVER, + attributes => { + 'http.request.method' => $method, + 'network.protocol.version' => $version, + 'url.path' => $url->path, + 'url.scheme' => $url->scheme, + 'http.route' => $route, + 'client.address' => $req->address, + # 'client.port' => ..., # TODO + $host ? ( 'server.address' => $host ) : (), + $port ? ( 'server.port' => $port ) : (), + $agent ? ( 'user_agent.original' => $agent ) : (), + $query ? ( 'url.query' => $query ) : (), + }, + ); + + # Normally we would set this with `dynamically`, to ensure + # that any previous context was restored after the fact. + # However, that requires us to be have a scope that wraps + # around the entire request, and Dancer2 does not have such + # a hook. + # We can do that with the Plack middleware, but that has no + # way to hook into the Dancer2 router at span-creation time, + # so we have no way to generate a low-cardinality span name + # early enough for it to be used in a sampling decision. + otel_current_context + = otel_context_with_span( $span, $context ); + }, + ), + ); + + $plugin->app->add_hook( + Dancer2::Core::Hook->new( + name => 'after', + code => sub ( $res ) { + return unless my $context + = delete $plugin->app->request->env->{+BACKGROUND}; + + my $code = $res->status; + my $error = $code >= 400 && $code < 600; + otel_span_from_context + ->set_status( $error ? SPAN_STATUS_ERROR : SPAN_STATUS_OK ) + ->set_attribute( 'http.response.status_code' => $code ) + ->end; + + otel_current_context = $context; + }, + ), + ); + + $plugin->app->add_hook( + Dancer2::Core::Hook->new( + name => 'on_route_exception', + code => sub ( $, $error ) { + return unless my $context + = delete $plugin->app->request->env->{+BACKGROUND}; + + my ($message) = split /\n/, "$error", 2; + $message =~ s/ at \S+ line \d+\.$//a; + + otel_span_from_context + ->record_exception($error) + ->set_status( SPAN_STATUS_ERROR, $message ) + ->set_attribute( + 'error.type' => ref $error || 'string', + 'http.response.status_code' => 500, + ) + ->end; + + otel_current_context = $context; + }, + ), + ); +} + +1; diff --git a/lib/Dancer2/Plugin/OpenTelemetry.pod b/lib/Dancer2/Plugin/OpenTelemetry.pod new file mode 100644 index 0000000..cd920bd --- /dev/null +++ b/lib/Dancer2/Plugin/OpenTelemetry.pod @@ -0,0 +1,237 @@ +=encoding UTF-8 + +=head1 NAME + +Dancer2::Plugin::OpenTelemetry - Use OpenTelemetry in your Dancer2 app + +=head1 SYNOPSIS + + use Dancer2; + use Dancer2::Plugin::OpenTelemetry; + + # Or via config, probably in your config file + BEGIN { + set plugins => { + OpenTelemetry => { + tracer => { + # Tracer parameters, see below + }, + }, + }; + } + + # Will generate a span named + # GET /static/url + get '/static/url' => sub { 'OK' }; + + # Will use placeholders for reduced span cardinality + # POST /url/with/:placeholder + post '/url/with/:placeholder' => sub { 'OK' } + + # Use it also with async actions! + get '/async' => sub { + delayed { + flush; + content 'Hello '; + content 'World'; + done; + } + }; + + # Errors will be correctly captured in the span + get '/error' => sub { + die 'oops'; + }; + + dance; + +=head1 DESCRIPTION + +This plugin allows a L application to automatically generate +telemetry data using the L API. The plugin will add a "before" +hook and "after" L to capture telemetry data about +handled requests in a L object. Errors will be +captured via an "on_route_exception" hook, which will set the span status +accordingly. + +During the "before" hook, a L +L will be created and injected into the +current L, together with any propagation data +retrieved from the incoming request headers by the currently installed +L. + +The value of the C parameter set in the plugin configuration will be +used to obtain a L via +L. Setting this is optional. If +no value is set, the name will be read from the +L<"OTEL_SERVICE_NAME"|OpenTelemetry::SDK/OTEL_SERVICE_NAME> environment +variable. If this variable is also not set, it will default to "dancer2". +The name is the only parameter that has a default: all other values will be +left unspecified. + +The name of the generated span will be derived from the current request +method, and the "spec_route" of the request's +L, as in C, to reduce the +span cardinality. + +The span will be created with the following attributes, which will therefore +be available for any +L. + +=over + +=item C + +Set to the L. It will be the +same value that was concatenated to the route in the span's name. + +=item C + +Set to the version number of the +L. + +=item C + +Set to the path of the request URL. This will be the raw path, without using +any placeholders. It will not include the query string. + +=item C + +Set to the scheme of the request URL. + +=item C + +Set to the "spec_route" of the request's L. +This will use placeholders, and will be the same value that was concatenated +to the method in the span's name. + +=item C + +Set to the L. + +=item C + +Set to the host portion of the C value in the leftmost entry in the +C header, falling back to the value of the C +header, or to the value of the C header if no other is set. The host +portion is the part before an optional port number. + +See the +L +for more details on this logic. + +If no value could be determined, this attribute will not be present. + +=item C + +Set to the port number in the C value in the leftmost entry in the +C header, falling back to the value of the C +header, or to the value of the C header if no other is set. + +See the +L +for more details on this logic. + +The port number in these is optional. If none is set, or none could be +determined, this attribute will not be present. + +=item C + +Set to the value of the L. +If not set, this attribute will not be present. + +=item C + +Set to the query of the L, if present. +If the URL had no query parameters, this attribute will not be present. + +=back + +The attributes described below will be set in the span once the action is +completed, but will not be available for the sampler. + +=over + +=item C + +If an error is encountered during the execution of the action, this attribute +will be set to the package name of the error (as reported by +L<"ref"|https://perldoc.perl.org/functions/ref>), or the value C if +the error is not blessed into any package. + +If there were no errors, this attribute will not be present. + +=item C + +Set to the L. If +an error was encountered while handling the request, this will be set to +C<500>. + +=back + +The span will be unconditionally L after +the request has been handled, and the +L to either an +L if no errors were found, +or to an L otherwise. +In the latter case, an error description will also be set if possible. + +=head2 Internal redirects + +Dancer2 supports the use of keywords to internally forward a request from +one handler to another, or to pass from one handler to another. In this case, +this plugin will only execute for the I matching route. The following +handlers will continue to be executed as expected, but the plugin will ignore +them. + +This is done on the assumption that the requests that are of interest from a +telemetry point of view are those that come from outside the application. + +Users for which this is undesirable are free to create new spans for those +routes that this plugin would otherwise ignore. + +=head1 SEE ALSO + +=over + +=item L + +=item L + +=item L + +=item L + +=item L + +=item L + +=item L + +=item L + +=back + +=head2 Integrations with other web frameworks + +=over + +=item L + +A lower-level, Plack-based implementation of the same idea behind this plugin. +Because it executes outside the scope of Dancer2, it will not have access to +the routes with placeholders at span-creation time, but it's worth a look. + +=item L + +A similar version of this plugin, but integrating with the L web +framework. + +=back + +=head1 COPYRIGHT AND LICENSE + +This software is copyright (c) 2023 by José Joaquín Atria. + +This is free software; you can redistribute it and/or modify it under the same +terms as the Perl 5 programming language system itself. diff --git a/t/basic.t b/t/basic.t new file mode 100644 index 0000000..65a8852 --- /dev/null +++ b/t/basic.t @@ -0,0 +1,370 @@ +#!/usr/bin/env perl + +use Test2::V0; +use Test2::Tools::Spec; +use HTTP::Request::Common; +use Plack::Test; + +use OpenTelemetry -all; +use OpenTelemetry::Constants -span; +use OpenTelemetry::Trace::Tracer; + +use IO::Async; + +use experimental 'signatures'; + +my $span; +my $mock = mock 'OpenTelemetry::Trace::Tracer' => override => [ + create_span => sub { + shift; + $span = mock { otel => { @_ } } => track => 1 => add => [ + record_exception => sub { $_[0] }, + set_attribute => sub { $_[0] }, + set_status => sub { $_[0] }, + ]; + }, +]; + +sub span_calls ( $tests, $message = undef ) { + my @calls; + while ( my $name = shift @$tests ) { + push @calls => { + sub_name => $name, + args => [ D, @{ shift @$tests } ], + sub_ref => E, + }; + } + + is [ mocked($span) ]->[0]->call_tracking, \@calls, + $message // 'Called expected methods on span'; +} + +use Object::Pad; +class Local::Provider :isa(OpenTelemetry::Trace::TracerProvider) { } + +OpenTelemetry->tracer_provider = Local::Provider->new; + +package Local::App { + use Dancer2; + use Dancer2::Plugin::OpenTelemetry; + + set logger => 'null'; + + get '/static/url' => sub { 'OK' }; + + get '/url/with/pass' => sub { pass }; + + get '/url/with/:placeholder' => sub { 'OK' }; + + get '/forward' => sub { forward '/static/url' }; + + get '/async' => sub { + delayed { + flush; + content 'O'; + + require IO::Async::Loop; + require IO::Async::Timer::Countdown; + + my $loop = IO::Async::Loop->new; + + $loop->add( + IO::Async::Timer::Countdown->new( + delay => 0.1, + on_expire => delayed { + content 'K'; + $loop->stop; + done; + }, + )->start + ); + + $loop->run; + } + }; + + get '/error' => sub { die 'oops' }; +} + +my $test = Plack::Test->create( Local::App->to_app ); + +subtest 'Static URL' => sub { + is $test->request( GET '/static/url?query=parameter' ), object { + call decoded_content => 'OK'; + }, 'Request OK'; + + is $span->{otel}, { + attributes => { + 'client.address' => '127.0.0.1', + 'client.port' => DNE, + 'http.request.method' => 'GET', + 'http.route' => '/static/url', + 'network.protocol.version' => '1.1', + 'server.address' => 'localhost', + 'server.port' => DNE, + 'url.path' => '/static/url', + 'url.query' => 'query=parameter', + 'url.scheme' => 'http', + 'user_agent.original' => DNE, + }, + kind => SPAN_KIND_SERVER, + name => 'GET /static/url', + parent => object { + prop isa => 'OpenTelemetry::Context'; + }, + }, 'Span created as expected'; + + span_calls [ + set_status => [ SPAN_STATUS_OK ], + set_attribute => [ 'http.response.status_code', 200 ], + end => [], + ], 'Expected calls on span'; +}; + +subtest 'Forward' => sub { + is $test->request( GET '/forward' ), object { + call decoded_content => 'OK'; + }, 'Request OK'; + + is $span->{otel}, { + attributes => { + 'client.address' => '127.0.0.1', + 'client.port' => DNE, + 'http.request.method' => 'GET', + 'http.route' => '/forward', + 'network.protocol.version' => '1.1', + 'server.address' => 'localhost', + 'server.port' => DNE, + 'url.path' => '/forward', + 'url.query' => DNE, + 'url.scheme' => 'http', + 'user_agent.original' => DNE, + }, + kind => SPAN_KIND_SERVER, + name => 'GET /forward', + parent => object { + prop isa => 'OpenTelemetry::Context'; + }, + }, 'Span created as expected'; + + span_calls [ + set_status => [ SPAN_STATUS_OK ], + set_attribute => [ 'http.response.status_code', 200 ], + end => [], + ], 'Expected calls on span'; +}; + +subtest 'Pass' => sub { + is $test->request( GET '/url/with/pass' ), object { + call decoded_content => 'OK'; + }, 'Request OK'; + + is $span->{otel}, { + attributes => { + 'client.address' => '127.0.0.1', + 'client.port' => DNE, + 'http.request.method' => 'GET', + 'http.route' => '/url/with/pass', + 'network.protocol.version' => '1.1', + 'server.address' => 'localhost', + 'server.port' => DNE, + 'url.path' => '/url/with/pass', + 'url.query' => DNE, + 'url.scheme' => 'http', + 'user_agent.original' => DNE, + }, + kind => SPAN_KIND_SERVER, + name => 'GET /url/with/pass', + parent => object { + prop isa => 'OpenTelemetry::Context'; + }, + }, 'Span created as expected'; + + span_calls [ + set_status => [ SPAN_STATUS_OK ], + set_attribute => [ 'http.response.status_code', 200 ], + end => [], + ], 'Expected calls on span'; +}; + +subtest 'Async' => sub { + require Test2::Require::Module; + Test2::Require::Module->import('IO::Async'); + + is $test->request( GET '/async', user_agent => 'Test' ), object { + call decoded_content => 'OK'; + }, 'Request OK'; + + is $span->{otel}, { + attributes => { + 'client.address' => '127.0.0.1', + 'client.port' => DNE, + 'http.request.method' => 'GET', + 'http.route' => '/async', + 'network.protocol.version' => '1.1', + 'server.address' => 'localhost', + 'server.port' => DNE, + 'url.path' => '/async', + 'url.scheme' => 'http', + 'user_agent.original' => 'Test', + }, + kind => SPAN_KIND_SERVER, + name => 'GET /async', + parent => object { + prop isa => 'OpenTelemetry::Context'; + }, + }, 'Span created as expected'; + + span_calls [ + set_status => [ SPAN_STATUS_OK ], + set_attribute => [ 'http.response.status_code', 200 ], + end => [], + ], 'Expected calls on span'; +}; + +subtest 'With placeholder' => sub { + is $test->request( GET '/url/with/value' ), object { + call decoded_content => 'OK'; + }, 'Request OK'; + + is $span->{otel}, { + attributes => { + 'client.address' => '127.0.0.1', + 'client.port' => DNE, + 'http.request.method' => 'GET', + 'http.route' => '/url/with/:placeholder', + 'network.protocol.version' => '1.1', + 'server.address' => 'localhost', + 'server.port' => DNE, + 'url.path' => '/url/with/value', + 'url.scheme' => 'http', + 'user_agent.original' => DNE, + }, + kind => SPAN_KIND_SERVER, + name => 'GET /url/with/:placeholder', + parent => object { + prop isa => 'OpenTelemetry::Context'; + }, + }, 'Span created as expected'; + + span_calls [ + set_status => [ SPAN_STATUS_OK ], + set_attribute => [ 'http.response.status_code', 200 ], + end => [], + ], 'Expected calls on span'; +}; + +subtest Error => sub { + is $test->request( GET '/error' ), object { + call code => 500; + }, 'Request OK'; + + is $span->{otel}, { + attributes => { + 'client.address' => '127.0.0.1', + 'client.port' => DNE, + 'http.request.method' => 'GET', + 'http.route' => '/error', + 'network.protocol.version' => '1.1', + 'server.address' => 'localhost', + 'server.port' => DNE, + 'url.path' => '/error', + 'url.scheme' => 'http', + 'user_agent.original' => DNE, + }, + kind => SPAN_KIND_SERVER, + name => 'GET /error', + parent => object { + prop isa => 'OpenTelemetry::Context'; + }, + }, 'Span created as expected'; + + span_calls [ + record_exception => [ match qr/oops at \S+ line [0-9]+\.$/ ], + set_status => [ SPAN_STATUS_ERROR, 'oops' ], + set_attribute => [ + 'error.type' => 'string', + 'http.response.status_code' => 500, + ], + end => [], + ], 'Expected calls on span'; +}; + +describe 'Host / port parsing' => sub { + my $port; + + case 'With port' => sub { $port = '1234' }; + case 'Without port' => sub { undef $port }; + + tests Host => sub { + is $test->request( + GET '/static/url', + host => join ':', 'some.doma.in', $port // (), + ), object { + call code => 200; + }, 'Request OK'; + + like $span->{otel}, { + attributes => { + 'server.address' => 'some.doma.in', + 'server.port' => $port ? $port : DNE, + }, + }; + }; + + tests 'X-Forwarded-Proto wins over Host' => sub { + is $test->request( + GET '/static/url', + Host => 'wrong.doma.in:9999', + 'X-Forwarded-Proto' => join ':', 'some.doma.in', $port // (), + ), object { + call code => 200; + }, 'Request OK'; + + like $span->{otel}, { + attributes => { + 'server.address' => 'some.doma.in', + 'server.port' => $port ? $port : DNE, + }, + }; + }; + + tests 'Forwarded wins over X-Forwarded-Proto' => sub { + is $test->request( + GET '/static/url', + Host => 'wrong.doma.in:9999', + 'X-Forwarded-Proto' => 'another.wrong.doma.in:8888', + Forwarded => 'host=' . join ':', 'some.doma.in', $port // (), + ), object { + call code => 200; + }, 'Request OK'; + + like $span->{otel}, { + attributes => { + 'server.address' => 'some.doma.in', + 'server.port' => $port ? $port : DNE, + }, + }; + }; + + tests 'Forwarded with multiple values' => sub { + is $test->request( + GET '/static/url', + Host => 'wrong.doma.in:9999', + 'X-Forwarded-Proto' => 'another.wrong.doma.in:8888', + Forwarded => 'host=' . join( ':', 'some.doma.in', $port // () ) + . ', host=wrong.doma.in:777', + ), object { + call code => 200; + }, 'Request OK'; + + like $span->{otel}, { + attributes => { + 'server.address' => 'some.doma.in', + 'server.port' => $port ? $port : DNE, + }, + }; + }; +}; + +done_testing;