ForgeOps-Tracker

 view release on metacpan or  search on metacpan

t/trace_context.t  view on Meta::CPAN


subtest 'parses a valid traceparent, trimming whitespace' => sub {
    is_deeply(ForgeOps::Tracker::TraceParent::parse($HEADER), { trace_id => $TRACE_ID, parent_span_id => $PARENT_ID });
    is(ForgeOps::Tracker::TraceParent::parse(" $HEADER\n")->{trace_id}, $TRACE_ID);
};

subtest 'accepts a future version with extra fields' => sub {
    is(ForgeOps::Tracker::TraceParent::parse("01-$TRACE_ID-$PARENT_ID-01-extra")->{trace_id}, $TRACE_ID);
};

subtest 'rejects invalid traceparents' => sub {
    my %invalid = (
        'undef'                     => undef,
        'a reference'               => [],
        'blank'                     => '',
        'garbage'                   => 'not-a-traceparent',
        'uppercase hex'             => '00-' . uc($TRACE_ID) . "-$PARENT_ID-01",
        'version ff'                => "ff-$TRACE_ID-$PARENT_ID-01",
        'all-zero trace id'         => '00-' . ('0' x 32) . "-$PARENT_ID-01",
        'all-zero parent id'        => "00-$TRACE_ID-" . ('0' x 16) . '-01',
        'short trace id'            => '00-' . substr($TRACE_ID, 1) . "-$PARENT_ID-01",
        'extra field on version 00' => "$HEADER-extra",
        'missing flags'             => "00-$TRACE_ID-$PARENT_ID",
    );
    for my $name (sort keys %invalid) {
        is(ForgeOps::Tracker::TraceParent::parse($invalid{$name}), undef, $name);
    }
};

subtest 'builds and generates W3C ids' => sub {
    is(ForgeOps::Tracker::TraceParent::build($TRACE_ID, $PARENT_ID), $HEADER);
    like(ForgeOps::Tracker::TraceParent::generate_trace_id(), qr/\A[0-9a-f]{32}\z/);
    like(ForgeOps::Tracker::TraceParent::generate_span_id(), qr/\A[0-9a-f]{16}\z/);
};

subtest 'propagates to every host by default' => sub {
    my $config = ForgeOps::Tracker::Configuration->new;
    is($config->{propagate_traces}, 1);
    is($config->{trace_propagation_targets}, undef);
    ok($config->should_propagate_trace('anything.example'));
    $config->{propagate_traces} = 0;
    ok(!$config->should_propagate_trace('anything.example'), 'propagate_traces off never propagates');
};

subtest 'matches targets as hosts on a dot boundary, or as regular expressions' => sub {
    my $config = ForgeOps::Tracker::Configuration->new;
    $config->{trace_propagation_targets} = ['.Example.com', qr/\A10\.0\./];

    ok($config->should_propagate_trace('example.com'));
    ok($config->should_propagate_trace('API.example.com'));
    ok(!$config->should_propagate_trace('badexample.com'));
    ok(!$config->should_propagate_trace('example.com.evil.test'));
    ok($config->should_propagate_trace('10.0.3.4'));
    ok(!$config->should_propagate_trace('110.0.3.4'));
    ok(!$config->should_propagate_trace(undef));

    $config->{trace_propagation_targets} = [];
    ok(!$config->should_propagate_trace('example.com'), 'an empty list propagates nowhere');
};

subtest 'errors reported during a request carry its trace id, name and endpoint, unscrubbed' => sub {
    init_tracker();
    ForgeOps::Tracker::start_trace($HEADER);
    # An email-shaped route segment would be scrubbed anywhere else in the payload.
    ForgeOps::Tracker::set_request_route('GET /users/:a@b.co', 'GET /users/:a@b.co');
    ForgeOps::Tracker::report("boom\n");
    ForgeOps::Tracker::finish_trace('GET /users/:a@b.co', Time::HiRes::time, 5);

    is(reported()->[0]{trace_id}, $TRACE_ID);
    is(reported()->[0]{transaction_name}, 'GET /users/:a@b.co');
    is(reported()->[0]{endpoint}, 'GET /users/:a@b.co');
};

subtest 'errors outside a request are unchanged' => sub {
    init_tracker();
    ForgeOps::Tracker::report("before\n");
    ForgeOps::Tracker::start_trace();
    ForgeOps::Tracker::finish_trace('GET /x', Time::HiRes::time, 5);
    ForgeOps::Tracker::report("after\n");

    for my $event (@{ reported() }) {
        ok(!exists $event->{$_}, "no $_") for qw(trace_id transaction_name endpoint);
    }
    is(ForgeOps::Tracker::current_trace_id(), undef);
};

subtest 'a trace id exists even with tracing off, and no trace is sent' => sub {
    init_tracker(track_tracing => 0);
    ForgeOps::Tracker::start_trace($HEADER);
    is(ForgeOps::Tracker::current_trace_id(), $TRACE_ID);
    ForgeOps::Tracker::report("boom\n");
    ForgeOps::Tracker::finish_trace('GET /x', Time::HiRes::time, 5000);

    is(reported()->[0]{trace_id}, $TRACE_ID);
    ok(!exists reported()->[0]{endpoint}, 'an endpoint nobody set is left out');
    is_deeply(sent_traces(), []);
};

subtest 'nothing starts when the client is not enabled' => sub {
    init_tracker(environment => 'development');
    ForgeOps::Tracker::start_trace($HEADER);
    is(ForgeOps::Tracker::current_trace_id(), undef);
};

subtest "a continued trace's root span points at the caller's span; a fresh one has no parent" => sub {
    init_tracker(trace_capture_threshold => 0.001);
    ForgeOps::Tracker::start_trace($HEADER);
    ForgeOps::Tracker::finish_trace('GET /x', Time::HiRes::time, 50);
    ForgeOps::Tracker::start_trace('00-ffffffffffffffffffffffffffffffff-zzzzzzzzzzzzzzzz-01');
    ForgeOps::Tracker::finish_trace('GET /y', Time::HiRes::time, 50);

    is(sent_traces()->[0]{trace_id}, $TRACE_ID);
    is(sent_traces()->[0]{spans}[0]{parent_span_id}, $PARENT_ID);
    like(sent_traces()->[1]{trace_id}, qr/\A[0-9a-f]{32}\z/);
    is(sent_traces()->[1]{spans}[0]{parent_span_id}, undef);
};

subtest 'a fast request that errored still sends its trace' => sub {
    init_tracker();
    ForgeOps::Tracker::start_trace();
    ForgeOps::Tracker::report("handled\n");
    ForgeOps::Tracker::finish_trace('GET /fast', Time::HiRes::time, 1);
    ForgeOps::Tracker::start_trace();
    ForgeOps::Tracker::finish_trace('GET /fast-and-fine', Time::HiRes::time, 1);

    is(scalar(@{ sent_traces() }), 1);
    is(sent_traces()->[0]{spans}[0]{name}, 'GET /fast');
    is(sent_traces()->[0]{trace_id}, reported()->[0]{trace_id});
};

subtest 'an error snapshotted on its way out keeps its context when reported afterwards' => sub {
    init_tracker();
    my $object = bless { message => 'object error' }, 'My::Error';
    ForgeOps::Tracker::start_trace($HEADER);
    ForgeOps::Tracker::set_request_route('POST /checkout', 'POST /checkout');
    ForgeOps::Tracker::set_user(id => 7);
    ForgeOps::Tracker::add_breadcrumb('clicked pay');
    ForgeOps::Tracker::snapshot_onto("string error\n");
    ForgeOps::Tracker::snapshot_onto($object);
    ForgeOps::Tracker::finish_trace('POST /checkout', Time::HiRes::time, 1);
    ForgeOps::Tracker::set_user();
    ForgeOps::Tracker::clear_breadcrumbs();

    ForgeOps::Tracker::report("string error\n");
    ForgeOps::Tracker::report($object);
    ForgeOps::Tracker::report("unrelated\n");

    for my $event (@{ reported() }[0, 1]) {
        is($event->{trace_id}, $TRACE_ID);
        is($event->{endpoint}, 'POST /checkout');
        is_deeply($event->{user}, { id => 7 });
        is($event->{breadcrumbs}[0]{message}, 'clicked pay');
    }
    ok(!exists reported()->[2]{trace_id}, 'an unrelated error gets nothing');
    is(scalar(@{ sent_traces() }), 1, 'snapshotting marked the fast request errored');
};

subtest 'http_span hands over a traceparent naming its own recorded span' => sub {
    init_tracker();
    ForgeOps::Tracker::start_trace($HEADER);
    my $sent;
    my $result = ForgeOps::Tracker::http_span(post => 'https://Payments.Example.com:8443/charges/42?token=x', sub {
        ($sent) = @_;
        return 'response';
    }, data => { attempt => 1 });
    ForgeOps::Tracker::report("boom\n");
    ForgeOps::Tracker::finish_trace('POST /checkout', Time::HiRes::time, 1);

    is($result, 'response');
    my $parsed = ForgeOps::Tracker::TraceParent::parse($sent->{traceparent});
    is($parsed->{trace_id}, $TRACE_ID);
    my $http = sent_traces()->[0]{spans}[1];
    is($http->{name}, 'POST payments.example.com');
    is($http->{kind}, 'http');
    is($http->{span_id}, $parsed->{parent_span_id});
    is($http->{parent_span_id}, sent_traces()->[0]{spans}[0]{span_id});
    is_deeply($http->{data}, { attempt => 1 });
};

subtest 'http_span records and re-raises when the call dies' => sub {
    init_tracker(trace_capture_threshold => 0);
    ForgeOps::Tracker::start_trace();
    eval { ForgeOps::Tracker::http_span(GET => 'https://api.example.com/', sub { die "connection refused\n" }) };
    is($@, "connection refused\n");
    ForgeOps::Tracker::finish_trace('GET /x', Time::HiRes::time, 1);

    is(sent_traces()->[0]{spans}[1]{name}, 'GET api.example.com');
};

subtest 'http_span sends no header to a host outside the targets, or with propagation off' => sub {
    my $config = init_tracker(trace_propagation_targets => ['internal.example']);
    ForgeOps::Tracker::start_trace();
    my $outside = ForgeOps::Tracker::http_span(GET => 'https://api.thirdparty.test/x', sub { $_[0] });
    my $inside = ForgeOps::Tracker::http_span(GET => 'https://orders.internal.example/x', sub { $_[0] });
    $config->{propagate_traces} = 0;
    my $off = ForgeOps::Tracker::http_span(GET => 'https://orders.internal.example/x', sub { $_[0] });

    is_deeply($outside, {});
    ok(exists $inside->{traceparent});
    is_deeply($off, {});
};

subtest 'http_span still propagates with tracing off but records nothing' => sub {
    init_tracker(track_tracing => 0);
    ForgeOps::Tracker::start_trace($HEADER);
    my $headers = ForgeOps::Tracker::http_span(GET => 'https://api.example.com/', sub { $_[0] });
    ForgeOps::Tracker::finish_trace('GET /x', Time::HiRes::time, 5000);

    is(ForgeOps::Tracker::TraceParent::parse($headers->{traceparent})->{trace_id}, $TRACE_ID);
    is_deeply(sent_traces(), []);
};

subtest 'http_span outside a request just runs the call with no headers' => sub {
    init_tracker();
    is_deeply(ForgeOps::Tracker::http_span(GET => 'https://api.example.com/', sub { $_[0] }), {});
};

subtest 'PSGI: continues the incoming trace, and an escaped error keeps it when reported outside' => sub {
    init_tracker();
    my $app = builder {
        enable '+ForgeOps::Tracker::Integrations::PSGI';
        enable '+ForgeOps::Tracker::Integrations::PSGIPerformance';
        sub {
            my $env = shift;
            ForgeOps::Tracker::report("handled\n") if $env->{PATH_INFO} eq '/handled';
            die "route exploded\n" if $env->{PATH_INFO} eq '/boom';
            return [200, ['Content-Type' => 'text/plain'], ['ok']];
        };
    };

    test_psgi $app, sub {
        my $cb = shift;
        $cb->(GET '/handled', traceparent => $HEADER);
        $cb->(GET '/boom');
    };

    my ($handled, $escaped) = @{ reported() };
    is($handled->{trace_id}, $TRACE_ID);
    ok(!exists $handled->{endpoint}, 'plain PSGI has no route pattern');
    like($escaped->{trace_id}, qr/\A[0-9a-f]{32}\z/);
    isnt($escaped->{trace_id}, $TRACE_ID, 'a request without the header starts its own trace');
    is(scalar(@{ sent_traces() }), 2, 'both fast requests errored, so both traces were sent');
    is(sent_traces()->[0]{spans}[0]{parent_span_id}, $PARENT_ID);
    is(sent_traces()->[1]{trace_id}, $escaped->{trace_id});
    is(ForgeOps::Tracker::current_trace_id(), undef, 'nothing leaks past the request');
};

# Dancer2 is an optional integration, not something `cpanm ForgeOps::Tracker` installs to run these
# tests, so its apps are compiled only when it is there and its subtests skip otherwise. A string
# eval, since `use Dancer2` inside a plain package block would fail at compile time without it.
my $have_dancer2 = eval { require Dancer2; 1 };

# Loaded in both orders: Dancer2 runs the two plugins' on_route_exception hooks in load order, and
# the report has to carry the request context either way.
if ($have_dancer2) {
    eval <<'APPS' or die $@;
    package ErrorsFirstApp {
        use Dancer2;
        use ForgeOps::Tracker::Integrations::Dancer2;
        use ForgeOps::Tracker::Integrations::Dancer2Performance;

        set apphandler => 'PSGI';
        set startup_info => 0;
        set logger => 'Null';

        get '/orders/:id' => sub { ForgeOps::Tracker::report("handled\n"); return 'ok'; };
        post '/checkout/:cart' => sub { die "route exploded\n"; };
    }

    package PerformanceFirstApp {
        use Dancer2;
        use ForgeOps::Tracker::Integrations::Dancer2Performance;
        use ForgeOps::Tracker::Integrations::Dancer2;

        set apphandler => 'PSGI';
        set startup_info => 0;
        set logger => 'Null';

        post '/checkout/:cart' => sub { die "route exploded\n"; };
    }
    1;
APPS
}

SKIP: {
    skip 'Dancer2 is not installed (only the optional Dancer2 integration needs it)', 3 unless $have_dancer2;

    subtest 'Dancer2: an error reported inside a route carries the continued trace and the route pattern' => sub {
        init_tracker();
        test_psgi(ErrorsFirstApp->to_app, sub { $_[0]->(GET '/orders/42', traceparent => $HEADER) });

        is(reported()->[0]{trace_id}, $TRACE_ID);
        is(reported()->[0]{transaction_name}, 'GET /orders/:id');
        is(reported()->[0]{endpoint}, 'GET /orders/:id');
        is(sent_traces()->[0]{spans}[0]{parent_span_id}, $PARENT_ID, 'fast, but errored: sent');
    };

    for my $app_class (qw(ErrorsFirstApp PerformanceFirstApp)) {
        subtest "Dancer2 ($app_class): a route that dies is reported with its context and its trace is sent" => sub {
            init_tracker();
            my $res;
            test_psgi($app_class->to_app, sub { $res = $_[0]->(POST '/checkout/9', traceparent => $HEADER) });

            is($res->code, 500);
            is(scalar(@{ reported() }), 1);
            is(reported()->[0]{trace_id}, $TRACE_ID);
            is(reported()->[0]{endpoint}, 'POST /checkout/:cart');
            is(scalar(@{ sent_traces() }), 1);
            is(sent_traces()->[0]{spans}[0]{name}, 'POST /checkout/:cart');
            is(ForgeOps::Tracker::current_trace_id(), undef, 'nothing leaks past the request');
        };
    }
}

done_testing;



( run in 0.809 second using v1.01-cache-2.11-cpan-16aabcbfbcb )