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.

Expose local appview performance metrics

alice 20aa751b 9f139292

+307 -16
+33 -4
lib/ATProto/PDS/API/Util.pm
··· 56 56 my $target = lc($did // q()); 57 57 $target =~ s/%3a/:/ig; 58 58 my $cache = $c->can('stash') ? ($c->stash('resolve_did_account_cache') || {}) : {}; 59 - return $cache->{$target} if exists $cache->{$target}; 59 + if (exists $cache->{$target}) { 60 + _observe_repo_resolution($c, 'did_account', 'request_cache'); 61 + return $cache->{$target}; 62 + } 60 63 61 64 my $account = $c->store->get_account_by_did($did); 62 65 if ($account) { 66 + _observe_repo_resolution($c, 'did_account', 'exact'); 63 67 $cache->{$target} = $account if $c->can('stash'); 64 68 $c->stash(resolve_did_account_cache => $cache) if $c->can('stash'); 65 69 return $account; ··· 69 73 my $candidate = lc($row->{did} // q()); 70 74 $candidate =~ s/%3a/:/ig; 71 75 if ($candidate eq $target) { 76 + _observe_repo_resolution($c, 'did_account', 'list_scan'); 72 77 if ($c->can('stash')) { 73 78 $cache->{$target} = $row; 74 79 $c->stash(resolve_did_account_cache => $cache); ··· 80 85 $cache->{$target} = undef; 81 86 $c->stash(resolve_did_account_cache => $cache); 82 87 } 88 + _observe_repo_resolution($c, 'did_account', 'miss'); 83 89 return undef; 84 90 } 85 91 ··· 87 93 return undef unless defined $repo && length $repo; 88 94 my $cache = $c->can('stash') ? ($c->stash('resolve_repo_cache') || {}) : {}; 89 95 my $cache_key = lc($repo); 90 - return $cache->{$cache_key} if exists $cache->{$cache_key}; 96 + if (exists $cache->{$cache_key}) { 97 + _observe_repo_resolution($c, 'repo', 'request_cache'); 98 + return $cache->{$cache_key}; 99 + } 91 100 92 101 if ($repo !~ /\Adid:/i) { 93 102 my $normalized = normalize_handle($repo, $c->config_value('service_handle_domain', 'localhost')); 94 - my $account = $c->store->get_account_by_handle($repo) 95 - || (defined($normalized) ? $c->store->get_account_by_handle($normalized) : undef); 103 + my $account = $c->store->get_account_by_handle($repo); 104 + my $source = 'exact'; 105 + if (!$account && defined($normalized)) { 106 + $account = $c->store->get_account_by_handle($normalized); 107 + $source = 'normalized' if $account; 108 + } 96 109 if ($c->can('stash')) { 97 110 $cache->{$cache_key} = $account; 98 111 $cache->{ lc($normalized) } = $account if defined $normalized && length $normalized; 99 112 $c->stash(resolve_repo_cache => $cache); 100 113 } 114 + _observe_repo_resolution($c, 'repo', $account ? $source : 'miss'); 101 115 return $account; 102 116 } 103 117 my $account = resolve_did_account($c, $repo); ··· 105 119 $cache->{$cache_key} = $account; 106 120 $c->stash(resolve_repo_cache => $cache); 107 121 } 122 + _observe_repo_resolution($c, 'repo', $account ? 'did_account' : 'miss'); 108 123 return $account; 124 + } 125 + 126 + sub _observe_repo_resolution ($c, $resolver, $source) { 127 + return unless $c && $c->can('app'); 128 + my $app = eval { $c->app } or return; 129 + my $metrics = eval { $app->metrics } or return; 130 + $metrics->increment_counter( 131 + 'perlsky_repo_resolution_total', 132 + 1, 133 + { 134 + resolver => $resolver, 135 + source => $source, 136 + }, 137 + ); 109 138 } 110 139 111 140 sub subscription_start_seq ($c, %args) {
+42
lib/ATProto/PDS/Metrics.pm
··· 25 25 [0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10], 26 26 ); 27 27 $self->_register_counter( 28 + 'perlsky_service_proxy_requests_total', 29 + 'Total service-proxy requests handled locally or upstream by NSID, source, and status.', 30 + [qw(nsid source status)], 31 + ); 32 + $self->_register_histogram( 33 + 'perlsky_service_proxy_request_duration_seconds', 34 + 'Service-proxy request duration in seconds.', 35 + [qw(nsid source status)], 36 + [0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10], 37 + ); 38 + $self->_register_counter( 39 + 'perlsky_service_proxy_local_post_index_cache_access_total', 40 + 'Total local post-index cache accesses by result.', 41 + [qw(result)], 42 + ); 43 + $self->_register_histogram( 44 + 'perlsky_service_proxy_local_post_index_rebuild_duration_seconds', 45 + 'Local post-index rebuild duration in seconds.', 46 + [], 47 + [0.0005, 0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1], 48 + ); 49 + $self->_register_gauge( 50 + 'perlsky_service_proxy_local_post_index_entries', 51 + 'Current process-wide local post-index entry counts by kind.', 52 + [qw(kind)], 53 + ); 54 + $self->_register_counter( 55 + 'perlsky_service_proxy_local_post_resolution_total', 56 + 'Total local post URI resolutions by source.', 57 + [qw(source)], 58 + ); 59 + $self->_register_counter( 60 + 'perlsky_service_proxy_profile_record_cache_total', 61 + 'Total local profile record cache accesses by result.', 62 + [qw(result)], 63 + ); 64 + $self->_register_counter( 65 + 'perlsky_repo_resolution_total', 66 + 'Total repo/account resolution attempts by resolver path and source.', 67 + [qw(resolver source)], 68 + ); 69 + $self->_register_counter( 28 70 'perlsky_subscription_connections_total', 29 71 'Total websocket subscription connections opened.', 30 72 [qw(nsid)],
+41 -7
lib/ATProto/PDS/ServiceProxy.pm
··· 6 6 use Mojo::Base -base, -signatures; 7 7 use Mojo::URL; 8 8 use Mojo::UserAgent; 9 + use Time::HiRes qw(time); 9 10 10 11 use ATProto::PDS::API::Server qw(require_auth); 11 12 use ATProto::PDS::API::Util qw(xrpc_error); ··· 77 78 ); 78 79 79 80 sub proxy_xrpc_request ($self, $c, $nsid) { 81 + my $started = time; 80 82 if (my $handler = $LOCAL_HANDLER_FOR{$nsid}) { 81 - my $status = $self->$handler($c); 83 + my $status = eval { $self->$handler($c) }; 84 + if (my $err = $@) { 85 + if (ref($err) eq 'HASH' && $err->{error}) { 86 + _observe_service_proxy_metrics($c, $nsid, 'local', $err->{status} // 400, $started); 87 + } 88 + die $err; 89 + } 90 + _observe_service_proxy_metrics($c, $nsid, 'local', $status, $started) 91 + if defined $status; 82 92 return $status if defined $status; 83 93 } 84 94 ··· 127 137 ); 128 138 } 129 139 130 - my $res = $self->_perform_upstream_request( 131 - method => $method, 132 - url => $url, 133 - headers => \%headers, 134 - body => ($c->req->body // q()), 135 - ); 140 + my $res = eval { 141 + $self->_perform_upstream_request( 142 + method => $method, 143 + url => $url, 144 + headers => \%headers, 145 + body => ($c->req->body // q()), 146 + ); 147 + }; 148 + if (my $err = $@) { 149 + if (ref($err) eq 'HASH' && $err->{error}) { 150 + _observe_service_proxy_metrics($c, $nsid, 'upstream', $err->{status} // 502, $started); 151 + } 152 + die $err; 153 + } 136 154 137 155 my $status = $res->code // 502; 138 156 my $headers_out = $c->res->headers; ··· 161 179 status => $status, 162 180 data => $res->body, 163 181 ); 182 + _observe_service_proxy_metrics($c, $nsid, 'upstream', $status, $started); 164 183 return $status; 184 + } 185 + 186 + sub _observe_service_proxy_metrics ($c, $nsid, $source, $status, $started) { 187 + my $metrics = $c->app->metrics; 188 + my %labels = ( 189 + nsid => $nsid // 'unknown', 190 + source => $source // 'unknown', 191 + status => defined $status ? $status : 'unknown', 192 + ); 193 + $metrics->increment_counter('perlsky_service_proxy_requests_total', 1, \%labels); 194 + $metrics->observe_histogram( 195 + 'perlsky_service_proxy_request_duration_seconds', 196 + time - $started, 197 + \%labels, 198 + ); 165 199 } 166 200 167 201 1;
+23 -1
lib/ATProto/PDS/ServiceProxy/Posts.pm
··· 22 22 sub _resolve_local_post_uri ($self, $c, $uri) { 23 23 my $cache = $c->stash('service_proxy_local_post_uri_cache') || {}; 24 24 if (exists $cache->{$uri}) { 25 + $c->app->metrics->increment_counter( 26 + 'perlsky_service_proxy_local_post_resolution_total', 27 + 1, 28 + { source => 'request_cache' }, 29 + ); 25 30 return $cache->{$uri}; 26 31 } 27 32 28 33 my ($repo, $collection, $rkey) = parse_at_uri($uri); 29 34 return undef unless defined $repo && defined $collection && defined $rkey; 30 - my $account = resolve_repo($c, $repo) or return undef; 35 + my $account = resolve_repo($c, $repo) or do { 36 + $c->app->metrics->increment_counter( 37 + 'perlsky_service_proxy_local_post_resolution_total', 38 + 1, 39 + { source => 'non_local' }, 40 + ); 41 + return undef; 42 + }; 31 43 xrpc_error(404, 'RecordNotFound', 'Record was not found') 32 44 unless $collection eq 'app.bsky.feed.post'; 33 45 my $canonical_uri = 'at://' . $account->{did} . '/' . $collection . '/' . $rkey; 34 46 my $local_post_index = $c->stash('local_post_index'); 35 47 if ($local_post_index && $local_post_index->{posts}{$canonical_uri}) { 48 + $c->app->metrics->increment_counter( 49 + 'perlsky_service_proxy_local_post_resolution_total', 50 + 1, 51 + { source => 'index_cache' }, 52 + ); 36 53 my $resolved = $local_post_index->{posts}{$canonical_uri}; 37 54 $cache->{$uri} = $resolved; 38 55 $cache->{$canonical_uri} = $resolved; 39 56 $c->stash(service_proxy_local_post_uri_cache => $cache); 40 57 return $resolved; 41 58 } 59 + $c->app->metrics->increment_counter( 60 + 'perlsky_service_proxy_local_post_resolution_total', 61 + 1, 62 + { source => 'store' }, 63 + ); 42 64 my $row = $c->store->get_record($account->{did}, $collection, $rkey); 43 65 xrpc_error(404, 'RecordNotFound', 'Record was not found') unless $row; 44 66 my $resolved = [ $account, $row ];
+13 -1
lib/ATProto/PDS/ServiceProxy/Profile.pm
··· 51 51 52 52 sub _profile_record_value ($self, $c, $account) { 53 53 my $cache = $c->stash('service_proxy_profile_record_value_cache') || {}; 54 - return $cache->{ $account->{did} } if exists $cache->{ $account->{did} }; 54 + if (exists $cache->{ $account->{did} }) { 55 + $c->app->metrics->increment_counter( 56 + 'perlsky_service_proxy_profile_record_cache_total', 57 + 1, 58 + { result => 'hit' }, 59 + ); 60 + return $cache->{ $account->{did} }; 61 + } 55 62 63 + $c->app->metrics->increment_counter( 64 + 'perlsky_service_proxy_profile_record_cache_total', 65 + 1, 66 + { result => 'miss' }, 67 + ); 56 68 my $profile = $c->store->get_record($account->{did}, 'app.bsky.actor.profile', 'self'); 57 69 my $value = (ref($profile) eq 'HASH' && ref($profile->{value}) eq 'HASH') ? $profile->{value} : {}; 58 70 $cache->{ $account->{did} } = $value;
+44 -1
lib/ATProto/PDS/ServiceProxy/Threads.pm
··· 7 7 8 8 use Exporter 'import'; 9 9 use JSON::PP (); 10 + use Time::HiRes qw(time); 10 11 11 12 use ATProto::PDS::API::Server qw(require_auth); 12 13 use ATProto::PDS::API::Util qw(resolve_repo xrpc_error); ··· 205 206 206 207 sub _local_post_index ($self, $c) { 207 208 my $index = $c->stash('local_post_index'); 208 - return $index if $index; 209 + if ($index) { 210 + $c->app->metrics->increment_counter( 211 + 'perlsky_service_proxy_local_post_index_cache_access_total', 212 + 1, 213 + { result => 'request_cache_hit' }, 214 + ); 215 + return $index; 216 + } 209 217 210 218 my $event_seq = $c->store->latest_event_seq; 211 219 my $cache = $self->local_post_index_cache; 212 220 if ($cache && (($cache->{event_seq} // -1) == $event_seq)) { 221 + $c->app->metrics->increment_counter( 222 + 'perlsky_service_proxy_local_post_index_cache_access_total', 223 + 1, 224 + { result => 'process_cache_hit' }, 225 + ); 213 226 $c->stash(local_post_index => $cache->{index}); 214 227 return $cache->{index}; 215 228 } 216 229 230 + my $started = time; 217 231 $index = _build_local_post_index($self, $c); 232 + $c->app->metrics->increment_counter( 233 + 'perlsky_service_proxy_local_post_index_cache_access_total', 234 + 1, 235 + { result => 'rebuild' }, 236 + ); 237 + $c->app->metrics->observe_histogram( 238 + 'perlsky_service_proxy_local_post_index_rebuild_duration_seconds', 239 + time - $started, 240 + ); 241 + $c->app->metrics->set_gauge( 242 + 'perlsky_service_proxy_local_post_index_entries', 243 + scalar(keys %{ $index->{posts} }), 244 + { kind => 'posts' }, 245 + ); 246 + $c->app->metrics->set_gauge( 247 + 'perlsky_service_proxy_local_post_index_entries', 248 + scalar(keys %{ $index->{replies} }), 249 + { kind => 'reply_parents' }, 250 + ); 251 + $c->app->metrics->set_gauge( 252 + 'perlsky_service_proxy_local_post_index_entries', 253 + scalar(keys %{ $index->{stats} }), 254 + { kind => 'stats' }, 255 + ); 256 + $c->app->metrics->set_gauge( 257 + 'perlsky_service_proxy_local_post_index_entries', 258 + scalar(keys %{ $index->{viewer} }), 259 + { kind => 'viewer_subjects' }, 260 + ); 218 261 $self->local_post_index_cache({ 219 262 event_seq => $event_seq, 220 263 index => $index,
+88 -1
t/metrics.t
··· 5 5 use File::Spec; 6 6 use File::Temp qw(tempdir); 7 7 use FindBin qw($Bin); 8 + use Mojo::Util qw(url_escape); 8 9 use Test::More; 9 10 10 11 BEGIN { ··· 45 46 password => 'hunter22', 46 47 })->status_is(200); 47 48 48 - my $access = $t->tx->res->json->{accessJwt}; 49 + my $created = $t->tx->res->json; 50 + my $access = $created->{accessJwt}; 51 + my $did = $created->{did}; 49 52 50 53 $t->post_ok('/xrpc/com.atproto.repo.uploadBlob' => { 51 54 Authorization => "Bearer $access", 52 55 'Content-Type' => 'text/plain', 53 56 } => 'hello')->status_is(200); 54 57 58 + $t->post_ok('/xrpc/com.atproto.repo.createRecord' => { 59 + Authorization => "Bearer $access", 60 + } => json => { 61 + repo => $did, 62 + collection => 'app.bsky.feed.post', 63 + rkey => 'metrics-root', 64 + record => { 65 + '$type' => 'app.bsky.feed.post', 66 + text => 'metrics root', 67 + createdAt => '2026-03-11T19:00:00Z', 68 + }, 69 + })->status_is(200); 70 + 71 + my $root_post = $t->tx->res->json; 72 + 73 + $t->post_ok('/xrpc/com.atproto.repo.createRecord' => { 74 + Authorization => "Bearer $access", 75 + } => json => { 76 + repo => $did, 77 + collection => 'app.bsky.feed.post', 78 + rkey => 'metrics-reply', 79 + record => { 80 + '$type' => 'app.bsky.feed.post', 81 + text => 'metrics reply', 82 + reply => { 83 + root => { uri => $root_post->{uri}, cid => $root_post->{cid} }, 84 + parent => { uri => $root_post->{uri}, cid => $root_post->{cid} }, 85 + }, 86 + createdAt => '2026-03-11T19:01:00Z', 87 + }, 88 + })->status_is(200); 89 + 90 + $t->get_ok("/xrpc/app.bsky.actor.getProfile?actor=$did" => { 91 + Authorization => "Bearer $access", 92 + })->status_is(200); 93 + 94 + $t->get_ok("/xrpc/app.bsky.feed.getAuthorFeed?actor=$did&limit=10" => { 95 + Authorization => "Bearer $access", 96 + })->status_is(200); 97 + 98 + $t->get_ok('/xrpc/app.bsky.feed.getPostThread?uri=' . url_escape('at://' . $did . '/app.bsky.feed.post/metrics-reply') => { 99 + Authorization => "Bearer $access", 100 + })->status_is(200); 101 + 55 102 $t->websocket_ok('/xrpc/com.atproto.sync.subscribeRepos') 56 103 ->finish_ok; 57 104 ··· 95 142 $metrics, 96 143 qr/perlsky_store_operations_total\{operation="append_event",status="ok"\} [1-9]\d*\b/, 97 144 'store operation counters are exported', 145 + ); 146 + like( 147 + $metrics, 148 + qr/perlsky_service_proxy_requests_total\{nsid="app\.bsky\.actor\.getProfile",source="local",status="200"\} 1\b/, 149 + 'local service-proxy request counters are exported', 150 + ); 151 + like( 152 + $metrics, 153 + qr/perlsky_service_proxy_request_duration_seconds_count\{nsid="app\.bsky\.feed\.getPostThread",source="local",status="200"\} 1\b/, 154 + 'local service-proxy latency histograms are exported', 155 + ); 156 + like( 157 + $metrics, 158 + qr/perlsky_service_proxy_local_post_index_cache_access_total\{result="rebuild"\} [1-9]\d*\b/, 159 + 'local post-index rebuild counters are exported', 160 + ); 161 + like( 162 + $metrics, 163 + qr/perlsky_service_proxy_local_post_index_cache_access_total\{result="process_cache_hit"\} [1-9]\d*\b/, 164 + 'local post-index process-cache hits are exported', 165 + ); 166 + like( 167 + $metrics, 168 + qr/perlsky_service_proxy_local_post_index_entries\{kind="posts"\} 2\b/, 169 + 'local post-index entry gauges are exported', 170 + ); 171 + like( 172 + $metrics, 173 + qr/perlsky_service_proxy_local_post_resolution_total\{source="index_cache"\} [1-9]\d*\b/, 174 + 'local post-resolution source counters are exported', 175 + ); 176 + like( 177 + $metrics, 178 + qr/perlsky_service_proxy_profile_record_cache_total\{result="miss"\} [1-9]\d*\b/, 179 + 'profile cache metrics are exported', 180 + ); 181 + like( 182 + $metrics, 183 + qr/perlsky_repo_resolution_total\{resolver="did_account",source="exact"\} [1-9]\d*\b/, 184 + 'repo-resolution cache metrics are exported', 98 185 ); 99 186 like( 100 187 $metrics,
+23 -1
t/service-proxy-local.t
··· 85 85 } 86 86 87 87 { 88 + package LocalTestMetrics; 89 + 90 + sub increment_counter { return 1 } 91 + sub observe_histogram { return 1 } 92 + sub set_gauge { return 1 } 93 + } 94 + 95 + { 96 + package LocalTestApp; 97 + 98 + sub metrics { return bless {}, 'LocalTestMetrics' } 99 + } 100 + 101 + { 88 102 package LocalTestContext; 89 103 90 104 sub new { 91 105 my ($class, $store) = @_; 92 - return bless { store => $store }, $class; 106 + return bless { 107 + store => $store, 108 + app => bless({}, 'LocalTestApp'), 109 + }, $class; 93 110 } 94 111 95 112 sub store { 96 113 my ($self) = @_; 97 114 return $self->{store}; 115 + } 116 + 117 + sub app { 118 + my ($self) = @_; 119 + return $self->{app}; 98 120 } 99 121 100 122 sub config_value {