perlsky is a Perl 5 implementation of an AT Protocol Personal Data Server.
13
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add Perl stack traces to Sentry events

alice 2b0558a4 a7c33ac3

+119 -7
+2 -1
docs/DEPLOYMENT.md
··· 81 81 - `hostname`: the host relays should crawl 82 82 - `service_handle_domain`: the suffix used for local handles 83 83 - `jwt_secret`: required; the server now refuses to start if it is missing or still set to the old `perlsky-dev-secret` fallback 84 - - `sentry_dsn`: optional; when set, perlsky reports unhandled XRPC exceptions to Sentry 84 + - `sentry_dsn`: optional; when set, perlsky reports unhandled XRPC exceptions to Sentry with request context and Perl stack frames 85 85 - If you want users like `alice.pds.example.com`, set `service_handle_domain` to `pds.example.com`, not `example.com`. 86 86 - Public handle resolution for `alice.pds.example.com` also requires wildcard DNS for `*.pds.example.com` and a reverse proxy/TLS setup that will answer those subdomains. 87 87 - `invite_code_required`: if true, `createAccount` requires a valid invite code ··· 337 337 The current integration is intentionally narrow: 338 338 339 339 - it reports unhandled XRPC exceptions 340 + - the Sentry event includes request metadata and Perl stack frames 340 341 - it does not report ordinary handled XRPC errors like `InvalidToken` 341 342 - it is a no-op when `sentry_dsn` is unset 342 343
+1 -1
docs/METRICS.md
··· 107 107 108 108 ## Sentry 109 109 110 - Prometheus is still the main place to watch rates and latency. If you also configure `sentry_dsn`, perlsky will report unhandled XRPC exceptions to Sentry. That works well as a complement to: 110 + Prometheus is still the main place to watch rates and latency. If you also configure `sentry_dsn`, perlsky will report unhandled XRPC exceptions to Sentry with request metadata and Perl stack frames. That works well as a complement to: 111 111 112 112 - `perlsky_xrpc_errors_total` for handled request failures 113 113 - `perlsky_xrpc_unhandled_exceptions_total` for internal 500-class failures
+82
lib/ATProto/PDS/Sentry.pm
··· 34 34 return 0 unless $self->enabled; 35 35 36 36 my $message = $args{message} // 'Unhandled exception'; 37 + my $frames = _stacktrace_frames($message); 37 38 my $event = { 38 39 event_id => substr(sha1_hex(join q{|}, time, $$, rand(), $message), 0, 32), 39 40 timestamp => strftime('%Y-%m-%dT%H:%M:%SZ', gmtime), ··· 55 56 { 56 57 type => $args{type} // 'UnhandledXRPCException', 57 58 value => $message, 59 + (@$frames ? (stacktrace => { frames => $frames }) : ()), 58 60 }, 59 61 ], 60 62 }, ··· 88 90 return 0 unless $tx; 89 91 my $code = eval { $tx->result->code } // 0; 90 92 return ($code >= 200 && $code < 300) ? 1 : 0; 93 + } 94 + 95 + sub _stacktrace_frames ($message) { 96 + my @frames = _message_stack_frames($message)->@*; 97 + my %seen = map { _frame_key($_) => 1 } @frames; 98 + 99 + for my $frame (_caller_stack_frames()->@*) { 100 + my $key = _frame_key($frame); 101 + next if $seen{$key}++; 102 + push @frames, $frame; 103 + } 104 + 105 + return \@frames; 106 + } 107 + 108 + sub _message_stack_frames ($message) { 109 + return [] unless defined $message && length $message; 110 + 111 + my @frames; 112 + my @lines = split /\n/, $message; 113 + if (@lines && $lines[0] =~ / at (.+) line (\d+)\.?$/) { 114 + push @frames, { 115 + filename => $1, 116 + function => '<exception>', 117 + module => undef, 118 + lineno => 0 + $2, 119 + in_app => _in_app_filename($1), 120 + }; 121 + } 122 + 123 + for my $line (@lines[1 .. $#lines]) { 124 + next unless $line =~ /^\s*(.+?) called at (.+) line (\d+)\.?$/; 125 + push @frames, { 126 + filename => $2, 127 + function => $1, 128 + module => _module_from_function($1), 129 + lineno => 0 + $3, 130 + in_app => _in_app_filename($2), 131 + }; 132 + } 133 + 134 + return \@frames; 135 + } 136 + 137 + sub _caller_stack_frames () { 138 + my @frames; 139 + my $level = 1; 140 + while (my @caller = caller($level++)) { 141 + my ($package, $filename, $line, $subroutine) = @caller[0 .. 3]; 142 + next if defined $subroutine && $subroutine =~ /\AATProto::PDS::Sentry::(?:capture_exception|_stacktrace_frames|_message_stack_frames|_caller_stack_frames|_frame_key|_module_from_function|_in_app_filename)\z/; 143 + push @frames, { 144 + filename => $filename, 145 + function => $subroutine // '<main>', 146 + module => $package, 147 + lineno => 0 + $line, 148 + in_app => _in_app_filename($filename), 149 + }; 150 + } 151 + return [ reverse @frames ]; 152 + } 153 + 154 + sub _frame_key ($frame) { 155 + return join "\x1F", 156 + map { defined $_ ? $_ : q() } 157 + @{$frame}{qw(filename function lineno)}; 158 + } 159 + 160 + sub _module_from_function ($function) { 161 + return undef unless defined $function && length $function; 162 + return $1 if $function =~ /\A(.+)::[^:]+\z/; 163 + return undef; 164 + } 165 + 166 + sub _in_app_filename ($filename) { 167 + return 0 unless defined $filename && length $filename; 168 + return 0 if $filename =~ /^\(eval/; 169 + return 0 if $filename =~ m{(?:^|/)(?:core_perl|site_perl|vendor_perl)(?:/|$)}; 170 + return 0 if $filename =~ m{(?:^|/)local/lib/perl5(?:/|$)}; 171 + return 0 if $filename =~ m{^/usr/}; 172 + return 1; 91 173 } 92 174 93 175 sub _ua ($self) {
+34 -5
t/sentry.t
··· 72 72 ); 73 73 $sentry->{ua} = SentryTestUA->new(\@requests); 74 74 75 - ok($sentry->enabled, 'sentry client is enabled when a DSN is configured'); 76 - ok( 77 - $sentry->capture_exception( 78 - message => 'intentional sentry test failure', 75 + sub sentry_nested_failure { 76 + die "intentional sentry test failure"; 77 + } 78 + 79 + sub sentry_emit_nested_failure { 80 + eval { sentry_nested_failure(); 1 }; 81 + my $err = $@; 82 + return $sentry->capture_exception( 83 + message => $err, 79 84 method => 'GET', 80 85 nsid => 'com.atproto.server.describeServer', 81 86 endpoint_type => 'query', 82 87 status => 500, 83 88 did => 'did:plc:test', 84 - ), 89 + ); 90 + } 91 + 92 + ok($sentry->enabled, 'sentry client is enabled when a DSN is configured'); 93 + ok( 94 + sentry_emit_nested_failure(), 85 95 'capture_exception reports success for a 200 response', 86 96 ); 87 97 is(scalar @requests, 1, 'capture_exception submits one store request'); ··· 91 101 is($requests[0]{payload}{exception}{values}[0]{type}, 'UnhandledXRPCException', 'payload includes exception type'); 92 102 like($requests[0]{payload}{exception}{values}[0]{value}, qr/intentional sentry test failure/, 'payload includes exception message'); 93 103 is($requests[0]{payload}{user}{id}, 'did:plc:test', 'payload includes the actor did when available'); 104 + ok( 105 + ref($requests[0]{payload}{exception}{values}[0]{stacktrace}{frames}) eq 'ARRAY' 106 + && @{$requests[0]{payload}{exception}{values}[0]{stacktrace}{frames}}, 107 + 'payload includes stacktrace frames', 108 + ); 109 + ok( 110 + scalar(grep { 111 + ($_->{filename} // q()) =~ m{(?:^|/)t/sentry\.t$} 112 + && ($_->{function} // q()) =~ /sentry_nested_failure|<exception>/ 113 + } @{$requests[0]{payload}{exception}{values}[0]{stacktrace}{frames}}), 114 + 'payload stacktrace points at the test failure site', 115 + ); 116 + ok( 117 + scalar(grep { 118 + ($_->{filename} // q()) =~ m{(?:^|/)t/sentry\.t$} 119 + && ($_->{function} // q()) =~ /sentry_emit_nested_failure/ 120 + } @{$requests[0]{payload}{exception}{values}[0]{stacktrace}{frames}}), 121 + 'payload stacktrace includes the caller frame', 122 + ); 94 123 95 124 my $root = File::Spec->rel2abs(File::Spec->catdir($Bin, '..')); 96 125 my $tmp = tempdir(CLEANUP => 1);