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 basic Sentry exception reporting

alice f35a0526 7c4eafea

+310
+12
docs/DEPLOYMENT.md
··· 63 63 "jwt_secret": "REPLACE_WITH_A_RANDOM_SECRET", 64 64 "admin_password": "REPLACE_WITH_A_RANDOM_SECRET", 65 65 "metrics_token": "REPLACE_WITH_A_RANDOM_SECRET", 66 + "sentry_dsn": "https://PUBLIC_KEY@o0.ingest.sentry.io/0", 66 67 "bsky_appview_url": "https://api.bsky.app", 67 68 "bsky_appview_did": "did:web:api.bsky.app", 68 69 "chat_service_url": "https://api.bsky.chat", ··· 80 81 - `hostname`: the host relays should crawl 81 82 - `service_handle_domain`: the suffix used for local handles 82 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 83 85 - If you want users like `alice.pds.example.com`, set `service_handle_domain` to `pds.example.com`, not `example.com`. 84 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. 85 87 - `invite_code_required`: if true, `createAccount` requires a valid invite code ··· 327 329 - [ops/grafana/perlsky-dashboard.json](../ops/grafana/perlsky-dashboard.json) 328 330 329 331 See [METRICS.md](./METRICS.md) for the metric surface and dashboard notes. 332 + 333 + ## Sentry 334 + 335 + If you want exception reporting in addition to Prometheus metrics, add `sentry_dsn` to `/etc/perlsky/perlsky.json`. 336 + 337 + The current integration is intentionally narrow: 338 + 339 + - it reports unhandled XRPC exceptions 340 + - it does not report ordinary handled XRPC errors like `InvalidToken` 341 + - it is a no-op when `sentry_dsn` is unset 330 342 331 343 ## Prometheus 332 344
+7
docs/METRICS.md
··· 105 105 106 106 The dashboard expects a Prometheus data source. When provisioning, either keep the checked-in `uid` from the example data source or update the dashboard's `${DS_PROMETHEUS}` mapping during import. 107 107 108 + ## Sentry 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: 111 + 112 + - `perlsky_xrpc_errors_total` for handled request failures 113 + - `perlsky_xrpc_unhandled_exceptions_total` for internal 500-class failures 114 + 108 115 ## Example Scrape 109 116 110 117 ```sh
+9
lib/ATProto/PDS.pm
··· 20 20 use ATProto::PDS::LexiconRegistry; 21 21 use ATProto::PDS::Metrics; 22 22 use ATProto::PDS::Repo::Manager; 23 + use ATProto::PDS::Sentry; 23 24 use ATProto::PDS::ServiceProxy; 24 25 use ATProto::PDS::Store::SQLite; 25 26 use ATProto::PDS::XRPC::Dispatcher; ··· 70 71 $crawler_notifier->{store} = $c->store; 71 72 $crawler_notifier; 72 73 }; 74 + }); 75 + $self->helper(sentry => sub ($c) { 76 + state $sentry = ATProto::PDS::Sentry->new( 77 + dsn => $c->app->settings->{sentry_dsn}, 78 + environment => $ENV{MOJO_MODE} || 'development', 79 + server_name => ($c->app->settings->{hostname} // lc($public_url->host // 'localhost')), 80 + service => $config->{service_name} // 'perlsky', 81 + ); 73 82 }); 74 83 $self->helper(append_event => sub ($c, %args) { 75 84 my $seq = $c->store->append_event(%args);
+136
lib/ATProto/PDS/Sentry.pm
··· 1 + package ATProto::PDS::Sentry; 2 + 3 + use v5.34; 4 + use warnings; 5 + use feature 'signatures'; 6 + no warnings 'experimental::signatures'; 7 + 8 + use Digest::SHA qw(sha1_hex); 9 + use Mojo::JSON qw(false); 10 + use Mojo::URL; 11 + use Mojo::UserAgent; 12 + use POSIX qw(strftime); 13 + 14 + sub new ($class, %args) { 15 + my $parsed = _parse_dsn($args{dsn}); 16 + return bless { 17 + dsn => $args{dsn}, 18 + parsed_dsn => $parsed, 19 + environment => $args{environment} // 'production', 20 + release => $args{release}, 21 + server_name => $args{server_name}, 22 + logger => $args{logger}, 23 + service => $args{service} // 'perlsky', 24 + timeout => $args{timeout} // 2, 25 + ua => undef, 26 + }, $class; 27 + } 28 + 29 + sub enabled ($self) { 30 + return $self->{parsed_dsn} ? 1 : 0; 31 + } 32 + 33 + sub capture_exception ($self, %args) { 34 + return 0 unless $self->enabled; 35 + 36 + my $message = $args{message} // 'Unhandled exception'; 37 + my $event = { 38 + event_id => substr(sha1_hex(join q{|}, time, $$, rand(), $message), 0, 32), 39 + timestamp => strftime('%Y-%m-%dT%H:%M:%SZ', gmtime), 40 + platform => 'perl', 41 + level => $args{level} // 'error', 42 + logger => $self->{service}, 43 + environment => $self->{environment}, 44 + ($self->{release} ? (release => $self->{release}) : ()), 45 + ($self->{server_name} ? (server_name => $self->{server_name}) : ()), 46 + tags => { 47 + service => $self->{service}, 48 + nsid => $args{nsid} // 'unknown', 49 + endpoint_type => $args{endpoint_type} // 'unknown', 50 + status => ($args{status} // 500), 51 + (defined $args{method} ? (method => $args{method}) : ()), 52 + }, 53 + exception => { 54 + values => [ 55 + { 56 + type => $args{type} // 'UnhandledXRPCException', 57 + value => $message, 58 + }, 59 + ], 60 + }, 61 + }; 62 + 63 + if (my $c = $args{context}) { 64 + my $req = $c->req; 65 + $event->{request} = { 66 + method => $req->method, 67 + url => $req->url->to_abs->to_string, 68 + headers => { 69 + map { $_ => scalar $req->headers->header($_) } 70 + grep { lc($_) ne 'authorization' && lc($_) ne 'cookie' } 71 + $req->headers->names->@* 72 + }, 73 + }; 74 + } 75 + 76 + if (my $did = $args{did}) { 77 + $event->{user} = { id => $did }; 78 + } 79 + 80 + my $tx = eval { 81 + $self->_ua->post( 82 + $self->{parsed_dsn}{store_url} => { 83 + 'Content-Type' => 'application/json', 84 + 'X-Sentry-Auth' => $self->_auth_header, 85 + } => json => $event 86 + ); 87 + }; 88 + return 0 unless $tx; 89 + my $code = eval { $tx->result->code } // 0; 90 + return ($code >= 200 && $code < 300) ? 1 : 0; 91 + } 92 + 93 + sub _ua ($self) { 94 + return $self->{ua} if $self->{ua}; 95 + my $ua = Mojo::UserAgent->new(max_redirects => 0); 96 + $ua->request_timeout($self->{timeout}); 97 + $ua->inactivity_timeout($self->{timeout}); 98 + $ua->connect_timeout($self->{timeout}); 99 + return $self->{ua} = $ua; 100 + } 101 + 102 + sub _auth_header ($self) { 103 + my $parsed = $self->{parsed_dsn}; 104 + my @parts = ( 105 + 'Sentry sentry_version=7', 106 + 'sentry_client=' . $self->{service} . '/1.0', 107 + 'sentry_key=' . $parsed->{public_key}, 108 + ); 109 + push @parts, 'sentry_secret=' . $parsed->{secret_key} 110 + if defined $parsed->{secret_key} && length $parsed->{secret_key}; 111 + return join(', ', @parts); 112 + } 113 + 114 + sub _parse_dsn ($dsn) { 115 + return undef unless defined $dsn && length $dsn; 116 + my $url = Mojo::URL->new($dsn); 117 + my $project_id = pop @{ $url->path->parts }; 118 + die "invalid sentry_dsn: missing project id\n" unless defined $project_id && length $project_id; 119 + my @prefix = @{ $url->path->parts }; 120 + my $store = $url->clone; 121 + $store->userinfo(undef); 122 + $store->path('/' . join('/', grep { length } @prefix, 'api', $project_id, 'store') . '/'); 123 + $store->query({ 124 + sentry_key => $url->username, 125 + sentry_version => 7, 126 + ((defined($url->password) && length($url->password)) ? (sentry_secret => $url->password) : ()), 127 + }); 128 + return { 129 + public_key => $url->username, 130 + secret_key => $url->password, 131 + project_id => $project_id, 132 + store_url => $store->to_string, 133 + }; 134 + } 135 + 136 + 1;
+12
lib/ATProto/PDS/XRPC/Dispatcher.pm
··· 160 160 if (ref($err) eq 'HASH' && $err->{error}) { 161 161 return $render_error->($err->{status} // 400, $err->{error}, $err->{message} // $err->{error}, $endpoint->{type}, $endpoint->{id}); 162 162 } 163 + my ($claims) = eval { $c->req->headers->authorization ? ATProto::PDS::API::Server::require_auth($c) : () }; 164 + eval { 165 + $c->sentry->capture_exception( 166 + context => $c, 167 + message => "$err", 168 + method => $method, 169 + nsid => $endpoint->{id}, 170 + endpoint_type => $endpoint->{type}, 171 + status => 500, 172 + (ref($claims) eq 'HASH' && defined($claims->{sub}) ? (did => $claims->{sub}) : ()), 173 + ); 174 + }; 163 175 return $render_internal_error->($err, $endpoint->{type}, $endpoint->{id}); 164 176 } 165 177
+134
t/sentry.t
··· 1 + use v5.34; 2 + use warnings; 3 + 4 + use Config (); 5 + use File::Spec; 6 + use File::Temp qw(tempdir); 7 + use FindBin qw($Bin); 8 + use Test::More; 9 + 10 + BEGIN { 11 + require lib; 12 + my $root = File::Spec->rel2abs(File::Spec->catdir($Bin, '..')); 13 + lib->import( 14 + File::Spec->catdir($root, 'lib'), 15 + File::Spec->catdir($root, 'local', 'lib', 'perl5'), 16 + File::Spec->catdir($root, 'local', 'lib', 'perl5', $Config::Config{archname}), 17 + ); 18 + } 19 + 20 + use Test::Mojo; 21 + use ATProto::PDS; 22 + use ATProto::PDS::Sentry; 23 + 24 + { 25 + package SentryTestTx; 26 + 27 + sub new { 28 + my ($class, $code) = @_; 29 + return bless { code => $code }, $class; 30 + } 31 + 32 + sub result { 33 + my ($self) = @_; 34 + return bless { code => $self->{code} }, 'SentryTestResult'; 35 + } 36 + } 37 + 38 + { 39 + package SentryTestResult; 40 + 41 + sub code { 42 + my ($self) = @_; 43 + return $self->{code}; 44 + } 45 + } 46 + 47 + { 48 + package SentryTestUA; 49 + 50 + sub new { 51 + my ($class, $sink) = @_; 52 + return bless { sink => $sink }, $class; 53 + } 54 + 55 + sub post { 56 + my ($self, $url, $headers, %rest) = @_; 57 + push @{ $self->{sink} }, { 58 + url => $url, 59 + headers => $headers, 60 + payload => $rest{json}, 61 + }; 62 + return SentryTestTx->new(200); 63 + } 64 + } 65 + 66 + my @requests; 67 + my $sentry = ATProto::PDS::Sentry->new( 68 + dsn => 'http://public:secret@127.0.0.1:9999/42', 69 + environment => 'test', 70 + server_name => 'perlsky.test', 71 + service => 'perlsky', 72 + ); 73 + $sentry->{ua} = SentryTestUA->new(\@requests); 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', 79 + method => 'GET', 80 + nsid => 'com.atproto.server.describeServer', 81 + endpoint_type => 'query', 82 + status => 500, 83 + did => 'did:plc:test', 84 + ), 85 + 'capture_exception reports success for a 200 response', 86 + ); 87 + is(scalar @requests, 1, 'capture_exception submits one store request'); 88 + is($requests[0]{url}, 'http://127.0.0.1:9999/api/42/store/?sentry_key=public&sentry_secret=secret&sentry_version=7', 'dsn is converted into the expected store URL'); 89 + like($requests[0]{headers}{'X-Sentry-Auth'}, qr/sentry_key=public/, 'sentry auth header includes the public key'); 90 + is($requests[0]{payload}{tags}{nsid}, 'com.atproto.server.describeServer', 'payload includes the nsid tag'); 91 + is($requests[0]{payload}{exception}{values}[0]{type}, 'UnhandledXRPCException', 'payload includes exception type'); 92 + like($requests[0]{payload}{exception}{values}[0]{value}, qr/intentional sentry test failure/, 'payload includes exception message'); 93 + is($requests[0]{payload}{user}{id}, 'did:plc:test', 'payload includes the actor did when available'); 94 + 95 + my $root = File::Spec->rel2abs(File::Spec->catdir($Bin, '..')); 96 + my $tmp = tempdir(CLEANUP => 1); 97 + my $app = ATProto::PDS->new( 98 + project_root => $root, 99 + settings => { 100 + base_url => 'http://127.0.0.1:7755', 101 + service_did_method => 'did:web', 102 + service_handle_domain => 'test', 103 + jwt_secret => 'sentry-test-secret', 104 + sentry_dsn => 'http://public:secret@127.0.0.1:9999/42', 105 + db_path => File::Spec->catfile($tmp, 'sentry.sqlite'), 106 + data_dir => File::Spec->catdir($tmp, 'data'), 107 + }, 108 + ); 109 + 110 + my @captured; 111 + { 112 + no warnings 'redefine'; 113 + local *ATProto::PDS::Sentry::capture_exception = sub { 114 + my ($self, %args) = @_; 115 + push @captured, \%args; 116 + return 1; 117 + }; 118 + 119 + $app->api_registry->register('com.atproto.server.describeServer', sub { 120 + die "intentional dispatcher sentry failure\n"; 121 + }); 122 + 123 + my $t = Test::Mojo->new($app); 124 + $t->get_ok('/xrpc/com.atproto.server.describeServer') 125 + ->status_is(500) 126 + ->json_is('/error' => 'InternalServerError'); 127 + } 128 + 129 + is(scalar @captured, 1, 'dispatcher reports an unhandled xrpc exception to sentry'); 130 + is($captured[0]{nsid}, 'com.atproto.server.describeServer', 'dispatcher passes the xrpc nsid to sentry'); 131 + is($captured[0]{endpoint_type}, 'query', 'dispatcher passes the endpoint type to sentry'); 132 + like($captured[0]{message}, qr/intentional dispatcher sentry failure/, 'dispatcher passes the exception message to sentry'); 133 + 134 + done_testing;