diff --git a/lib/Etsy/StatsD.pm b/lib/Etsy/StatsD.pm index 2cfabfb..a9f6b79 100644 --- a/lib/Etsy/StatsD.pm +++ b/lib/Etsy/StatsD.pm @@ -11,78 +11,58 @@ our $VERSION = 1.002002; # https://github.com/etsy/statsd (in the exmaples directory), so you can get # it from either location. -=head1 NAME - -Etsy::StatsD - Object-Oriented Client for Etsy's StatsD Server - -=head1 SYNOPSIS - - use Etsy::StatsD; - - # Increment a counter - my $statsd = Etsy::StatsD->new(); - $statsd->increment( 'app.method.success' ); - - - # Time something - use Time::HiRes; - - my $start_time = time; - $app->do_stuff; - my $done_time = time; - - # Timers are expected in milliseconds - $statsd->timing( 'app.method', ($done_time - $start_time) * 1000 ); - - # Send to two StatsD Endpoints simultaneously - my $repl_statsd = Etsy::StatsD->new(["statsd1","statsd2"]); - - # On two different ports: - my $repl_statsd = Etsy::StatsD->new(["statsd1","statsd1:8126"]); - - # Use TCP to a collector (you must specify a port) - my $important_stats = Etsy::StatsD->new(["bizstats1:8125:tcp"]); - - -=head1 DESCRIPTION +sub import { + my $class = shift; -=cut + return unless @_; -=over + my $varname; + my $config = {}; -=item new (HOST, PORT, SAMPLE_RATE) + while ( $_ = shift @_) { + if (/^\$(statsd?)/) { + $varname = $1; + } + else { + $config->{$_} = shift @_; # pairwise + } + } -Create a new instance. + if (%$config) { + $class->configure($config); + } -=over + if ($varname) { + my $caller = caller(); -=item HOST + no strict 'refs'; + my $full_varname = "${caller}::$varname"; + # my $statsd = $class->get_statsd; + # *$full_varname = \$statsd; -If the argument is a string, it must be a hostname or IP only. The default is -'localhost'. The argument may also be an array reference of strings in the -form of "", ":", or "::". If the port is -not specified, the default port specified by the PORT argument will be used. -If the protocol is not specified, or is not "tcp" or "udp", "udp" will be set. -The only way to change the protocol, is to specify the host, port and protocol. - -=item PORT - -Default is 8125. Will be used as the default port for any HOST argument not explicitly defining port. + # Defer object creation because we need config that will be availabe at run time + my $deferred = Etsy::StatsD::_Deferred->new; + *$full_varname = \$deferred; + } +} -=item SAMPLE_RATE +sub new { + my ( $class, $host, $port, $sample_rate, $prefix, $suffix ) = @_; -Default is undefined, or no sampling performed. Specify a rate as a decimal between 0 and 1 to enable -sampling. e.g. 0.5 for 50%. + if ( ref($host) eq 'HASH' ) { + my $args = $host; -=back + $host = $args->{host}; + $port = $args->{port}; -=cut + $sample_rate = $args->{sample_rate}; + $prefix = $args->{prefix}; + $suffix = $args->{suffix}; + } -sub new { - my ( $class, $host, $port, $sample_rate ) = @_; - $host = 'localhost' unless defined $host; - $port = 8125 unless defined $port; + $host = 'localhost' unless defined $host; + $port = 8125 unless defined $port; # Handle multiple connections and # allow different ports to be specified @@ -114,7 +94,7 @@ sub new { my @sockets = (); foreach my $conn ( @connections ) { - my $sock = new IO::Socket::INET( + my $sock = IO::Socket::INET->new( PeerAddr => $conn->[0], PeerPort => $conn->[1], Proto => $conn->[2], @@ -125,103 +105,348 @@ sub new { # Check that we have at least 1 socket to send to croak "Failed to initialize any sockets." unless @sockets; - bless { sockets => \@sockets, sample_rate => $sample_rate }, $class; + bless { + sockets => \@sockets, + sample_rate => $sample_rate, + prefix => $prefix // '', + suffix => $suffix // '', + }, $class; } -=item timing(STAT, TIME, SAMPLE_RATE) - -Log timing information +sub prefix { + my ( $self ) = @_; + @_ > 1 ? $self->{prefix} = $_[1] : $self->{prefix}; +} -=cut +sub suffix { + my ( $self ) = @_; + @_ > 1 ? $self->{suffix} = $_[1] : $self->{suffix}; +} sub timing { - my ( $self, $stat, $time, $sample_rate ) = @_; - $self->send( { $stat => "$time|ms" }, $sample_rate ); + my ( $self, $stat, $time, $sample_rate ) = @_; + $self->send( { $stat => "$time|ms" }, $sample_rate ); } -=item increment(STATS, SAMPLE_RATE) - -Increment one of more stats counters. - -=cut - sub increment { - my ( $self, $stats, $sample_rate ) = @_; - $self->update( $stats, 1, $sample_rate ); + my ( $self, $stats, $sample_rate ) = @_; + $self->update( $stats, 1, $sample_rate ); } -=item decrement(STATS, SAMPLE_RATE) - -Decrement one of more stats counters. - -=cut - sub decrement { - my ( $self, $stats, $sample_rate ) = @_; - $self->update( $stats, -1, $sample_rate ); + my ( $self, $stats, $sample_rate ) = @_; + $self->update( $stats, -1, $sample_rate ); } -=item update(STATS, DELTA, SAMPLE_RATE) - -Update one of more stats counters by arbitrary amounts. +sub update { + my ( $self, $stats, $delta, $sample_rate ) = @_; + $delta = 1 unless defined $delta; + my %data; + if ( ref($stats) eq 'ARRAY' ) { + %data = map { $_ => "$delta|c" } @$stats; + } + else { + %data = ( $stats => "$delta|c" ); + } + $self->send( \%data, $sample_rate ); +} -=cut +sub gauge { + my ( $self, $stats, $value, $sample_rate ) = @_; + $self->send( { $stats => "$value|g" }, $sample_rate ); +} -sub update { - my ( $self, $stats, $delta, $sample_rate ) = @_; - $delta = 1 unless defined $delta; - my %data; - if ( ref($stats) eq 'ARRAY' ) { - %data = map { $_ => "$delta|c" } @$stats; - } - else { - %data = ( $stats => "$delta|c" ); - } - $self->send( \%data, $sample_rate ); +sub set { + my ( $self, $stats, $value, $sample_rate ) = @_; + $self->send( { $stats => "$value|s" }, $sample_rate ); } -=item send(DATA, SAMPLE_RATE) +sub timer { + my ( $self, $stats, $sample_rate ) = @_; + return Etsy::StatsD::Timer->new( $self, $stats, $sample_rate ); +} -Sending logging data; implicitly called by most of the other methods. +sub send { + my ( $self, $data, $sample_rate ) = @_; + $sample_rate = $self->{sample_rate} unless defined $sample_rate; -=back + my $sampled_data; + if ( defined($sample_rate) and $sample_rate < 1 ) { + while ( my ( $stat, $value ) = each %$data ) { + $sampled_data->{$stat} = "$value|\@$sample_rate" if rand() <= $sample_rate; + } + } + else { + $sampled_data = $data; + } -=cut + return '0 but true' unless keys %$sampled_data; -sub send { - my ( $self, $data, $sample_rate ) = @_; - $sample_rate = $self->{sample_rate} unless defined $sample_rate; - - my $sampled_data; - if ( defined($sample_rate) and $sample_rate < 1 ) { - while ( my ( $stat, $value ) = each %$data ) { - $sampled_data->{$stat} = "$value|\@$sample_rate" if rand() <= $sample_rate; - } - } - else { - $sampled_data = $data; - } - - return '0 but true' unless keys %$sampled_data; - - #failures in any of this can be silently ignored - my $count = 0; - foreach my $socket ( @{ $self->{sockets} } ) { + #failures in any of this can be silently ignored + my $count = 0; + foreach my $socket ( @{ $self->{sockets} } ) { # calling keys() resets the each() iterator keys %$sampled_data; - while ( my ( $stat,$value ) = each %$sampled_data ) { - _send_to_sock($socket, "$stat:$value\n", 0); + while ( my ( $stat, $value ) = each %$sampled_data ) { + _send_to_sock($socket, $self->_metric_name($stat).":$value\n", 0); ++$count; } } - return $count; + return $count; +} + +{ + my $config = {}; + my $instances = []; + + sub configure { + my ( $class, @args ) = @_; + $config = ref($args[0]) eq 'HASH' ? $args[0] : { @args }; + + # clean out destroyed objects + # maybee it will be better to do in DESTROY + @$instances = grep { defined } @$instances; + + for (@$instances) { + $_->_update_options($config); + } + } + + sub get_statsd { + my ($class) = @_; + my $statsd = $class->new($config); + push( @$instances, $statsd ); + return $statsd; + } + + sub _update_options { + my ( $self, $options ) = @_; + my $new_statsd = ref($_[0])->new($_[1]); + %{ $self } = %{ $new_statsd }; + } } sub _send_to_sock( $$ ) { - my ($sock,$msg) = @_; + my ( $sock, $msg ) = @_; CORE::send( $sock, $msg, 0 ); } +sub _metric_name { + my ($self, $name) = @_; + join('', $self->{prefix}, $name, $self->{suffix}); +} + + +package Etsy::StatsD::Timer; + +use Time::HiRes; + +sub new { + my ( $class, $statsd, $metric, $sample_rate ) = @_; + + my ( undef, $file, $line ) = caller(1); + + return bless { + statsd => $statsd, + metric => $metric, + sample_rate => $sample_rate, + start => Time::HiRes::time, + file => $file, + line => $line, + is_finished => 0, + }, $class; +} + +sub finish { + my ($self) = @_; + + $self->{statsd}->timing( $self->{metric}, ( Time::HiRes::time - $self->{start} ) * 1000, $self->{sample_rate} ); + + $self->{is_finished} = 1; +} + +sub cancel { + my ($self) = @_; + $self->{is_finished} = 1; +} + +sub DESTROY { + my ($self) = @_; + warn sprintf( + "Destroy unfinished timer for metric %s started at %s line %s.\n", + $self->{statsd} ? $self->{statsd}->_metric_name( $self->{metric} ) : $self->{metric}, + $self->{file}, + $self->{line} + ) unless $_[0]->{is_finished}; +} + + +package Etsy::StatsD::_Deferred; + +sub new { + my ($class) = @_; + return bless {}, $class; +} + +sub AUTOLOAD { + my ($self, @args) = @_; + + my ($sub) = our $AUTOLOAD =~ /.*::(.+)$/; + + $_[0] = Etsy::StatsD->get_statsd; + + return $_[0]->$sub(@args); +} + +sub DESTROY { } + + +1; + + +__END__ + +=pod + +=encoding UTF-8 + +=head1 NAME + +Etsy::StatsD - Object-Oriented Client for Etsy's StatsD Server + +=head1 SYNOPSIS + + use Etsy::StatsD; + + # Increment a counter + my $statsd = Etsy::StatsD->new(); + $statsd->increment( 'app.method.success' ); + + + # Time something + use Time::HiRes; + + my $start_time = time; + $app->do_stuff; + my $done_time = time; + + # Timers are expected in milliseconds + $statsd->timing( 'app.method', ($done_time - $start_time) * 1000 ); + + # Send to two StatsD Endpoints simultaneously + my $repl_statsd = Etsy::StatsD->new(["statsd1","statsd2"]); + + # On two different ports: + my $repl_statsd = Etsy::StatsD->new(["statsd1","statsd1:8126"]); + + # Use TCP to a collector (you must specify a port) + my $important_stats = Etsy::StatsD->new(["bizstats1:8125:tcp"]); + + +=head1 METHODS + +=head2 new(HOST, PORT, SAMPLE_RATE, PREFIX, SUFFIX) + +Create a new instance. + +=over + +=item HOST + + +If the argument is a string, it must be a hostname or IP only. The default is +'localhost'. The argument may also be an array reference of strings in the +form of "", ":", or "::". If the port is +not specified, the default port specified by the PORT argument will be used. +If the protocol is not specified, or is not "tcp" or "udp", "udp" will be set. +The only way to change the protocol, is to specify the host, port and protocol. + +=item PORT + +Default is 8125. Will be used as the default port for any HOST argument not explicitly defining port. + +=item SAMPLE_RATE + +Default is undefined, or no sampling performed. Specify a rate as a decimal between 0 and 1 to enable +sampling. e.g. 0.5 for 50%. + +=item PREFIX + +Optional. Default is ''. Specify prefix for metric names. + +=item SUFFIX + +Optional. Default is ''. Specify suffix for metric names. + +=back + +=head2 prefix(STRING) + +A prefix to be prepended to all metric names. + +=head2 suffix(STRING) + +A suffix to be appended to all metric names. + +=head2 timing(STAT, TIME, SAMPLE_RATE) + +Log timing information + +=head2 increment(STATS, SAMPLE_RATE) + +Increment one of more stats counters. + +=head2 decrement(STATS, SAMPLE_RATE) + +Decrement one of more stats counters. + +=head2 update(STATS, DELTA, SAMPLE_RATE) + +Update one of more stats counters by arbitrary amounts. + +=head2 gauge(STATS, VALUE, SAMPLE_RATE) + +Send a value for the named gauge metric. + +=head2 set(STATS, VALUE, SAMPLE_RATE) + +Add a value to the unique set metric. + +=head2 timer(STATS, SAMPLE_RATE) + +Start timer for metric STATS. Return Etsy::StatsD::Timer object with C and C methods. + + my $timer = $statsd->timer('foo'); + ... + $timer->finish; + +=head2 send(DATA, SAMPLE_RATE) + +Sending logging data; implicitly called by most of the other methods. + +=head1 IMPORT SYNTACTIC SUGAR (EXPERIMENTAL) + +You can use L in well known L manner + +In a CPAN or other module: + + package Foo; + use Etsy::Statsd qw($statsd); + + # send a metric + $statsd->incemenet('foo.metrict'); + +In your application: + + use Foo; + use Etsy::StatsD '$statsd', host => '1.2.3.4'; + + $statsd->increment('main.metric'); + + # reconfigure Statsd options + # will affect all created $statsd objects + Etsy::StatsD->configure(host => '4.3.2.1'); + =head1 SEE ALSO L diff --git a/t/Etsy-StatsD.t b/t/Etsy-StatsD.t index e51546a..9a5aa53 100644 --- a/t/Etsy-StatsD.t +++ b/t/Etsy-StatsD.t @@ -1,5 +1,5 @@ use strict; -use Test::More tests=>19; +use Test::More tests => 30; use Test::MockModule; use Etsy::StatsD; @@ -7,9 +7,10 @@ my $module = Test::MockModule->new('Etsy::StatsD'); my $data; $module->mock( - send => sub { - $data = $_[1]; - } + _send_to_sock => sub ($$) { + chomp(my $value = $_[1]); + push(@$data, $value); + } ); my $bucket = 'test'; @@ -19,30 +20,59 @@ my $time = 1234; ok (my $statsd = Etsy::StatsD->new, "create an object" ); is ( $statsd->{sockets}[0]->peerport, 8125, 'used default port'); -$data = {}; -ok( $statsd->timing($bucket,$time) ); -is ( $data->{$bucket}, "$time|ms"); +$data = []; +ok( $statsd->timing($bucket, $time) ); +is ( $data->[0], "$bucket:$time|ms"); -$data = {}; +$data = []; ok( $statsd->increment($bucket) ); -is( $data->{$bucket}, '1|c'); +is( $data->[0], "$bucket:1|c"); -$data = {}; +$data = []; ok( $statsd->decrement($bucket) ); -is( $data->{$bucket}, '-1|c'); +is( $data->[0], "$bucket:-1|c"); -$data = {}; +$data = []; ok( $statsd->update($bucket, $update) ); -is( $data->{$bucket}, "$update|c"); +is( $data->[0], "$bucket:$update|c"); -$data = {}; +$data = []; ok( $statsd->update($bucket) ); -is( $data->{$bucket}, "1|c"); +is( $data->[0], "$bucket:1|c"); -$data = {}; +$data = []; ok( $statsd->update(['a','b']) ); -is( $data->{a}, "1|c"); -is( $data->{b}, "1|c"); +is( (sort @$data)[0], "a:1|c" ); +is( (sort @$data)[1], "b:1|c" ); + +$data = []; +ok( $statsd->gauge($bucket, $update) ); +is( $data->[0], "$bucket:$update|g" ); + +$data = []; +ok( $statsd->set($bucket, 'value') ); +is( $data->[0], "$bucket:value|s" ); + +$data = []; +ok( $statsd->prefix('prefix.') ); +ok( $statsd->suffix('.suffix') ); +ok( $statsd->increment($bucket) ); +is( $data->[0], "prefix.$bucket.suffix:1|c" ); + +$data = []; +ok( my $timer = $statsd->timer($bucket) ); +sleep(1); +$timer->finish; +like( $data->[0], qr/^prefix\.$bucket\.suffix:([\.\d]+)\|ms$/ ); + +{ + my $message; + local $SIG{__WARN__} = sub { $message = $_[0] }; + my $timer = $statsd->timer($bucket); + undef $timer; + like( $message, qr/Destroy unfinished timer for metric/ ); +} + ok ( my $remote = Etsy::StatsD->new('localhost', 123), 'created with host, port combo'); is ( $remote->{sockets}[0]->peerport, 123, 'used specified port'); diff --git a/t/SomeModule.pm b/t/SomeModule.pm new file mode 100644 index 0000000..e7e845f --- /dev/null +++ b/t/SomeModule.pm @@ -0,0 +1,14 @@ +package SomeModule; + +use strict; +use warnings; + +use Etsy::StatsD qw($statsd); + + +sub foo { + $statsd->increment( $_[1] ); +} + + +1; diff --git a/t/import_sugar.t b/t/import_sugar.t new file mode 100644 index 0000000..0ab4761 --- /dev/null +++ b/t/import_sugar.t @@ -0,0 +1,37 @@ +use warnings; +use strict; + +use FindBin; +use lib "$FindBin::Bin"; + +use Test::More tests => 7; +use Test::MockModule; + +use SomeModule; +use Etsy::StatsD '$statsd', host => '1.2.3.4'; + +my $module = Test::MockModule->new('Etsy::StatsD'); +my $data; + +$module->mock( + _send_to_sock => sub ($$) { + chomp(my $value = $_[1]); + push(@$data, $value); + } +); + +$data = []; +ok( $statsd->increment('test', 1) ); +is( $statsd->{sockets}[0]->peerhost, '1.2.3.4' ); +is( $data->[0], 'test:1|c' ); + +$data = []; +SomeModule->foo( 'metric' ); +is( $SomeModule::statsd->{sockets}[0]->peerhost, '1.2.3.4' ); +is( $data->[0], 'metric:1|c' ); + + +Etsy::StatsD->configure(host => '4.3.2.1'); +is( $statsd->{sockets}[0]->peerhost, '4.3.2.1' ); +is( $SomeModule::statsd->{sockets}[0]->peerhost, '4.3.2.1' ); + diff --git a/t/send.t b/t/send.t index afa9937..5c49b5d 100644 --- a/t/send.t +++ b/t/send.t @@ -7,9 +7,9 @@ my $module = Test::MockModule->new('Etsy::StatsD'); my $data; $module->mock( - _send_to_sock => sub($$) { - $data = $_[1]; - } + _send_to_sock => sub($$) { + $data = $_[1]; + } ); my $bucket = 'test';