File Coverage

blib/lib/WWW/Analytics/MultiTouch.pm
Criterion Covered Total %
statement 375 575 65.2
branch 54 152 35.5
condition 28 77 36.3
subroutine 41 55 74.5
pod 19 21 90.4
total 517 880 58.7


line stmt bran cond sub pod time code
1             package WWW::Analytics::MultiTouch;
2              
3 2     2   83312 use warnings;
  2         5  
  2         109  
4 2     2   13 use strict;
  2         5  
  2         71  
5 2     2   1942 use Net::Google::Analytics;
  2         276150  
  2         69  
6 2     2   2401 use Net::Google::Analytics::OAuth2;
  2         2010  
  2         63  
7 2     2   3556 use DateTime;
  2         505540  
  2         98  
8 2     2   2532 use Data::Dumper;
  2         19241  
  2         232  
9 2     2   22 use Params::Validate qw(:all);
  2         4  
  2         675  
10 2     2   20 use List::Util qw/sum max/;
  2         5  
  2         215  
11 2     2   12 use List::MoreUtils qw/part/;
  2         4  
  2         174  
12 2     2   2580 use Config::General qw/ParseConfig SaveConfig/;
  2         72282  
  2         229  
13 2     2   2217 use Hash::Merge qw/merge/;
  2         6203  
  2         136  
14 2     2   1767 use Path::Class qw/file/;
  2         101779  
  2         162  
15              
16 2     2   1744 use WWW::Analytics::MultiTouch::Tabular;
  2         10  
  2         20225  
17              
18             our $VERSION = '0.36';
19              
20             my $client_id = "452786331228.apps.googleusercontent.com";
21             my $client_secret = "ZNSff9Rzw0WS0I4M-F_8NUL7";
22              
23             my $default_header_colour = { bold => 1,
24             color => 'white',
25             bg_color => 'gray',
26             right => 'white',
27             };
28             my $default_column_formats = [
29             { bg_color => '#D0D0D0', },
30             { bg_color => '#E8E8E8', },
31             ];
32              
33             my $default_title_format = { bold => 1 };
34              
35             my $default_row__heading = { bold => 1 };
36              
37             my %formatting_params = (
38             title_format => { type => HASHREF,
39             default => $default_title_format,
40             },
41             column_heading_format => { type => HASHREF,
42             default => $default_header_colour,
43             },
44             column_formats => { type => ARRAYREF,
45             default => $default_column_formats,
46             },
47             row_heading_format => { type => HASHREF,
48             default => $default_row__heading,
49             },
50             heading_map => { type => HASHREF,
51             default => {},
52             },
53             header_layout => 0,
54             footer_layout => 0,
55             strict_integer_values => 0,
56             );
57              
58             sub new {
59 5     5 1 123895 my $class = shift;
60              
61 5         366 my %params = validate(@_, {
62             auth_token => 0,
63             refresh_token => 0,
64             auth_file => 0,
65             id => 1,
66             event_category => { default => 'multitouch' },
67             fieldsep => { default => '!' },
68             recsep => { default => '*' },
69             patsep => { default => '-' },
70             debug => { default => 0 },
71             bugfix1 => { default => 0 },
72             channel_map => { type => HASHREF,
73             default => {} },
74             date_format => { default => '%d %b %Y' },
75             time_format => { default => '%Y-%m-%d %H:%M:%S' },
76             ga_timezone => { default => 'UTC' },
77             report_timezone => { default => 'UTC' },
78             revenue_scale => { default => 1 },
79             });
80 5   33     113 my $self = bless \%params, ref $class || $class;
81              
82 5         24 return $self;
83             }
84              
85             sub get_data {
86 0     0 1 0 my $self = shift;
87 0         0 my %params = validate(@_, { start_date => 0,
88             end_date => 0,
89             });
90              
91 0 0       0 unless (exists $self->{analytics}) {
92 0         0 $self->{oauth} = Net::Google::Analytics::OAuth2->new(
93             client_id => $client_id,
94             client_secret => $client_secret,
95             );
96 0 0       0 if (! $self->{refresh_token}) {
97 0         0 $self->authorise;
98             }
99 0         0 $self->{analytics} = Net::Google::Analytics->new();
100             }
101 0         0 my $token = $self->{oauth}->refresh_access_token($self->{refresh_token});
102 0         0 $self->{analytics}->token($token);
103              
104 0         0 my $req = $self->{analytics}->new_request();
105 0         0 $req->ids("ga:" . $self->{id});
106 0         0 $req->dimensions('ga:eventCategory,ga:eventAction,ga:eventLabel');
107 0         0 $req->metrics('ga:totalEvents');
108 0         0 $req->sort('ga:eventAction');
109 0         0 $req->filters('ga:eventCategory==' . $self->{event_category});
110              
111 0         0 my $start_date = _to_date_time($params{start_date}, $self->{report_timezone});
112 0         0 my $end_date = _to_date_time($params{end_date}, $self->{report_timezone})->add(days => 1);
113 0         0 my $date = $start_date->clone;
114 0         0 my %data;
115 0         0 while (DateTime->compare($date, $end_date) <= 0) {
116 0         0 my $ymd = $date->ymd('-');
117 0         0 my $ga_ymd = $date->clone->set_time_zone($self->{ga_timezone})->ymd('-');
118 0         0 $self->_debug("Processing $ga_ymd\n");
119 0         0 $req->start_date($ga_ymd);
120 0         0 $req->end_date($ga_ymd);
121              
122 0         0 my $res = $self->retrieve_paged($req);
123 0         0 for my $entry (@{$res->rows}) {
  0         0  
124 0         0 my @events = $self->split_events($entry->get('event_label'));
125              
126             # Keep event if within reporting time range (not GA time range)
127 0         0 my $t = DateTime->from_epoch(epoch => $events[0][3])->set_time_zone($self->{report_timezone});
128 0 0 0     0 if ($start_date <= $t && $t < $end_date) {
129 0         0 my ($key, $events) = $self->condition_entry($entry->get('event_action'), \@events);
130 0 0       0 $data{$key} = [ $ymd, @$events ] if $key;
131             }
132             }
133 0         0 $date->add(days => 1);
134             }
135              
136 0         0 $self->{current_data} = { start_date => $start_date,
137             end_date => $end_date->subtract(days => 1),
138             transactions => \%data,
139             };
140 0     0   0 $self->_debug(sub { Dumper($self->{current_data}) });
  0         0  
141             }
142              
143             sub retrieve_paged {
144 0     0 0 0 my ($self, $req) = @_;
145              
146 0         0 my $start_index = $req->start_index;
147 0 0       0 $start_index = 1 if !defined($start_index);
148 0         0 my $remaining_items = $req->max_results;
149 0         0 my $max_items_per_page = 10_000;
150 0         0 my $res;
151              
152 0   0     0 while (!defined($remaining_items) || $remaining_items > 0) {
153 0 0 0     0 my $max_results =
154             defined($remaining_items) &&
155             $remaining_items < $max_items_per_page ?
156             $remaining_items : $max_items_per_page;
157              
158 0         0 my $page = $self->{analytics}->retrieve($req, $start_index, $max_results);
159             # $self->_debug("Page data: " . Dumper($page));
160 0 0       0 if (! $page->is_success) {
161 0         0 die "There was a problem fetching analytics data. Authorisation errors such as 'Forbidden' can occur if the Analytics ID you have specified is not accessible via the authorised Google account.\n Error reported was: " . $page->message;
162             }
163              
164 0 0       0 if (!defined($res)) {
165 0         0 $res = $page;
166             }
167             else {
168 0         0 push(@{ $res->rows }, @{ $page->rows });
  0         0  
  0         0  
169             }
170              
171 0         0 my $items_per_page = $page->items_per_page;
172 0 0 0     0 last if $page->total_results == 0 || $items_per_page < $max_results;
173              
174 0 0       0 $remaining_items -= $items_per_page if defined($remaining_items);
175 0         0 $start_index += $items_per_page;
176             }
177              
178 0         0 $res->items_per_page(scalar(@{ $res->rows }));
  0         0  
179              
180 0         0 return $res;
181             }
182              
183              
184             sub set_data {
185 1     1 1 1390 my $self = shift;
186 1         27 my %params = validate(@_, { start_date => 0,
187             end_date => 0,
188             transactions => { required => 1,
189             type => HASHREF,
190             },
191             });
192            
193 1         22 my $start_date = _to_date_time($params{start_date}, $self->{report_timezone});
194 1         211 my $end_date = _to_date_time($params{end_date}, $self->{report_timezone});
195              
196 1         162 $self->{current_data} = { start_date => $start_date,
197             end_date => $end_date,
198             transactions => $params{transactions},
199             };
200             }
201              
202             sub condition_entry {
203 0     0 1 0 my ($self, $key, $touches) = @_;
204              
205 0         0 return ($key, $touches);
206             }
207              
208              
209             sub _to_date_time {
210 2     2   4 my $date = shift;
211 2   50     7 my $tz = shift || 'UTC';
212              
213 2 50       10 if ($date) {
214 2         75 my ($y, $m, $d) = ( $date =~ m/^(\d{4})-?(\d{2})-?(\d{2})/ );
215 2 50       38 die "Invalid date format: $date\n" if ! defined $d;
216 2         152 return DateTime->new(year => $y, month => $m, day => $d, time_zone => $tz);
217             }
218 0         0 return DateTime->now->set_time_zone($tz)->truncate(to => 'day');
219             }
220              
221             # Splits event label into array of [ source, medium, subcat, time ]
222             # or for orders, [ __ORD, TID, revenue, time ]
223             sub split_events {
224 25     25 1 119 my ($self, $events) = @_;
225              
226 25 50       55 return unless $events;
227 25         53 my $rs = $self->{recsep};
228 25         35 my $fs = $self->{fieldsep};
229 25         115 my @events = split(/\Q$rs\E/, $events);
230 25         44 my @rec = map { [ split(/\Q$fs\E/, $_) ] } @events;
  75         342  
231              
232 25 50       74 if ($self->{bugfix1}) {
233 0         0 for (@rec) {
234 0 0 0     0 if ($_->[0] eq 'organic' && $_->[1] ne 'organic') {
235 0         0 my $tmp = $_->[1]; $_->[1] = $_->[0]; $_->[0] = $tmp;
  0         0  
  0         0  
236             }
237             }
238             }
239 25         115 return @rec;
240             }
241              
242             sub summarise {
243 5     5 1 6227 my $self = shift;
244              
245 5         133 my %params = validate(@_, { window_length => { default => 45 },
246             single_order_model => 0,
247             channel_pattern => { default => join($self->{patsep}, qw/source med subcat/) },
248             channel => 0,
249             adjustments => { type => HASHREF, default => {} },
250             });
251 5         49 my $patsubst = $self->_compile_channel_pattern($params{channel_pattern});
252 5         15 my $dt = $params{window_length} * 24 * 3600;
253              
254 5         9 my %distr_touches;
255             my %even_touches;
256 0         0 my %all_touches;
257 0         0 my @trans;
258 0         0 my @touchlist;
259 0         0 my %transdist;
260 0         0 my %transdistoverall;
261 0         0 my %firstlast;
262 0         0 my %overlap;
263 5         10 my %first_touch_channels = map { $_ => 1 } grep { $params{channel}{$_}{requires_first_touch} } keys %{$params{channel}};
  0         0  
  0         0  
  5         20  
264              
265             # Each event has category 'multitouch', action TIDtid, label ORDER*TOUCH*TOUCH...
266             # Each order is of format __ORD!tid!rev!time
267             # Each touch is of format source!medium!subcat!time
268 5         11 for my $tid (keys %{$self->{current_data}->{transactions}}) {
  5         34  
269 25         53 my $rec = $self->{current_data}->{transactions}->{$tid};
270 25         35 my $order = $rec->[1];
271 25 50 33     256 if (! ($order->[0] eq '__ORD'
      33        
272             && 'TID' . $order->[1] eq $tid
273             && $order->[3] =~ m/^\d+$/)) {
274 0         0 $self->_debug("Bad record for TID $tid: no __ORD. " . Dumper($rec));
275 0         0 next;
276             }
277 25         69 my $rev = $self->_currency_conversion($order->[2]);
278              
279             # Set window start based on browser timestamps
280 25         51 my $window_start = $order->[3] - $dt;
281              
282             # work out adjustment factors, if any
283 25   100     109 my $trans_adj = $params{adjustments}{$rec->[0]}{transactions} || 1;
284 25   66     99 my $rev_adj = $params{adjustments}{$rec->[0]}{revenue} || $trans_adj;
285              
286             # Iterate through list of touches and summarise in %touches and @touchlist
287 25         30 my %touches;
288 25         41 push(@touchlist, []);
289 25         29 my $first_touch = 1;
290 25         24 my %seen_first_touch;
291 25         70 for my $entry (@$rec[2 .. @$rec - 1]) {
292 49 50       113 if (@$entry != 4) {
293 0         0 $self->_debug("Bad record for TID $tid: invalid entry. " . Dumper($entry));
294 0         0 next;
295             }
296 49 100       127 last if $entry->[3] < $window_start;
297 46 100       117 if ($entry->[0] =~ m/__ORD/) {
298 5 100       13 last if $params{single_order_model};
299 4         6 unshift(@{$touchlist[-1]}, [ "ORDER($entry->[1])", $entry->[-1] ]);
  4         16  
300 4         8 next;
301             }
302 41 100       86 if (my $channel = $self->_map_channel(join($self->{patsep}, map { $entry->[$_] || '(none)' } @$patsubst ))) {
  123 50       408  
303 41 100       78 if ($first_touch) {
304 25         31 $first_touch = 0;
305 25         46 $seen_first_touch{$channel}++;
306             }
307              
308 41 50 33     106 unless ($first_touch_channels{$channel} && !$seen_first_touch{$channel}) {
309 41         96 $touches{$channel}{count} += $trans_adj;
310 41         67 $touches{$channel}{transactions} = $trans_adj;
311 41         70 $touches{$channel}{revenue} = $rev * $rev_adj;
312 41         40 unshift(@{$touchlist[-1]}, [ $channel, $entry->[-1] ]);
  41         186  
313             }
314             }
315             }
316              
317             # Summarise first/last touch attribution using @touchlist
318 25 50       31 if (@{$touchlist[-1]} > 0) {
  25         61  
319 25         28 my $start = 0;
320 25         24 my $end = @{$touchlist[-1]} - 1;
  25         40  
321 25         38 my $firstchannel = $touchlist[-1][$start][0];
322 25         79 my $lastchannel = $touchlist[-1][$end][0];
323              
324 25   66     119 while (defined($firstchannel) && $firstchannel =~ m/^ORDER\(/) {
325 1         2 $start++;
326 1 50       4 if ($start > $end) {
327 0         0 $firstchannel = undef;
328 0         0 last;
329             }
330 1         6 $firstchannel = $touchlist[-1][$start][0];
331             }
332 25   33     315 while (defined($lastchannel) && $lastchannel =~ m/^ORDER\(/) {
333 0         0 $end--;
334 0 0       0 if ($end <= $start) {
335 0         0 $lastchannel = $firstchannel;
336 0         0 last;
337             }
338 0         0 $lastchannel = $touchlist[-1][$end][0];
339             }
340              
341 25 50       50 if (defined $firstchannel) {
342 25         64 $firstlast{'first'}{$firstchannel}{count} += $trans_adj;
343 25         40 $firstlast{'first'}{$firstchannel}{transactions} += $trans_adj;
344 25         43 $firstlast{'first'}{$firstchannel}{revenue} += $rev * $rev_adj;
345 25         45 $firstlast{'last'}{$lastchannel}{count} += $trans_adj;
346 25         36 $firstlast{'last'}{$lastchannel}{transactions} += $trans_adj;
347 25         44 $firstlast{'last'}{$lastchannel}{revenue} += $rev * $rev_adj;
348             }
349              
350             # for hybrid, only count once if first/last channel are the same touch
351 25 100       46 if ($end == $start) {
352 9         24 $lastchannel = undef;
353             }
354             # Attribute to first or last channel or both for hybrid
355 25         27 my @channels;
356 25 50       52 push(@channels, $firstchannel) if defined $firstchannel;
357 25 100       50 push(@channels, $lastchannel) if defined $lastchannel;
358 25 50       50 if (@channels) {
359 25         43 my $scale = 1/@channels;
360 25         35 for my $channel (@channels) {
361 41         82 $firstlast{hybrid}{$channel}{count} += $trans_adj;
362 41         71 $firstlast{hybrid}{$channel}{transactions} += $scale * $trans_adj;
363 41         118 $firstlast{hybrid}{$channel}{revenue} += $scale * $rev * $rev_adj;
364             }
365             }
366             }
367             # Finish off touchlist by attaching order details as prefix and last touch
368 25         95 push(@{$touchlist[-1]}, [ "ORDER($order->[1])", $order->[3] ]);
  25         82  
369 25         31 unshift(@{$touchlist[-1]}, $order->[1], $order->[2], $order->[3]); # order details prefix
  25         78  
370              
371             # Summarise according to various attribution methods using %touches
372 25 50       55 if (scalar keys %touches > 0) {
373 25         41 for my $sum (qw/count transactions revenue/) {
374 75         323 $all_touches{$_}{$sum} += $touches{$_}{$sum} for keys %touches;
375             }
376             # normalise
377 25         28 my %touches_norm;
378 25         73 my $touches_total = sum(map { $touches{$_}{count} } keys %touches);
  37         117  
379 25         42 for my $sum (qw/transactions revenue/) {
380 50   50     740 $touches_norm{$_}{$sum} = $touches{$_}{$sum} * $touches{$_}{count} / ($touches_total || 1) for keys %touches;
381             }
382 25         49 for (keys %touches) {
383 37         58 my $c = $touches{$_}{count};
384 37         52 $touches_norm{$_}{count} += $c;
385 37         90 $distr_touches{$_}{count} += $c;
386 37         75 $even_touches{$_}{count} += $c;
387             }
388 25         45 my $scale = 1 / (scalar keys %touches);
389 25         34 for my $sum (qw/transactions revenue/) {
390 50         198 $distr_touches{$_}{$sum} += $touches_norm{$_}{$sum} for keys %touches;
391 50         220 $even_touches{$_}{$sum} += $touches{$_}{$sum} * $scale for keys %touches;
392             }
393 25         144 push(@trans, { tid => $order->[1],
394             timestamp => $order->[3],
395             date => $rec->[0],
396             rev => $order->[2],
397             touches => \%touches_norm });
398             # distribution of touches by number of conversions
399 25         127 $transdist{$touches{$_}{count}}{$_} += $trans_adj for keys %touches;
400 25         50 $transdistoverall{sum(map { $touches{$_}{count}} keys %touches)} += $trans_adj;
  37         205  
401              
402 25         88 my $key = join('+', sort keys %touches);
403 25         76 $overlap{joint}{$key}{transactions} += $trans_adj;
404 25         50 $overlap{joint}{$key}{revenue} += $rev * $rev_adj;
405 25         36 $overlap{joint}{$key}{touches} += $touches_total;
406 25         29 $key = scalar keys %touches;
407 25         52 $overlap{count}{$key}{transactions} += $trans_adj;
408 25         34 $overlap{count}{$key}{revenue} += $rev * $rev_adj;
409 25         131 $overlap{count}{$key}{touches} += $touches_total;
410              
411             }
412             }
413 5         30 @touchlist = sort { $a->[0] cmp $b->[0] } @touchlist;
  38         52  
414 5         87 $self->{summary} = {
415             all_touches => \%all_touches,
416             distr_touches => \%distr_touches,
417             even_touches => \%even_touches,
418             trans => \@trans,
419             touchlist => \@touchlist,
420             transdist => \%transdist,
421             transdistoverall => \%transdistoverall,
422             firstlast => \%firstlast,
423             overlap => \%overlap,
424             window_length => $params{window_length},
425             };
426              
427             }
428              
429             sub _map_channel {
430 41     41   54 my ($self, $channel) = @_;
431 41 100       154 return $channel if defined $self->{no_mappings};
432              
433 5 50       22 if (! exists $self->{compiled_mappings}) {
434 5 50       9 if (scalar keys %{$self->{channel_map}} == 0) {
  5         22  
435 5         13 $self->{no_mappings} = 1;
436 5         105 return $channel;
437             }
438 0         0 $self->{compiled_mappings} = [];
439 0         0 for my $key (keys %{$self->{channel_map}}) {
  0         0  
440 0         0 eval {
441 0 0       0 if ($key =~ m{ ^/(.*)/$ }x) {
442 0         0 my $re = $1;
443 0         0 push(@{$self->{compiled_mappings}}, [ qr/$re/, $self->{channel_map}{$key} ]);
  0         0  
444             }
445             };
446 0 0       0 if ($@) {
447 0         0 warn "Failed to compile channel mapping for $key: $@\n";
448             }
449             }
450             }
451              
452 0 0       0 if (exists $self->{channel_map}{$channel}) {
453 0         0 return $self->{channel_map}{$channel};
454             }
455              
456 0         0 for my $match (@{$self->{compiled_mappings}}) {
  0         0  
457 0 0       0 return $match->[1] if $channel =~ $match->[0];
458             }
459 0         0 return $channel;
460             }
461              
462             sub _map_header {
463 206     206   264 my ($header, $header_map) = @_;
464              
465 206 50       792 return exists $header_map->{$header} ? $header_map->{$header} : $header;
466             }
467              
468             sub report {
469 0     0 1 0 my $self = shift;
470 0         0 my %params = validate(@_, { all_touches_report => { default => 1 },
471             even_touches_report => { default => 1 },
472             distributed_touches_report => { default => 1 },
473             first_touch_report => { default => 1 },
474             last_touch_report => { default => 1 },
475             fifty_fifty_report => { default => 1 },
476             transactions_report => { default => 1 },
477             touchlist_report => { default => 1 },
478             transaction_distribution_report => { default => 1 },
479             channel_overlap_report => { default => 1 },
480              
481             all_touches => 0,
482             even_touches => 0,
483             distributed_touches => 0,
484             first_touch => 0,
485             last_touch => 0,
486             fifty_fifty => 0,
487             transactions => 0,
488             touchlist => 0,
489             transaction_distribution => 0,
490             channel_overlap => 0,
491              
492             report_order => { type => ARRAYREF,
493             default => [ qw/all_touches even_touches distributed_touches first_touch last_touch fifty_fifty transactions touchlist transaction_distribution channel_overlap/ ],
494             },
495             filename => 1,
496             'format' => 0,
497             title => 0,
498             column_heading_format => 0,
499             column_formats => 0,
500             header_layout => 0,
501             footer_layout => 0,
502             strict_integer_values => 0,
503             heading_map => 0,
504             report_writer => { type => CODEREF,
505             optional => 1,
506             },
507             });
508 0 0       0 if ($params{filename} =~ m/\.(xls|txt|csv)$/i) {
    0          
509 0         0 $params{'format'} = lc($1);
510             }
511             elsif (! defined $params{format}) {
512 0         0 $params{'format'} = 'csv';
513             }
514              
515 0         0 my @reports;
516 0         0 my @report_options = (qw/title sheetname/, keys %formatting_params);
517              
518 0         0 for my $report (@{$params{report_order}}) {
  0         0  
519 0         0 my $method = $report . '_report';
520 0 0       0 if (! $self->can($method)) {
521 0         0 warn "Report type $report is not valid\n";
522 0         0 next;
523             }
524 0 0       0 next unless $params{$method};
525 0         0 $self->_debug("Generating report '$report'\n");
526 0         0 push(@reports,
527             $self->$method(_opts_subset(_merge_params(\%params, $params{$report}),
528             @report_options)))
529             }
530 0 0       0 if ($params{report_writer}) {
531 0         0 $params{report_writer}->(\@reports, \%params);
532             }
533             else {
534 0         0 my $output = WWW::Analytics::MultiTouch::Tabular->new(_opts_subset(\%params,
535             qw/format filename/));
536 0         0 $output->print(\@reports);
537 0         0 $output->close();
538             }
539             }
540              
541             sub _merge_params {
542 0     0   0 my $h1 = shift;
543 0         0 my $h2 = shift;
544 0 0       0 return $h1 unless ref($h2) eq 'HASH';
545              
546 0         0 Hash::Merge::set_behavior('RIGHT_PRECEDENT');
547 0         0 return merge($h1, $h2);
548             }
549              
550             sub all_touches_report {
551 4     4 1 30 my $self = shift;
552 4         155 my %params = validate(@_, { title => { default => 'All Touches' },
553             sheetname => { default => 'All Touches' },
554             %formatting_params,
555             });
556 4         36 $params{total_100} = 0;
557 4         10 $params{total_header} = 'ACTUAL TOTALS';
558              
559 4         21 return $self->_touches_report(\%params,
560             $self->{summary}{all_touches},
561             $self->{summary}{distr_touches});
562             }
563              
564             sub even_touches_report {
565 2     2 1 45914 my $self = shift;
566 2         118 my %params = validate(@_, { title => { default => 'Even Touches' },
567             sheetname => { default => 'Even Touches' },
568             %formatting_params,
569             });
570              
571 2         20 $params{total_100} = 1;
572 2         6 $params{total_header} = 'TOTAL';
573              
574 2         17 return $self->_touches_report(\%params,
575             $self->{summary}{even_touches},
576             $self->{summary}{even_touches});
577              
578             }
579             sub distributed_touches_report {
580 3     3 1 49235 my $self = shift;
581 3         148 my %params = validate(@_, { title => { default => 'Distributed Touches' },
582             sheetname => { default => 'Distributed Touches' },
583             %formatting_params,
584             });
585              
586 3         29 $params{total_100} = 1;
587 3         10 $params{total_header} = 'TOTAL';
588              
589 3         72 return $self->_touches_report(\%params,
590             $self->{summary}{distr_touches},
591             $self->{summary}{distr_touches});
592              
593             }
594              
595             sub first_touch_report {
596 1     1 1 14971 my $self = shift;
597 1         56 my %params = validate(@_, { title => { default => 'First Touch' },
598             sheetname => { default => 'First Touch' },
599             %formatting_params,
600             });
601 1         12 $params{total_100} = 1;
602 1         4 $params{total_header} = 'TOTAL';
603              
604 1         16 return $self->_touches_report(\%params,
605             $self->{summary}{firstlast}{'first'},
606             $self->{summary}{firstlast}{'first'});
607              
608             }
609              
610             sub last_touch_report {
611 1     1 1 16085 my $self = shift;
612 1         61 my %params = validate(@_, { title => { default => 'Last Touch' },
613             sheetname => { default => 'Last Touch' },
614             %formatting_params,
615             });
616 1         13 $params{total_100} = 1;
617 1         4 $params{total_header} = 'TOTAL';
618              
619 1         11 return $self->_touches_report(\%params,
620             $self->{summary}{firstlast}{'last'},
621             $self->{summary}{firstlast}{'last'});
622              
623             }
624              
625             sub fifty_fifty_report {
626 2     2 1 15567 my $self = shift;
627 2         93 my %params = validate(@_, { title => { default => '50/50 First-Last Touch' },
628             sheetname => { default => 'Fifty Fifty' },
629             %formatting_params,
630             });
631 2         20 $params{total_100} = 1;
632 2         6 $params{total_header} = 'TOTAL';
633              
634 2         14 return $self->_touches_report(\%params,
635             $self->{summary}{firstlast}{hybrid},
636             $self->{summary}{distr_touches});
637              
638             }
639              
640             sub _touches_report {
641 13     13   37 my ($self, $params, $summary, $summary_for_total) = @_;
642              
643             # Total based on distributed touches to get actual totals
644 13         21 my @totals;
645             my %totals;
646 13     141   72 my $formatter = sub { shift };
  141         265  
647 13 100   12   60 $formatter = sub { sprintf("%d", shift) } if $params->{strict_integer_values};
  12         29  
648              
649 13         35 for my $col (qw/count transactions revenue/) {
650 39         58 push(@totals, $formatter->(sum map { $summary_for_total->{$_}{$col} } keys %{$summary_for_total}));
  114         330  
  39         86  
651 39         110 $totals{$col} = $totals[-1];
652             }
653 13 100       48 if ($params->{total_100}) {
654 9         24 push(@totals, 100, 100); # % transactions, % revenue
655             }
656             else {
657 4         10 push(@totals, '', ''); # % transactions, % revenue
658             }
659 13         25 my @data;
660 13         24 for my $channel (sort keys %{$summary}) {
  13         64  
661 38         153 my $i = 0;
662 114         636 push(@data, [ [ $channel, $params->{row_heading_format} ],
663 114         251 (map { [ $formatter->($summary->{$channel}{$_}), $params->{column_formats}->[$i++ % @{$params->{column_formats}}] ] } qw/count transactions revenue/),
  76         486  
664 38   50     107 (map { [ sprintf("%.2f", $summary->{$channel}{$_} / ($totals{$_} || 1) * 100), $params->{column_formats}->[$i++ % @{$params->{column_formats}}] ] } qw/transactions revenue/),
  76         605  
665             ]);
666             }
667              
668             # sort by revenue, transactions descending
669 13 50       64 @data = sort { $b->[3][0] <=> $a->[3][0] || $b->[2][0] <=> $a->[2][0] } @data;
  37         122  
670              
671 13         75 push(@data, [ map { [ $_, $params->{column_heading_format} ] }
  78         360  
672             _map_header($params->{total_header}, $params->{heading_map}), @totals ]);
673              
674 13         27 my $i = 0;
675 13         43 my @heading = ('Channel', 'Touches', 'Transactions', 'Revenue', '% Transactions', '% Revenue');
676 78         142 my %report = ( title => [ $params->{title}, $params->{title_format} ],
677             sheetname => $params->{sheetname},
678 26         443 headings => [ map { [ _map_header($_, $params->{heading_map}), $params->{column_heading_format} ] } @heading ],
679             data => \@data,
680 13         53 chart => [ map { { type => 'pie',
681             title => { name => $heading[$_],
682             name_formula => [-1, $_],
683             },
684             abs_row => 20 * $i++,
685             abs_col => 7,
686             x_scale => 1,
687             y_scale => 1,
688             series => [
689             { categories => [ 0, scalar @data - 2, 0, 0 ],
690             values => [ 0, (scalar @data - 2), $_, $_ ],
691             name_formula => [$_, 0],
692             name => $data[$_][0][0],
693             } ],
694             } } (2, 3) ],
695             start_date => $self->_format_date($self->{current_data}{start_date}),
696             end_date => $self->_format_date($self->{current_data}{end_date}),
697             generation_date => $self->_format_date(),
698             window_length => $self->{summary}->{window_length},
699              
700             );
701            
702 13         855 _add_layout(\%report, $params);
703 13         111 return \%report;
704            
705             }
706              
707             sub transactions_report {
708 3     3 1 41805 my $self = shift;
709 3         154 my %params = validate(@_, { title => { default => 'Transactions' },
710             sheetname => { default => 'Transactions' },
711             %formatting_params,
712             });
713              
714 3         27 my @summary = sort { $a->{tid} cmp $b->{tid} } @{$self->{summary}{trans}};
  21         50  
  3         28  
715 3         7 my %channels;
716 3         10 for my $rec (@summary) {
717 15         18 $channels{$_}++ for keys %{$rec->{touches}};
  15         89  
718             }
719 3 50       13 my @channels = sort { $channels{$b} <=> $channels{$a} || $b cmp $a } keys %channels;
  8         32  
720 3         10 my @data = ( [ map { [ _map_header($_, $params{heading_map}), $params{column_heading_format} ] } ('', '', map { qw/Touches Transactions Revenue/ } @channels )] );
  33         68  
  9         20  
721              
722              
723 3         12 for my $rec (@summary) {
724 15         28 my $i = 0;
725 45         128 push(@data, [ [ $rec->{tid}, $params{row_heading_format} ],
726             $rec->{'date'},
727 15         53 map { my $cf = $params{column_formats}->[$i++ % @{$params{column_formats}}];
  45         67  
728 45   100     540 ( [ $rec->{touches}{$_}{count} || '', $cf ],
      100        
      100        
729             [ $rec->{touches}{$_}{transactions} || '', $cf ],
730             [ $rec->{touches}{$_}{revenue} || '', $cf ] )
731             } @channels ]);
732             }
733              
734 33         56 my %report = ( title => [ $params{title}, $params{title_format} ],
735             sheetname => $params{sheetname},
736 3         17 headings => [ map { [ _map_header($_, $params{heading_map}), $params{column_heading_format} ] } ('Transaction ID', 'Date', map { (' ', $_, ' ') } @channels) ],
  9         21  
737             data => \@data,
738             start_date => $self->_format_date($self->{current_data}{start_date}),
739             end_date => $self->_format_date($self->{current_data}{end_date}),
740             generation_date => $self->_format_date(),
741             window_length => $self->{summary}->{window_length},
742              
743             );
744              
745 3         280 _add_layout(\%report, \%params);
746 3         21 return \%report;
747             }
748              
749              
750             sub touchlist_report {
751 3     3 1 102562 my $self = shift;
752              
753 3         165 my %params = validate(@_, { title => { default => 'Touch List' },
754             sheetname => { default => 'Touch List' },
755             %formatting_params,
756             });
757              
758 3         29 my @data;
759 3         11 for my $touchlist (sort { $a->[0] cmp $b->[0] } @{$self->{summary}{touchlist}}) {
  24         83  
  3         29  
760 15         3979 my $i = 0;
761 40         97 push(@data, [
762             [ $touchlist->[0], $params{row_heading_format} ], #tid
763             $self->_format_time($touchlist->[2]), # date
764             $touchlist->[1], # revenue
765 15         100 map { my $cf = $params{column_formats}->[$i++ % @{$params{column_formats}}];
  40         12818  
766 40         177 ( [ $_->[0], $cf ], [ $self->_format_time($_->[1]), $cf ] ) } @$touchlist[3 .. @$touchlist - 1]
767             ]);
768             }
769 3   50     914 my $maxcols = max(map { 2 * ( @$_ - 3 ) } @{$self->{summary}{touchlist}}) || 0;
770            
771 32         56 my %report = ( title => [ $params{title}, $params{title_format} ],
772             sheetname => $params{sheetname},
773 3         21 headings => [ map { [ _map_header($_, $params{heading_map}), $params{column_heading_format} ] } ('Transaction ID', 'Date', 'Revenue', 'Touches', ('') x $maxcols) ],
774             data => \@data,
775             start_date => $self->_format_date($self->{current_data}{start_date}),
776             end_date => $self->_format_date($self->{current_data}{end_date}),
777             generation_date => $self->_format_date(),
778             window_length => $self->{summary}->{window_length},
779              
780             );
781              
782 3         273 _add_layout(\%report, \%params);
783 3         21 return \%report;
784             }
785              
786             sub transaction_distribution_report {
787 1     1 1 17773 my $self = shift;
788              
789 1         56 my %params = validate(@_, { title => { default => 'Transaction Distribution' },
790             sheetname => { default => 'Transaction Distribution' },
791             %formatting_params,
792             });
793              
794 1         12 my $transdist = $self->{summary}{transdist};
795 1         4 my $transdistoverall = $self->{summary}{transdistoverall};
796              
797             # find 95th percentile so last column contains remaining 5%
798 1         2 my @total;
799             my %channels;
800 1         5 my %bins = map { $_ => 1 } keys %$transdist;
  2         7  
801 1         8 $bins{$_}++ for keys %$transdistoverall;
802              
803 1         8 for my $count (sort { $a <=> $b } keys %bins) {
  1         5  
804 2 100 50     15 push(@total, [ $count, ($transdistoverall->{$count} || 0) + (@total > 0 ? $total[-1][1] : 0) ]);
805 2         3 $channels{$_}++ for keys %{$transdist->{$count}};
  2         15  
806             }
807 1         3 my $main = \@total;
808 1         3 my $rest;
809 1 50       5 if (@total > 10) {
810 0         0 my $threshold = 0.95 * $total[-1][1];
811 0         0 my $i = 0;
812 0 0   0   0 ($main, $rest) = part { $total[$i++][1] <= $threshold ? 0 : 1 } @total;
  0         0  
813             }
814 1         3 my @headings = ("No. of Touches", map { $_->[0] } @$main);
  2         6  
815             # create last roll-up heading
816 1 50       18 push(@headings, ">" . $main->[-1][0]) if $rest;
817            
818 1         2 my @data;
819 1         6 for my $channel ((sort keys %channels), 'OVERALL') {
820 4         5 my $i = 0;
821 8         149 push(@data, [ [ $channel, $params{row_heading_format} ],
822             map {
823 8         13 my $cf = $params{column_formats}->[$i++ % @{$params{column_formats}}];
  8         15  
824 8   100     51 [ ($channel eq 'OVERALL' ? $transdistoverall->{$_} : $transdist->{$_}{$channel}) || 0, $cf ]
825 4         13 } map { $_->[0] } @$main ]);
826 4 50       15 if ($rest) {
827             # append a bin containing the sum of remaining values
828 0 0       0 push(@{$data[-1]}, sum(map { ($channel eq 'OVERALL' ? $transdistoverall->{$_->[0]} : $transdist->{$_->[0]}{$channel}) || 0 } @$rest));
  0 0       0  
  0         0  
829             }
830             }
831              
832 3         11 my %report = ( title => [ $params{title}, $params{title_format} ],
833             sheetname => $params{sheetname},
834 4         10 headings => [ map { [ _map_header($_, $params{heading_map}), $params{column_heading_format} ] } @headings ],
835             data => \@data,
836             chart => [ { type => 'column',
837             x_scale => 1.5,
838             y_scale => 1.5,
839             series => [ map {
840 1         7 { categories => [ -1, -1, 1, scalar @{$data[0]} ],
  4         35  
841 4         6 values => [ $_, $_, 1, scalar @{$data[0]} ],
842             name_formula => [$_, 0],
843             name => $data[$_][0],
844             } } (0 .. @data - 1) ]
845             } ],
846             start_date => $self->_format_date($self->{current_data}{start_date}),
847             end_date => $self->_format_date($self->{current_data}{end_date}),
848             generation_date => $self->_format_date(),
849             window_length => $self->{summary}->{window_length},
850              
851             );
852              
853 1         69 _add_layout(\%report, \%params);
854 1         10 return \%report;
855            
856             }
857              
858             sub channel_overlap_report {
859 1     1 1 16998 my $self = shift;
860              
861 1         55 my %params = validate(@_, { title => { default => 'Channel Overlap' },
862             sheetname => { default => 'Channel Overlap' },
863             %formatting_params,
864             });
865            
866 1         14 my @data;
867             my @offsets; # row offsets into data array for each report
868              
869 1         5 push(@offsets, scalar @data);
870             $self->_overlap_report(\%params, "Channel Count",
871 1     1   13 \@data, sub { $a->[0][0] <=> $b->[0][0] }, $self->{summary}{overlap}{count});
  1         8  
872 1         6 push(@data, [ ' ' ]);
873 1         2 push(@offsets, scalar @data);
874             $self->_overlap_report(\%params, "Channel Combination",
875 1     4   9 \@data, sub { $b->[6][0] <=> $a->[6][0] }, $self->{summary}{overlap}{joint});
  4         16  
876              
877 1         4 push(@offsets, scalar @data);
878              
879 1         3 my $i = 0;
880 1         35 my %report = ( title => [ $params{title}, $params{title_format} ],
881             sheetname => $params{sheetname},
882             data => \@data,
883 1         6 chart => [ map { { type => 'pie',
884             title => { name => $data[$offsets[$_]][0][0],
885             name_formula => [$offsets[$_], 0],
886             },
887             abs_row => 20 * $i++,
888             abs_col => 8,
889             x_scale => 1,
890             y_scale => 1,
891             series => [
892             { categories => [ $offsets[$_] + 1, $offsets[$_ + 1] - 1,
893             0, 0 ],
894             values => [ $offsets[$_] + 1, $offsets[$_ + 1] - 1, 1, 1 ],
895             name_formula => [$offsets[$_] + 1, 0],
896             name => $data[$offsets[$_]][0][0],
897             } ],
898             } } (0 .. 0) ], # just doing first pie, second is too busy
899             start_date => $self->_format_date($self->{current_data}{start_date}),
900             end_date => $self->_format_date($self->{current_data}{end_date}),
901             generation_date => $self->_format_date(),
902             window_length => $self->{summary}->{window_length},
903              
904             );
905              
906 1         64 _add_layout(\%report, \%params);
907 1         6 return \%report;
908             }
909              
910             sub _overlap_report {
911 2     2   6 my ($self, $params, $heading1, $result, $comparator, $src) = @_;
912              
913 2         7 push(@$result, [ map { [ _map_header($_, $params->{heading_map}), $params->{column_heading_format} ] }
  14         30  
914             ($heading1, 'Touches', 'Transactions', 'Revenue', '% Transactions', '% Revenue', 'Efficiency' ) ]);
915              
916 2         6 my %totals;
917 2         4 for my $sum (qw/transactions revenue/) {
918 4         37 $totals{$sum} += $src->{$_}{$sum} for keys %$src;
919             }
920              
921 2         5 my @data;
922 2         6 for my $row (keys %$src) {
923 6         8 my $i = 0;
924 18         52 push(@data, [ [ $row, $params->{row_heading_format} ],
925 18         38 (map { [ $src->{$row}{$_}, $params->{column_formats}->[$i++ % @{$params->{column_formats}}] ] } qw/touches transactions revenue/),
  12         91  
926 6   50     17 (map { [ sprintf("%.2f", $src->{$row}{$_} / ($totals{$_} || 1) * 100), $params->{column_formats}->[$i++ % @{$params->{column_formats}}] ] } qw/transactions revenue/),
  12   50     92  
927             [ sprintf("%.2f", $src->{$row}{transactions} / ($src->{$row}{touches} || 1)) ]
928             ]);
929             }
930 2         13 push(@$result, sort $comparator @data);
931             }
932              
933            
934              
935             sub _add_layout {
936 21     21   44 my $report = shift;
937 21         44 my $params = shift;
938              
939 21         61 for (qw/header_layout footer_layout strict_integer_values/) {
940 63 100       197 $report->{$_} = $params->{$_} if defined $params->{$_};
941             }
942             }
943              
944             sub _compile_channel_pattern {
945 5     5   12 my ($self, $pat) = @_;
946              
947 5         36 my @parts = split($self->{patsep}, $pat);
948 5         14 my @idx;
949 5         16 for (@parts) {
950 15 100       48 m/source/ && do { push(@idx, 0); next };
  5         10  
  5         11  
951 10 100       24 m/med/ && do { push(@idx, 1); next };
  5         10  
  5         6  
952 5 50       41 m/sub|cat/ && do { push(@idx, 2); next };
  5         9  
  5         12  
953 0         0 warn "Invalid channel pattern component: $_\n";
954             }
955 5 50       14 if (! @idx) {
956 0         0 @idx = (0, 1, 2);
957             }
958 5         16 return \@idx;
959             }
960              
961             sub _currency_conversion {
962 25     25   45 my ($self, $dv) = @_;
963 25 50       137 return $self->{revenue_scale} * $dv if $dv =~ m/^[0-9.]+$/;
964              
965 0         0 die "Currency conversion not implemented: rev = $dv\n";
966             }
967              
968             sub _debug {
969 0     0   0 my $self = shift;
970 0 0       0 my @args = map { ref($_) eq 'CODE' ? $_->() : $_ } @_;
  0         0  
971 0 0       0 print STDERR @args if $self->{debug};
972             }
973              
974             sub process {
975 0     0 1 0 my $class = shift;
976 0         0 my $opts = shift;
977              
978 0         0 my $mt = $class->new(_opts_subset($opts, qw/id event_category fieldsep recsep patsep debug bugfix1 channel_map date_format time_format ga_timezone report_timezone revenue_scale auth_file refresh_token auth_token/));
979              
980 0         0 $mt->get_data(_opts_subset($opts, qw/start_date end_date/));
981 0         0 $mt->summarise(_opts_subset($opts, qw/window_length single_order_model channel_pattern channel adjustments/));
982 0         0 $mt->report(_opts_subset($opts, qw/
983             all_touches_report even_touches_report distributed_touches_report first_touch_report last_touch_report fifty_fifty_report
984             transactions_report touchlist_report transaction_distribution_report channel_overlap_report
985              
986             all_touches even_touches distributed_touches first_touch last_touch fifty_fifty
987             transactions touchlist transaction_distribution channel_overlap
988              
989             report_order filename format column_heading_format column_formats header_layout footer_layout strict_integer_values heading_map/));
990             }
991              
992             sub _opts_subset {
993 0     0   0 my ($opts, @fields) = @_;
994              
995 0         0 my %result;
996 0         0 for (@fields) {
997 0 0       0 $result{$_} = $opts->{$_} if exists $opts->{$_};
998             }
999              
1000 0         0 return %result;
1001             }
1002              
1003             sub _format_date {
1004 63     63   2682 my $self = shift;
1005 63   66     369 my $date = shift || DateTime->now->set_time_zone($self->{report_timezone})->truncate(to => 'day');
1006              
1007 63         15142 return $date->strftime( $self->{date_format} );
1008             }
1009              
1010             sub _format_time {
1011 55     55   75 my $self = shift;
1012 55         137 my $t = shift;
1013              
1014 55 50       277 return DateTime->from_epoch(epoch => $t)
1015             ->set_time_zone($self->{report_timezone})
1016             ->strftime( $self->{time_format} )
1017             if defined $t;
1018 0           return 'UNKNOWN';
1019             }
1020              
1021             sub authorise {
1022 0     0 0   my $self = shift;
1023              
1024 0           my $url = $self->{oauth}->authorize_url;
1025              
1026 0           print(<<"EOF");
1027             Multitouch Analytics requires access to data from your Google Analytics account.
1028              
1029             Please visit the following URL, grant access to this application, and enter
1030             the code you will be shown:
1031              
1032             $url
1033              
1034             EOF
1035              
1036 0           print("Enter code: ");
1037 0           my $code = ;
1038 0           chomp($code);
1039              
1040 0           my $res = $self->{oauth}->get_access_token($code);
1041              
1042 0   0       SaveConfig($self->{auth_file} || _default_auth_file(),
1043             { access_token => $res->{access_token},
1044             refresh_token => $res->{refresh_token}});
1045 0           $self->{refresh_token} = $res->{refresh_token};
1046             }
1047              
1048             sub parse_config {
1049 0     0 1   my $class = shift;
1050 0           my $opts = shift;
1051 0           my $conf_file = shift;
1052              
1053 0           Hash::Merge::set_behavior('RIGHT_PRECEDENT');
1054              
1055 0 0         if ($conf_file) {
1056 0 0 0       die "Config file $conf_file does not exist or is not readable" unless -f $conf_file && -r $conf_file;
1057              
1058 0           my %file_opts = ParseConfig(-ConfigFile => $conf_file,
1059             -AutoTrue => 1,
1060             -SplitPolicy => 'equalsign',
1061             -UTF8 => 1,
1062             -InterPolateVars => 1,
1063             -InterPolateEnv => 1,
1064             -IncludeRelative => 1,
1065             -DefaultConfig => {
1066             cwd => file($conf_file)->dir->absolute->stringify,
1067             },
1068             );
1069 0           _fix_array_keys(\%file_opts, 'column_formats');
1070              
1071 0           $opts = merge($opts, \%file_opts);
1072             }
1073              
1074 0   0       $opts->{auth_file} ||= _default_auth_file($conf_file);
1075 0 0         if (-f $opts->{auth_file}) {
1076 0           my %auth_opts = ParseConfig(-ConfigFile => $opts->{auth_file});
1077 0           $opts = merge($opts, \%auth_opts);
1078 0 0         if ($opts->{debug}) {
1079 0           print "Opened auth_file $opts->{auth_file}\n";
1080 0 0         open my $fh, '<', $opts->{auth_file} or die "Failed to open $opts->{auth_file}: $!";
1081 0           local $/ = undef;
1082 0           my $s = <$fh>;
1083 0           close($fh);
1084 0           print "Contents:\n$s\n";
1085 0           print "Parsed content:\n" . Dumper(\%auth_opts);
1086             }
1087             }
1088              
1089 0           return $opts;
1090             }
1091              
1092             sub _default_auth_file {
1093 0   0 0     my $conf_file = shift || 'multitouchanalytics';
1094              
1095 0           my (@parts) = grep { $_ } split(/\./, $conf_file);
  0            
1096 0 0         pop(@parts) if @parts > 1;
1097 0           push(@parts, 'auth');
1098 0           return '.' . join('.', @parts);
1099             }
1100              
1101             sub _fix_array_keys {
1102 0     0     my $hash = shift;
1103 0           my $key = shift;
1104 0 0 0       if (exists($hash->{$key}) && ref($hash->{$key}) ne 'ARRAY') {
1105 0           $hash->{$key} = [ $hash->{$key} ];
1106             }
1107 0           for my $v (values %$hash) {
1108 0 0         if (ref($v) eq 'HASH') {
1109 0           _fix_array_keys($v, $key);
1110             }
1111             }
1112             }
1113              
1114             =head1 NAME
1115              
1116             WWW::Analytics::MultiTouch - Multi-touch web analytics, using Google Analytics
1117              
1118             =head1 SYNOPSIS
1119              
1120             use WWW::Analytics::MultiTouch;
1121              
1122             # Simple, all-in-one approach
1123             WWW::Analytics::MultiTouch->process(id => $analytics_id,
1124             start_date => '2010-01-01',
1125             end_date => '2010-02-01',
1126             filename => 'report.xls');
1127              
1128             # Or step by step
1129             my $mt = WWW::Analytics::MultiTouch->new(id => $analytics_id);
1130             $mt->get_data(start_date => '2010-01-01',
1131             end_date => '2010-02-01');
1132              
1133             $mt->summarise(window_length => 45);
1134             $mt->report(filename => 'report-45day.xls');
1135            
1136             $mt->summarise(window_length => 30);
1137             $mt->report(filename => 'report-30day.xls');
1138              
1139             =head1 DESCRIPTION
1140              
1141             This module provides reporting for multi-touch web analytics, as described at
1142             L.
1143              
1144             Unlike typical last-session attribution web analytics, multi-touch gives insight
1145             into all of the various marketing channels to which a visitor is exposed before
1146             finally making the decision to buy.
1147              
1148             Multi-touch analytics uses a javascript library to send information from a
1149             web user's browser to Google Analytics for raw data collection; this module uses
1150             the Google Analytics API to collate the data and then summarises it in a
1151             spreadsheet, showing (for example):
1152              
1153             =over 4
1154              
1155             =item * Summary of marketing channels and number of transactions to which each channel
1156             had some contribution (sum of transactions > total transactions)
1157              
1158             =item * Summary of channels and fair attribution of transactions (sum of
1159             transactions = total transactions)
1160              
1161             =item * First touch, last touch, fifty-fifty first/last touch, and even attribution of transactions.
1162              
1163             =item * Overlap analysis
1164              
1165             =item * Transaction/touch distribution
1166              
1167             =item * List of each transaction and the contributing channels
1168              
1169             =back
1170              
1171             =head1 GOOGLE ACCOUNT AUTHORISATION
1172              
1173             In order to give permission for the multitouch reporting to access your data, you must follow the authorisation process. On first use, a URL will be displayed. You must click on this URL or cut and paste it into a browser, log in as the Google user that has access to the Google Analytics profile that you wish to analyse, grant permission, and paste the resulting authorisation code into the console. After this, the authorisation tokens will be stored and there should be no need to repeat the process.
1174              
1175             In case you need to change user or profile or re-authenticate, see the information on the L option.
1176              
1177             =head1 BASIC USAGE
1178              
1179             =head2 process
1180              
1181             WWW::Analytics::MultiTouch->process(%options)
1182              
1183             The process() function integrates all of the steps required to generate a report
1184             into one, i.e. it creates a WWW::Analytics::MultiTouch object, fetches data from
1185             the Google Analytics API, summarises the data and generates a report.
1186              
1187             Options available are all of the options for L, L, L
1188             and L. Minimum options are id, and typically start_date,
1189             end_date and filename.
1190              
1191             Typically the most time consuming part of the process is fetching the data from
1192             Google. The process() function is suitable if only one set of parameters is to
1193             be used for the reports; to generate multiple reports using, for example,
1194             different attribution windows, it is more efficient to use the full API to fetch
1195             the data once and then run all the needed reports.
1196              
1197             =head1 METHODS
1198              
1199             =head2 new
1200              
1201             my $mt = WWW::Analytics::MultiTouch->new(%options)
1202              
1203             Creates a new WWW::Analytics::MultiTouch object.
1204              
1205             Options are:
1206              
1207             =over 4
1208              
1209             =item * id
1210              
1211             This is the Google Analytics reporting ID. This parameter is mandatory. This is NOT the ID that you use in the javascript code! You can find the reporting id in the URL when you log into the Google Analytics console; it is the number following the letter 'p' in the URL, e.g.
1212              
1213             https://www.google.com/analytics/web/#dashboard/default/a111111w222222p123456/
1214              
1215             In this example, the ID is 123456.
1216              
1217             =item * auth_file
1218              
1219             This is the file in which authentication keys received from Google are kept for subsequent use. The default filename is derived from the configuration file (look for a file in the same directory as the configuration file ending in '.auth'). You may specify an alternative filename if you wish.
1220              
1221             The auth_file will be created on initial usage when authorisation keys are received from Google. If you need to change the Google username, or re-authorise the software for any other reason, delete the auth_file or specify an auth_file of a different name that does not exist. Then the initial authorisation process will be repeated and a new auth_file will be created.
1222              
1223             =item * event_category
1224              
1225             The name of the event category used in Google Analytics to store multi-touch
1226             data. Defaults to 'multitouch' and only needs to be changed if the equivalent
1227             variable in the associated javascript library has been customised.
1228              
1229             =item * fieldsep, recsep
1230              
1231             Field and record separators for stored multi-touch data. These default to '!'
1232             and '*' respectively and only need to be changed if the equivalent variables in
1233             the associated javascript library has been customised.
1234              
1235             =item * patsep
1236              
1237             The pattern separator for turning source, medium and subcategory information
1238             into a "channel" identifier. See the C option under
1239             L for more information. Defaults to '-'.
1240              
1241             =item * channel_map
1242              
1243             This is a hashref of channel name (after applying C) that maps
1244             the extracted name to a more friendly name. For example, if channel_pattern is
1245             'med-subcat', then direct traffic appears as '(none)-(none), organic traffic as
1246             organic-(none), etc. An appropriate channel_map might be:
1247              
1248             channel_map => {
1249             '(none)-(none)' => 'Direct',
1250             'organic-(none)' => 'Organic'
1251             }
1252              
1253              
1254             =item * date_format, time_format
1255              
1256             The format to be used for printing dates and times, respectively, using strftime
1257             patterns. See L for details. Defaults are '%d %b
1258             %Y' (e.g. 1 Jan 2010) and '%Y-%m-%d %H:%M:%S' (e.g. 2010-01-01 01:00:00).
1259              
1260             =item * ga_timezone, report_timezone
1261              
1262             Timezone used by Google Analytics, and timezone to be used in the reports,
1263             respectively. May be specified either as an Olson DB time zone name
1264             ("America/Chicago", "UTC") or an offset string ("+0600"). Default is UTC for both.
1265              
1266             =item * revenue_scale
1267              
1268             Scaling factor for revenue amounts. Useful if, for example, you wish to display
1269             revenue in thousands of dollars instead of dollars.
1270              
1271             =item * debug
1272              
1273             Enable debug output.
1274              
1275             =back
1276              
1277             =head2 get_data
1278              
1279             $mt->get_data(%options)
1280              
1281             Get data via the Google Analytics API.
1282              
1283             Options are:
1284              
1285             =over 4
1286              
1287             =item * start_date, end_date
1288              
1289             Start and end dates respectively. The total interval includes both start and
1290             end dates. Date format is YYYY-MM-DD or YYYYMMDD. (These dates are with
1291             respect to the report timezone).
1292              
1293             =back
1294              
1295             =head2 summarise
1296              
1297             $mt->summarise(%options)
1298              
1299             Summarise data.
1300              
1301             Options are:
1302              
1303             =over 4
1304              
1305             =item * window_length
1306              
1307             The analysis window length, in days. Only touches this many days prior to any
1308             given order will be included in the analysis.
1309              
1310             =item * single_order_model
1311              
1312             If set, any touch is counted only once, toward the next order only; subsequent
1313             repeat orders do not include touches prior to the initial order.
1314              
1315             =item * channel_pattern
1316              
1317             Each "channel" is derived from the Google source (source), Google medium (med)
1318             and a subcategory (subcat) field that can be set in the javascript calls, joined
1319             using the pattern separator patsep (defined in L, default '-').
1320              
1321             For example, the source might be 'yahoo' or 'google' and the medium 'organic' or
1322             'cpc'. To see a report on channels yahoo-organic, google-organic, google-cpc
1323             etc, the channel pattern would be 'source-med'. To see the report just at the
1324             search engine level, channel pattern would be 'source', and to see the report
1325             just at the medium level, the channel pattern would be 'med'.
1326              
1327             Arbitrary ordering is permissible, e.g. med-subcat-source.
1328              
1329             The default channel pattern is 'source-med-subcat'.
1330              
1331             =item * channel
1332              
1333             A hashref containing channel-specific options. This is a mapping from channel
1334             name (the friendly name, given in the L) to option hash.
1335              
1336             Currently the only option is 'requires_first_touch' (boolean). If set, a
1337             transaction will only be attributed to the channel if it received the first
1338             touch in the analysis window. This is mainly used to correct for
1339             over-attribution to the direct channel. Example:
1340              
1341             {
1342             Direct => { requires_first_touch => 1 }
1343             }
1344              
1345             =item * adjustments
1346              
1347             A hashref containing transactions and revenue corrections that may be
1348             applied for a given day. This allows, for example, compensation for
1349             lost data for short periods of time. A typical form of the adjustments hash is:
1350              
1351             {
1352             2010-09-01 => { transactions => 1.4, revenue => 1.3 }
1353             }
1354              
1355             which would apply correction factors of 1.4 and 1.3 for transaction counts
1356             and revenue respectively for any transactions occurring on the date
1357             2010-09-01.
1358              
1359             =back
1360              
1361             =head2 report
1362              
1363             $mt->report(%options)
1364              
1365             Generate reports.
1366              
1367             =head3 Report Type Options
1368              
1369             =over 4
1370              
1371             =item * all_touches_report
1372              
1373             If set, the generated report includes the all-touches report; enabled by
1374             default. The all-touches report shows, for each channel, the total number of
1375             transactions and the total revenue amount in which that channel played a role.
1376             Since multiple channels may have contributed to each transaction, the total of
1377             all transactions across all channels will exceed the actual number of
1378             transactions.
1379              
1380             =item * even_touches_report
1381              
1382             If set, the generated report includes the even-touches report; enabled by
1383             default. The even-touches report shows, for each channel, a number of
1384             transactions and revenue amount evenly distributed between the participating
1385             channels. For example, if Channel A has 3 touches and Channel B 2 touches, half
1386             of the revenue/transactions will be allocated to Channel A and half to Channel
1387             B. Since each individual transaction is evenly distributed across the
1388             contributing channels, the total of all transactions (revenue) across all
1389             channels will equal the actual number of transactions (revenue).
1390              
1391             =item * distributed_touches_report
1392              
1393             If set, the generated report includes the distributed-touches report; enabled by
1394             default. The distributed-touches report shows, for each channel, a number of
1395             transactions and revenue amount in proportion to the number of touches for that
1396             channel. Since each individual transaction is distributed across the
1397             contributing channels, the total of all transactions (revenue) across all
1398             channels will equal the actual number of transactions (revenue).
1399              
1400             =item * first_touch_report
1401              
1402             If set, the generated report includes the first-touch report; enabled by
1403             default. The first-touch report allocates transactions and revenue to the
1404             channel that received the first touch within the analysis window.
1405              
1406             =item * last_touch_report
1407              
1408             If set, the generated report includes the last-touch report; enabled by
1409             default. The last-touch report allocates transactions and revenue to the
1410             channel that received the last touch prior to the transaction.
1411              
1412             =item * fifty_fifty_report
1413              
1414             If set, the generated report includes the fifty-fifty report; enabled by
1415             default. The fifty-fifty report allocates transactions and revenue equally
1416             between first touch and last touch contributors.
1417              
1418             =item * transactions_report
1419              
1420             If set, the generated report includes the transactions report; enabled by default.
1421             The transactions report lists each transaction and the channels that contributed
1422             to it.
1423              
1424             =item * touchlist_report
1425              
1426             If set, the generated report includes the touchlist report; enabled by default.
1427             The touchlist report lists the touches for each transaction in chronological
1428             order. Note that this can be a very large amount of data compared to other reports.
1429              
1430             =item * transaction_distribution_report
1431              
1432             If set, the generated report includes the transaction distribution report;
1433             enabled by default. The transaction distribution report shows the number of
1434             transactions that had one touch, two touches, etc, both by channel and as a
1435             total.
1436              
1437             =item * channel_overlap_report
1438              
1439             If set, the generated report includes the channel overlap report; enabled by
1440             default. The channel overlap report shows the number of transactions that were
1441             touched by 1 channel, 2 channels, etc, and the number of transactions by channel
1442             combination.
1443              
1444             =back
1445              
1446             =head3 Report Output Options
1447              
1448             =over 4
1449              
1450             =item * filename
1451              
1452             Name of file in which to save reports. If not specified, output is sent to STDOUT. The filename extension, if given, is used to determine the file format, which can be xls, csv or txt.
1453              
1454             =item * format
1455              
1456             May be set to xls, csv or txt to specify Excel, CSV and Text format output
1457             respectively. The filename extension takes precedence over this parameter.
1458              
1459             =back
1460              
1461             =head3 Report Formatting Options
1462              
1463             =over 4
1464              
1465             =item * title
1466              
1467             Title to insert into reports.
1468              
1469             =item * column_heading_format
1470              
1471             The cell format (see L) to use for column headings.
1472              
1473             =item * column_formats
1474              
1475             An array of one or more cell formats (see L
1476             FORMATS>) to use in a round-robin manner across the columns of the data.
1477              
1478             =item * header_layout, footer_layout
1479              
1480             Page headers and footers. See L for details.
1481              
1482             =item * strict_integer_values
1483              
1484             If set, transactions and revenue will be reported in integer formats.
1485             Where a reasonable number of transactions are being counted, the
1486             fractional part of the transaction count in a distributed transactions
1487             report is rarely of consequence, and for some the concept of a
1488             fractional transaction attribution can be a distraction from the key
1489             messages of these reports, so this option helps to keep it simple.
1490              
1491             =item * heading_map
1492              
1493             A mapping from default report headings to custom report headings. For example,
1494              
1495             heading_map => {
1496             Transactions => 'Distributed Transactions',
1497             'Revenue' => 'Distributed Revenue (US$)'
1498             }
1499              
1500              
1501             =back
1502              
1503             For every type of report, (all_touches_report, first_touch_report,
1504             transactions_report, etc), report-specific formatting options can be given in a
1505             hashref with corresponding name, e.g. 'all_touches', 'first_touch',
1506             'transactions'. For example,
1507              
1508             column_heading_format => {
1509             bold => 1,
1510             color => 'white',
1511             bg_color => 'gray',
1512             right => 'white',
1513             },
1514             column_format => [
1515             { bg_color => '#D0D0D0', },
1516             { bg_color => '#E8E8E8', },
1517             ],
1518              
1519             all_touches => {
1520             column_heading_format => {
1521             color => 'blue'
1522             }
1523             }
1524            
1525              
1526             The report-specific options are merged with the top level options and then used.
1527              
1528             =head2 all_touches_report
1529              
1530             =head2 even_touches_report
1531              
1532             =head2 distributed_touches_report
1533              
1534             =head2 first_touch_report
1535              
1536             =head2 last_touch_report
1537              
1538             =head2 fifty_fifty_report
1539              
1540             =head2 touchlist_report
1541              
1542             =head2 transaction_distribution_report
1543              
1544             =head2 channel_overlap_report
1545              
1546             These implement the individual reports, taking options similar to those described under L above.
1547              
1548             =head1 DEVELOPER METHODS
1549              
1550             As well as the user API methods list above, there are also a number of methods
1551             that have been exposed as part of the API for developer purposes, e.g. for
1552             developing subclasses to override specific functionality, or for integrating
1553             into systems other than Google Analytics.
1554              
1555             =head2 set_data
1556              
1557             $mt->set_data(start_date => 20100101,
1558             end_date => 20100130,
1559             transactions => \%transactions,
1560             );
1561              
1562             Instead of invoking get_data to retrieve data from Google Analytics, it is also
1563             possible to set data directly - e.g. data collected through another mechanism,
1564             or data from Google Analytics that has been saved to file. set_data allows you
1565             to directly specify the data for subsequent analysis.
1566              
1567             Parameters 'start_date' and 'end_date' are used for reporting and should have the form YYYYMMDD.
1568              
1569             Parameter 'transactions' is a hash of transaction ID to [date (YMD), list of touches] (see
1570             L for description).
1571              
1572             =head2 split_events
1573              
1574             @events = $mt->split_events($cookie_value)
1575              
1576             Splits event label (i.e. 'multitouch' cookie value) into a list comprising order and touch arrayrefs. A touch has format
1577             [ source, medium, subcat, time ]
1578             and an order has format
1579             [ '__ORD', transactionID, revenue, time ].
1580              
1581             =head2 condition_entry
1582              
1583             ($conditioned_key, $conditioned_touches) = $self->condition_entry($key, \@touches)
1584              
1585             condition_entry is called by L for each data entry retrieved from
1586             Google Analytics. It is possibly useful for subclasses to override, in case any
1587             special data conditioning is required. $key is the event label (transaction ID)
1588             and @touches is the list of touches, each touch being an array as described
1589             under L. Conditioning might include removal of duplicates, or
1590             normalisation of transaction IDs.
1591              
1592             =head2 parse_config
1593              
1594             $opts = WWW::Analytics::MultiTouch->parse_config($opts, $config_file)
1595              
1596             Parses $config_file and merges options with $opts.
1597              
1598              
1599             =head1 RELATED INFORMATION
1600              
1601             See L for further details.
1602              
1603             =head1 AUTHOR
1604              
1605             Jon Schutz, C<< >>
1606              
1607             =head1 BUGS
1608              
1609             Please report any bugs or feature requests to C, or through
1610             the web interface at L. I will be notified, and then you'll
1611             automatically be notified of progress on your bug as I make changes.
1612              
1613              
1614             =head1 SUPPORT
1615              
1616             You can find documentation for this module with the perldoc command.
1617              
1618             perldoc WWW::Analytics::MultiTouch
1619              
1620              
1621             You can also look for information at:
1622              
1623             =over 4
1624              
1625             =item * RT: CPAN's request tracker
1626              
1627             L
1628              
1629             =item * AnnoCPAN: Annotated CPAN documentation
1630              
1631             L
1632              
1633             =item * CPAN Ratings
1634              
1635             L
1636              
1637             =item * Search CPAN
1638              
1639             L
1640              
1641             =back
1642              
1643              
1644             =head1 COPYRIGHT & LICENSE
1645              
1646             Copyright 2010 YourAmigo Ltd.
1647              
1648             Permission is hereby granted, free of charge, to any person obtaining a copy
1649             of this software and associated documentation files (the "Software"), to deal
1650             in the Software without restriction, including without limitation the rights
1651             to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
1652             copies of the Software, and to permit persons to whom the Software is
1653             furnished to do so, subject to the following conditions:
1654              
1655             The above copyright notice and this permission notice shall be included in
1656             all copies or substantial portions of the Software.
1657              
1658             THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
1659             IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
1660             FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
1661             AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
1662             LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
1663             OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
1664             THE SOFTWARE.
1665              
1666              
1667             =cut
1668              
1669             1; # End of WWW::Analytics::MultiTouch