ForgeOps-Tracker
view release on metacpan or search on metacpan
lib/ForgeOps/Tracker/EventBuilder.pm view on Meta::CPAN
package ForgeOps::Tracker::EventBuilder;
use strict;
use warnings;
use POSIX qw(strftime);
use ForgeOps::Tracker::PiiScrubber qw(scrub scrub_string);
use ForgeOps::Tracker::SqlStatement ();
my $MAX_FRAMES = 500;
# How many lines of source to grab on either side of the culprit line (see
# _attach_source_context), and the longest a single captured line is allowed to be before getting
# truncated: guards against a single pathological minified/generated line ballooning the
# payload. ForgeOps itself re-truncates on arrival too, the same "don't just trust the SDK"
# posture $MAX_FRAMES already gets on the server side.
my $CONTEXT_LINES = 5;
my $MAX_CONTEXT_LINE_LENGTH = 500;
# Identifies this client to the server's auto language-detection on the project the event lands
# in (see Project#note_sdk_platform server-side); matches this repo's own sdks/perl directory
# name, the same convention every other language's client follows.
my $SDK_NAME = 'perl';
sub new {
my ($class, $configuration) = @_;
return bless { configuration => $configuration }, $class;
}
# build($error, \%context): $error can be:
# - a blessed exception object exposing ->message (or overloaded stringification) and,
# optionally, ->trace returning a Devel::StackTrace-compatible object (frames() ->
# filename/line/subroutine): e.g. Throwable::Error, Moo::Exception-based classes.
# - a plain scalar, typically $@ after `die "..."` or `Carp::confess "..."`: Perl appends
# " at FILE line N." to any die message that doesn't already end in "\n", and Carp::confess
# appends a full "\tPACKAGE::sub(...) called at FILE line N" chain on top of that; both are
# parsed below into real backtrace frames rather than left as one opaque string, verified
# directly against real confess()/die output before relying on the format, not assumed from
# documentation alone.
#
# \%request describes the request the error happened in: transaction_name (the same
# "GET /users/:id" name the request's root span and performance sample use), endpoint (the HTTP
# method plus the route pattern), and trace_id (the request's 32-character lowercase hex W3C trace
# id, which is also what links this error to errors other services reported for the same trace).
# Each is left out of the payload when undef, and all three are undef outside a request.
sub build {
my ($self, $error, $context, $user, $breadcrumbs, $request) = @_;
$context ||= {};
my $config = $self->{configuration};
my ($exception_class, $message, $backtrace) = $self->_analyze($error);
my %payload = (
exception_class => $exception_class,
message => $message,
backtrace => $backtrace,
occurred_at => strftime('%Y-%m-%dT%H:%M:%SZ', gmtime),
environment => $config->{environment},
release => $config->{release},
server_name => $config->{server_name},
context => { %$context },
tags => {},
sdk_name => $SDK_NAME,
);
$payload{user} = { %$user } if $user && %$user;
# Omitted entirely (never sent as an empty array) when there's nothing to report.
$payload{breadcrumbs} = [ map { { %$_ } } @$breadcrumbs ] if $breadcrumbs && @$breadcrumbs;
for my $key (qw(transaction_name endpoint trace_id)) {
$payload{$key} = $request->{$key} if $request && defined $request->{$key};
}
$self->_attach_sql(\%payload, $error);
return $config->{scrub_pii} ? $self->_scrub_payload(\%payload) : \%payload;
}
# See SqlStatement for what's read off the error and how it's masked. The statement itself only
# goes out when capture_sql_statement is on; the extracted names go out on their own
# (capture_sql_objects) so an issue can still name the procedure or view involved.
sub _attach_sql {
my ($self, $payload, $error) = @_;
my $config = $self->{configuration};
return unless $config->{capture_sql_objects} || $config->{capture_sql_statement};
my $masked = ForgeOps::Tracker::SqlStatement::mask(ForgeOps::Tracker::SqlStatement::find_in($error));
return unless defined $masked;
my $objects = ForgeOps::Tracker::SqlStatement::objects($masked);
$payload->{sql_objects} = $objects if $objects && $config->{capture_sql_objects};
$payload->{sql_statement} = $masked if $config->{capture_sql_statement};
return;
}
sub _analyze {
my ($self, $error) = @_;
if (ref $error && eval { $error->can('message') }) {
my $exception_class = ref $error;
my $message = $error->message;
my $backtrace = eval { $error->can('trace') } ? $self->_frames_from_stack_trace($error->trace) : [];
return ($exception_class, $message, $backtrace);
}
# Plain scalar (the common case: $@). "RuntimeError" mirrors the other SDKs' own fallback
# exception_class for an error with no more specific type available.
my $text = "$error";
my ($message, $backtrace) = $self->_parse_die_text($text);
return ('RuntimeError', $message, $backtrace);
}
sub _frames_from_stack_trace {
my ($self, $trace) = @_;
my @frames;
for my $frame ($trace->frames) {
last if @frames >= $MAX_FRAMES;
push @frames, $self->_frame($frame->filename, $frame->line, $frame->subroutine);
}
return \@frames;
}
sub _parse_die_text {
my ($self, $text) = @_;
my @lines = split /\n/, $text;
return ('', []) unless @lines;
my @frames;
my $message = shift @lines;
# "MESSAGE at FILE line N.": what Perl itself appends to any die string not already ending
# in "\n", and the first line Carp::confess/croak produce too.
lib/ForgeOps/Tracker/EventBuilder.pm view on Meta::CPAN
sub _in_app {
my ($self, $file) = @_;
my $root = $self->{configuration}{app_root};
return 0 unless $file && $root;
return 0 unless index($file, $root) == 0;
# A vendored/system library installed alongside the app's own lib/ directory is never in_app,
# the same exclusion every other SDK applies for its own package-manager directory.
return $file !~ m{/(?:local|vendor)/lib/perl5/};
}
# Reads a few lines of source straight off disk around the culprit line, at die/confess-time, in
# the same running process the error came from. Gated on two things: the frame has to be in_app
# (never a vendored/system library: there'd be nothing meaningful to show, and it's not the host
# app's own code to begin with), and configuration's capture_source_context has to be true (see
# Configuration for why it defaults to true and why ForgeOps' own per-project setting, not this
# flag, is the durable, protected way to turn it off). Best-effort: any file that can't be opened
# (deleted, permission denied, a path that only ever existed inside a build step and isn't present
# in this deployment) just means this one frame gets no source context, never a die of its own.
sub _attach_source_context {
my ($self, $frame) = @_;
return $frame unless $self->{configuration}{capture_source_context} && $frame->{in_app};
my $lines = $self->_read_source_lines($frame->{file});
return $frame unless $lines;
my $index = $frame->{line} - 1;
return $frame unless $index >= 0 && $index <= $#$lines;
my $from = $index - $CONTEXT_LINES;
$from = 0 if $from < 0;
my $to = $index + $CONTEXT_LINES;
$to = $#$lines if $to > $#$lines;
$frame->{context_line} = _truncate_line($lines->[$index]);
$frame->{pre_context} = [ map { _truncate_line($_) } @{$lines}[$from .. $index - 1] ];
$frame->{post_context} = [ map { _truncate_line($_) } @{$lines}[$index + 1 .. $to] ];
return $frame;
}
# Isolated into its own method (rather than inlined into _attach_source_context) so tests can
# monkeypatch it to prove no read is ever attempted when capture_source_context is off.
sub _read_source_lines {
my ($self, $file) = @_;
return undef unless defined $file && length $file;
open(my $fh, '<', $file) or return undef;
my @lines = <$fh>;
close $fh;
chomp @lines;
return \@lines;
}
sub _truncate_line {
my ($line) = @_;
return $line if length($line) <= $MAX_CONTEXT_LINE_LENGTH;
return substr($line, 0, $MAX_CONTEXT_LINE_LENGTH) . '...';
}
# exception_class/occurred_at/environment/release/server_name/sdk_name/user/transaction_name/
# endpoint/trace_id are left alone: structured fields this client or the host app sets
# deliberately, not free text an exception or its context could accidentally spill sensitive data
# into (endpoint in particular is a declared route pattern, never the literal path). user specifically is a deliberate
# exemption, not an oversight: scrub_string's own email pattern would otherwise redact the exact
# thing this field exists to carry (the %scrubbed copy below never touches it either way).
sub _scrub_payload {
my ($self, $payload) = @_;
my %scrubbed = %$payload;
$scrubbed{message} = scrub_string($payload->{message});
$scrubbed{sql_statement} = scrub_string($payload->{sql_statement}) if defined $payload->{sql_statement};
$scrubbed{backtrace} = [
map {
my %frame = %$_;
$frame{file} = scrub_string($frame{file}) if defined $frame{file};
$frame{method} = scrub_string($frame{method}) if defined $frame{method};
if (exists $frame{context_line}) {
$frame{context_line} = scrub_string($frame{context_line});
$frame{pre_context} = [ map { scrub_string($_) } @{ $frame{pre_context} } ];
$frame{post_context} = [ map { scrub_string($_) } @{ $frame{post_context} } ];
}
\%frame;
} @{ $payload->{backtrace} }
];
# Breadcrumb message/data are free text the host app or an integration wrote; category, level,
# and timestamp are structured values set deliberately, the same split as the top-level
# fields above, so they are left alone.
if ($payload->{breadcrumbs}) {
$scrubbed{breadcrumbs} = [
map {
my %crumb = %$_;
$crumb{message} = scrub_string($crumb{message}) if defined $crumb{message};
$crumb{data} = scrub($crumb{data});
\%crumb;
} @{ $payload->{breadcrumbs} }
];
}
$scrubbed{context} = scrub($payload->{context});
$scrubbed{tags} = scrub($payload->{tags});
return \%scrubbed;
}
1;
( run in 0.600 second using v1.01-cache-2.11-cpan-16aabcbfbcb )