···8181- `hostname`: the host relays should crawl
8282- `service_handle_domain`: the suffix used for local handles
8383- `jwt_secret`: required; the server now refuses to start if it is missing or still set to the old `perlsky-dev-secret` fallback
8484-- `sentry_dsn`: optional; when set, perlsky reports unhandled XRPC exceptions to Sentry
8484+- `sentry_dsn`: optional; when set, perlsky reports unhandled XRPC exceptions to Sentry with request context and Perl stack frames
8585- If you want users like `alice.pds.example.com`, set `service_handle_domain` to `pds.example.com`, not `example.com`.
8686- 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.
8787- `invite_code_required`: if true, `createAccount` requires a valid invite code
···337337The current integration is intentionally narrow:
338338339339- it reports unhandled XRPC exceptions
340340+- the Sentry event includes request metadata and Perl stack frames
340341- it does not report ordinary handled XRPC errors like `InvalidToken`
341342- it is a no-op when `sentry_dsn` is unset
342343
+1-1
docs/METRICS.md
···107107108108## Sentry
109109110110-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:
110110+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:
111111112112- `perlsky_xrpc_errors_total` for handled request failures
113113- `perlsky_xrpc_unhandled_exceptions_total` for internal 500-class failures
+82
lib/ATProto/PDS/Sentry.pm
···3434 return 0 unless $self->enabled;
35353636 my $message = $args{message} // 'Unhandled exception';
3737+ my $frames = _stacktrace_frames($message);
3738 my $event = {
3839 event_id => substr(sha1_hex(join q{|}, time, $$, rand(), $message), 0, 32),
3940 timestamp => strftime('%Y-%m-%dT%H:%M:%SZ', gmtime),
···5556 {
5657 type => $args{type} // 'UnhandledXRPCException',
5758 value => $message,
5959+ (@$frames ? (stacktrace => { frames => $frames }) : ()),
5860 },
5961 ],
6062 },
···8890 return 0 unless $tx;
8991 my $code = eval { $tx->result->code } // 0;
9092 return ($code >= 200 && $code < 300) ? 1 : 0;
9393+}
9494+9595+sub _stacktrace_frames ($message) {
9696+ my @frames = _message_stack_frames($message)->@*;
9797+ my %seen = map { _frame_key($_) => 1 } @frames;
9898+9999+ for my $frame (_caller_stack_frames()->@*) {
100100+ my $key = _frame_key($frame);
101101+ next if $seen{$key}++;
102102+ push @frames, $frame;
103103+ }
104104+105105+ return \@frames;
106106+}
107107+108108+sub _message_stack_frames ($message) {
109109+ return [] unless defined $message && length $message;
110110+111111+ my @frames;
112112+ my @lines = split /\n/, $message;
113113+ if (@lines && $lines[0] =~ / at (.+) line (\d+)\.?$/) {
114114+ push @frames, {
115115+ filename => $1,
116116+ function => '<exception>',
117117+ module => undef,
118118+ lineno => 0 + $2,
119119+ in_app => _in_app_filename($1),
120120+ };
121121+ }
122122+123123+ for my $line (@lines[1 .. $#lines]) {
124124+ next unless $line =~ /^\s*(.+?) called at (.+) line (\d+)\.?$/;
125125+ push @frames, {
126126+ filename => $2,
127127+ function => $1,
128128+ module => _module_from_function($1),
129129+ lineno => 0 + $3,
130130+ in_app => _in_app_filename($2),
131131+ };
132132+ }
133133+134134+ return \@frames;
135135+}
136136+137137+sub _caller_stack_frames () {
138138+ my @frames;
139139+ my $level = 1;
140140+ while (my @caller = caller($level++)) {
141141+ my ($package, $filename, $line, $subroutine) = @caller[0 .. 3];
142142+ 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/;
143143+ push @frames, {
144144+ filename => $filename,
145145+ function => $subroutine // '<main>',
146146+ module => $package,
147147+ lineno => 0 + $line,
148148+ in_app => _in_app_filename($filename),
149149+ };
150150+ }
151151+ return [ reverse @frames ];
152152+}
153153+154154+sub _frame_key ($frame) {
155155+ return join "\x1F",
156156+ map { defined $_ ? $_ : q() }
157157+ @{$frame}{qw(filename function lineno)};
158158+}
159159+160160+sub _module_from_function ($function) {
161161+ return undef unless defined $function && length $function;
162162+ return $1 if $function =~ /\A(.+)::[^:]+\z/;
163163+ return undef;
164164+}
165165+166166+sub _in_app_filename ($filename) {
167167+ return 0 unless defined $filename && length $filename;
168168+ return 0 if $filename =~ /^\(eval/;
169169+ return 0 if $filename =~ m{(?:^|/)(?:core_perl|site_perl|vendor_perl)(?:/|$)};
170170+ return 0 if $filename =~ m{(?:^|/)local/lib/perl5(?:/|$)};
171171+ return 0 if $filename =~ m{^/usr/};
172172+ return 1;
91173}
9217493175sub _ua ($self) {
+34-5
t/sentry.t
···7272);
7373$sentry->{ua} = SentryTestUA->new(\@requests);
74747575-ok($sentry->enabled, 'sentry client is enabled when a DSN is configured');
7676-ok(
7777- $sentry->capture_exception(
7878- message => 'intentional sentry test failure',
7575+sub sentry_nested_failure {
7676+ die "intentional sentry test failure";
7777+}
7878+7979+sub sentry_emit_nested_failure {
8080+ eval { sentry_nested_failure(); 1 };
8181+ my $err = $@;
8282+ return $sentry->capture_exception(
8383+ message => $err,
7984 method => 'GET',
8085 nsid => 'com.atproto.server.describeServer',
8186 endpoint_type => 'query',
8287 status => 500,
8388 did => 'did:plc:test',
8484- ),
8989+ );
9090+}
9191+9292+ok($sentry->enabled, 'sentry client is enabled when a DSN is configured');
9393+ok(
9494+ sentry_emit_nested_failure(),
8595 'capture_exception reports success for a 200 response',
8696);
8797is(scalar @requests, 1, 'capture_exception submits one store request');
···91101is($requests[0]{payload}{exception}{values}[0]{type}, 'UnhandledXRPCException', 'payload includes exception type');
92102like($requests[0]{payload}{exception}{values}[0]{value}, qr/intentional sentry test failure/, 'payload includes exception message');
93103is($requests[0]{payload}{user}{id}, 'did:plc:test', 'payload includes the actor did when available');
104104+ok(
105105+ ref($requests[0]{payload}{exception}{values}[0]{stacktrace}{frames}) eq 'ARRAY'
106106+ && @{$requests[0]{payload}{exception}{values}[0]{stacktrace}{frames}},
107107+ 'payload includes stacktrace frames',
108108+);
109109+ok(
110110+ scalar(grep {
111111+ ($_->{filename} // q()) =~ m{(?:^|/)t/sentry\.t$}
112112+ && ($_->{function} // q()) =~ /sentry_nested_failure|<exception>/
113113+ } @{$requests[0]{payload}{exception}{values}[0]{stacktrace}{frames}}),
114114+ 'payload stacktrace points at the test failure site',
115115+);
116116+ok(
117117+ scalar(grep {
118118+ ($_->{filename} // q()) =~ m{(?:^|/)t/sentry\.t$}
119119+ && ($_->{function} // q()) =~ /sentry_emit_nested_failure/
120120+ } @{$requests[0]{payload}{exception}{values}[0]{stacktrace}{frames}}),
121121+ 'payload stacktrace includes the caller frame',
122122+);
9412395124my $root = File::Spec->rel2abs(File::Spec->catdir($Bin, '..'));
96125my $tmp = tempdir(CLEANUP => 1);