File Coverage

blib/lib/WebService/GrowthBook.pm
Criterion Covered Total %
statement 307 484 63.4
branch 89 180 49.4
condition 48 98 48.9
subroutine 34 42 80.9
pod 6 10 60.0
total 484 814 59.4


line stmt bran cond sub pod time code
1             package WebService::GrowthBook;
2             # ABSTRACT: ...
3              
4 3     3   551158 use strict;
  3         6  
  3         140  
5 3     3   16 use warnings;
  3         6  
  3         170  
6 3     3   1473 no indirect;
  3         3946  
  3         12  
7 3     3   186 use feature qw(state);
  3         9  
  3         414  
8 3     3   6131 use Object::Pad;
  3         39835  
  3         21  
9 3     3   1925 use JSON::MaybeUTF8 qw(decode_json_text);
  3         47047  
  3         342  
10 3     3   24 use Scalar::Util qw(blessed);
  3         6  
  3         178  
11 3     3   3216 use Data::Compare qw(Compare);
  3         71661  
  3         28  
12 3     3   19749 use Log::Any qw($log);
  3         42044  
  3         21  
13 3     3   16583 use WebService::GrowthBook::FeatureRepository;
  3         18  
  3         246  
14 3     3   2022 use WebService::GrowthBook::Feature;
  3         16  
  3         224  
15 3     3   2084 use WebService::GrowthBook::FeatureResult;
  3         11  
  3         238  
16 3     3   30 use WebService::GrowthBook::InMemoryFeatureCache;
  3         7  
  3         205  
17 3     3   2294 use WebService::GrowthBook::Eval qw(eval_condition);
  3         16  
  3         693  
18 3     3   33 use WebService::GrowthBook::Util qw(gbhash in_range get_query_string_override get_bucket_ranges choose_variation in_namespace adjust_args_camel_to_snake);
  3         8  
  3         354  
19 3     3   2451 use WebService::GrowthBook::Experiment;
  3         71  
  3         239  
20 3     3   1930 use WebService::GrowthBook::Result;
  3         11  
  3         538  
21              
22             our $VERSION = '0.003';
23              
24             =head1 NAME
25              
26             WebService::GrowthBook - sdk of growthbook
27              
28             =head1 SYNOPSIS
29              
30             use WebService::GrowthBook;
31             my $instance = WebService::GrowthBook->new(client_key => 'my key');
32             $instance->load_features;
33             if($instance->is_on('feature_name')){
34             # do something
35             }
36             else {
37             # do something else
38             }
39             my $string_feature = $instance->get_feature_value('string_feature');
40             my $number_feature = $instance->get_feature_value('number_feature');
41             # get decoded json
42             my $json_feature = $instance->get_feature_value('json_feature');
43              
44             =head1 DESCRIPTION
45              
46             This module is a sdk of growthbook, it provides a simple way to use growthbook features.
47              
48             =cut
49              
50             # singletons
51              
52             class WebService::GrowthBook {
53             field $enabled :param //= 1;
54             field $url :param //= 'https://cdn.growthbook.io';
55             field $client_key :param //= "";
56             field $features :param //= {};
57             field $attributes :param :reader :writer //= {};
58 2     2 0 7 field $cache_ttl :param //= 60;
  2     0 1 9  
  0         0  
  0         0  
59             field $user :param //= {};
60             field $forced_variations :param //= {};
61             field $overrides :param //= {};
62             field $sticky_bucket_service :param //= undef;
63             field $groups :param //= {};
64             field $qa_mode :param //= 0;
65             field $on_experiment_viewed :param //= undef;
66             field $tracking_callback :param //= undef;
67              
68             field $cache //= WebService::GrowthBook::InMemoryFeatureCache->singleton;
69             field $sticky_bucket_assignment_docs //= {};
70             field $tracked = {};
71             field $assigned = {};
72             field $subscriptions = [];
73              
74             sub BUILDARGS{
75 103     103 0 572987 my ($class, %args) = @_;
76 103         665 adjust_args_camel_to_snake(\%args);
77 103         2113 return %args;
78             }
79              
80             ADJUST {
81             $tracking_callback //= $on_experiment_viewed;
82             if($features){
83             $self->set_features($features);
84             }
85             }
86             method load_features {
87             my $feature_repository = WebService::GrowthBook::FeatureRepository->new(cache => $cache);
88             my $loaded_features = $feature_repository->load_features($url, $client_key, $cache_ttl);
89             if($loaded_features){
90             $self->set_features($loaded_features);
91             return 1;
92             }
93             return undef;
94             }
95 105     105 1 227 method set_features($features_set) {
  105         207  
  105         169  
  105         310  
96 105         194 $features = {};
97 105         619 for my $feature_id (keys $features_set->%*) {
98 39         74 my $feature = $features_set->{$feature_id};
99 39 50 33     1701 if(blessed($feature) && $feature->isa('WebService::GrowthBook::Feature')){
100 0         0 $features->{$feature->id} = $feature;
101             }
102             else {
103 39         326 $features->{$feature_id} = WebService::GrowthBook::Feature->new(id => $feature_id, default_value => $feature->{defaultValue}, rules => $feature->{rules});
104             }
105             }
106             }
107              
108 3     3 1 484 method is_on($feature_name) {
  3         14  
  3         8  
  3         7  
109 3         13 my $result = $self->eval_feature($feature_name);
110 3 50       17 return undef unless defined($result);
111 3         18 return $result->on;
112             }
113              
114 3     3 1 452 method is_off($feature_name) {
  3         13  
  3         11  
  3         5  
115 3         13 my $result = $self->eval_feature($feature_name);
116 3 50       11 return undef unless defined($result);
117 3         19 return $result->off;
118             }
119              
120             # I don't know why it is called stack in python version SDK. In fact it is a hash/dict
121 44     44   60 method _eval_feature($feature_name, $stack){
  44         83  
  44         61  
  44         80  
  44         46  
122 44         328 $log->debug("Evaluating feature $feature_name");
123 44 100       255 if(!exists($features->{$feature_name})){
124 5         26 $log->debugf("No such feature: %s", $feature_name);
125 5         94 return WebService::GrowthBook::FeatureResult->new(feature_id => $feature_name, value => undef, source => "unknownFeature");
126             }
127              
128 39 50       98 if ($stack->{$feature_name}) {
129 0         0 $log->warnf("Cyclic prerequisite detected, stack: %s", $stack);
130 0         0 return WebService::GrowthBook::FeatureResult->new(id => $feature_name, value => undef, source => "cyclicPrerequisite");
131             }
132              
133 39         84 $stack->{$feature_name} = 1;
134              
135 39         65 my $feature = $features->{$feature_name};
136 39         50 for my $rule (@{$feature->rules}){
  39         119  
137 37         170 $log->debugf("Evaluating feature %s, rule %s", $feature_name, $rule->to_hash());
138 37 50       212 if ($rule->parent_conditions){
139 0         0 my $prereq_res = $self->eval_prereqs($rule->parent_conditions, $stack);
140 0 0       0 if ($prereq_res eq "gate") {
    0          
    0          
141 0         0 $log->debugf("Top-lavel prerequisite failed, return undef, feature %s", $feature_name);
142 0         0 return WebService::GrowthBook::FeatureResult->new(id => $feature_name, value => undef, source => "prerequisite");
143             }
144             elsif ($prereq_res eq "cyclic") {
145 0         0 return WebService::GrowthBook::FeatureResult->new(id => $feature_name, value => undef, source => "cyclicPrerequisite");
146             }
147             elsif ($prereq_res eq "fail") {
148 0         0 $log->debugf("Skip rule becasue of failing prerequisite, feature %s", $feature_name);
149 0         0 next;
150             }
151             }
152              
153 37 100       114 if ($rule->condition){
154 11 100       22 if (!eval_condition($attributes, $rule->condition)){
155 7         22 $log->debugf("Skip rule because of failed condition, feature %s", $feature_name);
156 7         22 next;
157             }
158             }
159              
160 30 100       76 if ($rule->filters) {
161 2 100       7 if ($self->_is_filtered_out($rule->filters)) {
162 1         6 $log->debugf(
163             "Skip rule because of filters/namespaces, feature %s", $feature_name
164             );
165 1         3 next;
166             }
167             }
168              
169 29 100       66 if (defined($rule->force)){
170 16 100 66     81 if(!$self->_is_included_in_rollout($rule->seed || $feature_name,
171             $rule->hash_attribute,
172             $rule->fallback_attribute,
173             $rule->range,
174             $rule->coverage,
175             $rule->hash_version
176             )){
177 4         16 $log->debugf(
178             "Skip rule because user not included in percentage rollout, feature %s",
179             $feature_name,
180             );
181 4         14 next;
182             }
183 12         49 $log->debugf("Force value from rule, feature %s", $feature_name);
184 12         45 return WebService::GrowthBook::FeatureResult->new(
185             value => $rule->force,
186             source => "force",
187             rule_id => $rule->id,
188             feature_id => $feature_name,
189             );
190             }
191              
192 13 100       48 if(!defined($rule->variations)){
193 1         6 $log->warnf("Skip invalid rule, feature %s", $feature_name);
194 1         3 next;
195             }
196 12   66     45 my $exp = WebService::GrowthBook::Experiment->new(
197             # TODO change $feature_name to $key
198             key => $rule->key || $feature_name,
199             variations => $rule->variations,
200             coverage => $rule->coverage,
201             weights => $rule->weights,
202             hash_attribute => $rule->hash_attribute,
203             fallback_attribute => $rule->fallback_attribute,
204             namespace => $rule->namespace,
205             hash_version => $rule->hash_version,
206             meta => $rule->meta,
207             ranges => $rule->ranges,
208             name => $rule->name,
209             phase => $rule->phase,
210             seed => $rule->seed,
211             filters => $rule->filters,
212             # skip condition, since it will break test 246 and there is no condition in go version
213             #condition => $rule->condition,
214             disable_sticky_bucketing => $rule->disable_sticky_bucketing,
215             bucket_version => $rule->bucket_version,
216             min_bucket_version => $rule->min_bucket_version,
217             );
218 12         71 my $result = $self->_run($exp, $feature_name);
219 12         48 $self->_fire_subscriptions($exp, $result);
220 12 100       42 if (!$result->in_experiment) {
221 3         16 $log->debugf(
222             "Skip rule because user not included in experiment, feature %s", $feature_name
223             );
224 3         11 next;
225             }
226 9 100       31 if ($result->passthrough) {
227 1         13 $log->debugf("Continue to next rule, feature %s", $feature_name);
228              
229 1         3 next;
230             }
231              
232 8         21 $log->debugf("Assign value from experiment, feature %s", $feature_name);
233 8         35 return WebService::GrowthBook::FeatureResult->new(
234             value => $result->value,
235             source => "experiment",
236             experiment => $exp,
237             experiment_result => $result,
238             rule_id => $rule->id,
239             feature_id => $feature_name,
240             );
241             }
242 19         65 my $default_value = $feature->default_value;
243              
244 19         216 return WebService::GrowthBook::FeatureResult->new(
245             feature_id => $feature_name,
246             value => $default_value,
247             source => "defaultValue",
248             );
249             }
250              
251 82     82   157 method _fire_subscriptions($experiment, $result) {
  82         246  
  82         124  
  82         138  
  82         138  
252 82         233 my $prev = $assigned->{$experiment->key};
253 82 50 33     365 if (
      33        
254             !$prev
255             || $prev->{result}->in_experiment != $result->in_experiment
256             || $prev->{result}->variation_id != $result->variation_id
257             ) {
258 82         427 $assigned->{$experiment->key} = {
259             experiment => $experiment,
260             result => $result,
261             };
262 82         149 foreach my $cb (@{$subscriptions}) {
  82         242  
263             eval {
264 0         0 $cb->($experiment, $result);
265 0 0       0 } or do {
266             # Handle exception silently
267             };
268             }
269             }
270             }
271              
272 82     82   151 method _run($experiment, $feature_id = undef){
  82         213  
  82         125  
  82         186  
  82         118  
273             # 1. If experiment has less than 2 variations, return immediately
274 82 100       128 if (scalar @{$experiment->variations} < 2) {
  82         265  
275 1         6 $log->warnf(
276             "Experiment %s has less than 2 variations, skip", $experiment->key
277             );
278 1         9 return $self->_get_experiment_result($experiment, feature_id => $feature_id);
279             }
280              
281             # 2. If growthbook is disabled, return immediately
282 81 100       203 if (!$enabled) {
283 1         15 $log->debugf(
284             "Skip experiment %s because GrowthBook is disabled", $experiment->key
285             );
286 1         8 return $self->_get_experiment_result($experiment, feature_id => $feature_id);
287             }
288             # 2.5. If the experiment props have been overridden, merge them in
289 80 50       235 if (exists $overrides->{$experiment->key}) {
290 0         0 $experiment->update($overrides->{$experiment->{key}});
291             }
292              
293             # 3. If experiment is forced via a querystring in the URL
294             my $qs = get_query_string_override(
295 80         203 $experiment->key, $url, scalar @{$experiment->variations}
  80         163  
296             );
297 80 100       2791 if (defined $qs) {
298 2         11 $log->debugf(
299             "Force variation %d from URL querystring, experiment %s",
300             $qs,
301             $experiment->key,
302             );
303 2         15 return $self->_get_experiment_result($experiment, variation_id => $qs, feature_id => $feature_id);
304             }
305              
306             # 4. If variation is forced in the context
307 78 100       317 if (exists $forced_variations->{$experiment->key}) {
308             $log->debugf(
309             "Force variation %d from GrowthBook context, experiment %s",
310 3         14 $forced_variations->{$experiment->key},
311             $experiment->key,
312             );
313             return $self->_get_experiment_result(
314 3         19 $experiment, variation_id => $forced_variations->{$experiment->key}, feature_id => $feature_id
315             );
316             }
317              
318             # 5. If experiment is a draft or not active, return immediately
319 75 100 66     310 if ($experiment->status eq "draft" or not $experiment->active) {
320 1         12 $log->debugf("Experiment %s is not active, skip", $experiment->key);
321 1         8 return $self->_get_experiment_result($experiment, feature_id => $feature_id);
322             }
323              
324             # 6. Get the user hash attribute and value
325 74         332 my ($hash_attribute, $hash_value) = $self->_get_hash_value($experiment->hash_attribute, $experiment->fallback_attribute);
326 74 100       229 if (!$hash_value) {
327 5         24 $log->debugf(
328             "Skip experiment %s because user's hashAttribute value is empty",
329             $experiment->key,
330             );
331 5         32 return $self->_get_experiment_result($experiment, feature_id => $feature_id);
332             }
333              
334 69         122 my $assigned = -1;
335              
336 69         123 my $found_sticky_bucket = 0;
337 69         121 my $sticky_bucket_version_is_blocked = 0;
338 69 50 33     303 if ($sticky_bucket_service && !$experiment->disableStickyBucketing) {
339 0         0 my $sticky_bucket = $self->_get_sticky_bucket_variation(
340             experiment_key => $experiment->key,
341             bucket_version => $experiment->bucketVersion,
342             min_bucket_version => $experiment->minBucketVersion,
343             meta => $experiment->meta,
344             hash_attribute => $experiment->hashAttribute,
345             fallback_attribute => $experiment->fallbackAttribute,
346             );
347 0         0 $found_sticky_bucket = $sticky_bucket->{variation} >= 0;
348 0         0 $assigned = $sticky_bucket->{variation};
349 0         0 $sticky_bucket_version_is_blocked = $sticky_bucket->{versionIsBlocked};
350             }
351              
352              
353 69 50       177 if ($found_sticky_bucket) {
354 0         0 $log->debugf(
355             "Found sticky bucket for experiment %s, assigning sticky variation %s",
356             $experiment->key, $assigned
357             );
358             }
359              
360             # Some checks are not needed if we already have a sticky bucket
361             else {
362 69 100 100     305 if ($experiment->filters){
    100          
363              
364             # 7. Filtered out / not in namespace
365 4 100       15 if ($self->_is_filtered_out($experiment->filters)) {
366 1         6 $log->debugf(
367             "Skip experiment %s because of filters/namespaces", $experiment->key
368             );
369 1         8 return $self->_get_experiment_result($experiment, feature_id => $feature_id);
370             }
371             }
372             elsif ($experiment->namespace && !in_namespace($hash_value, $experiment->namespace)) {
373 2         7 $log->debugf("Skip experiment %s because of namespace", $experiment->key);
374 2         14 return $self->_get_experiment_result($experiment, feature_id => $feature_id);
375             }
376              
377             # 7.5. If experiment has an include property
378 66 50       233 if ($experiment->include) {
379             eval {
380 0 0       0 unless ($experiment->include->()) {
381 0         0 $log->debugf(
382             "Skip experiment %s because include() returned false",
383             $experiment->key,
384             );
385 0         0 return $self->_get_experiment_result($experiment, feature_id => $feature_id);
386             }
387 0 0       0 } or do {
388 0         0 $log->warnf(
389             "Skip experiment %s because include() raised an Exception",
390             $experiment->key,
391             );
392 0         0 return $self->_get_experiment_result($experiment, feature_id => $feature_id);
393             };
394             }
395              
396             # 8. Exclude if condition is false
397 66 100 100     365 if ($experiment->condition && !eval_condition($self->attributes, $experiment->condition)) {
398 1         7 $log->debugf(
399             "Skip experiment %s because user failed the condition", $experiment->key
400             );
401 1         7 return $self->_get_experiment_result($experiment, feature_id => $feature_id);
402             }
403              
404             # 8.05 Exclude if parent conditions are not met
405 65 50       272 if ($experiment->parent_conditions) {
406 0         0 my $prereq_res = $self->eval_prereqs($experiment->parent_conditions, {});
407 0 0 0     0 if ($prereq_res eq "gate" || $prereq_res eq "fail") {
408 0         0 $log->debugf(
409             "Skip experiment %s because of failing prerequisite", $experiment->key
410             );
411 0         0 return $self->_get_experiment_result($experiment, feature_id => $feature_id);
412             }
413 0 0       0 if ($prereq_res eq "cyclic") {
414 0         0 $log->debugf(
415             "Skip experiment %s because of cyclic prerequisite", $experiment->key
416             );
417 0         0 return $self->_get_experiment_result($experiment, feature_id => $feature_id);
418             }
419             }
420              
421             # 8.1. Make sure user is in a matching group
422 65 50 33     232 if ($experiment->groups && @{$experiment->groups}) {
  0         0  
423 0   0     0 my $exp_groups = $groups || {};
424 0         0 my $matched = 0;
425 0         0 foreach my $group (@{$experiment->groups}) {
  0         0  
426 0 0       0 if ($exp_groups->{$group}) {
427 0         0 $matched = 1;
428 0         0 last;
429             }
430             }
431 0 0       0 if (!$matched) {
432 0         0 $log->debugf(
433             "Skip experiment %s because user not in required group",
434             $experiment->key,
435             );
436 0         0 return $self->_get_experiment_result($experiment, feature_id => $feature_id);
437             }
438             }
439              
440             }
441              
442             # The following apply even when in a sticky bucket
443              
444             # 8.2. If experiment.url is set, see if it's valid
445 65 50       251 if ($experiment->url) {
446 0 0       0 unless ($self->_url_is_valid($experiment->url)) {
447 0         0 $log->debugf(
448             "Skip experiment %s because current URL is not targeted",
449             $experiment->key,
450             );
451 0         0 return $self->_get_experiment_result($experiment, feature_id => $feature_id);
452             }
453             }
454              
455             # 9. Get bucket ranges and choose variation
456 65   66     249 my $n = gbhash(
      50        
457             $experiment->seed // $experiment->key, $hash_value, $experiment->hash_version // 1
458             );
459 65 50       262 if (!defined $n) {
460 0         0 $log->warnf(
461             "Skip experiment %s because of invalid hashVersion", $experiment->key
462             );
463 0         0 return $self->_get_experiment_result($experiment, feature_id => $feature_id);
464             }
465              
466 65 50       169 if (!$found_sticky_bucket) {
467 65         258 my $c = $experiment->coverage;
468             my $ranges = $experiment->ranges || get_bucket_ranges(
469 65   66     219 scalar @{$experiment->variations}, defined $c ? $c : 1, $experiment->weights
470             );
471 65         632 $assigned = choose_variation($n, $ranges);
472              
473             }
474              
475             # Unenroll if any prior sticky buckets are blocked by version
476 65 50       175 if ($sticky_bucket_version_is_blocked) {
477 0         0 $log->debugf(
478             "Skip experiment %s because sticky bucket version is blocked",
479             $experiment->key
480             );
481 0         0 return $self->_get_experiment_result(
482             $experiment, feature_id => $feature_id, sticky_bucket_used => 1
483             );
484             }
485              
486             # 10. Return if not in experiment
487 65 100       194 if ($assigned < 0) {
488 8         37 $log->debugf(
489             "Skip experiment %s because user is not included in the rollout",
490             $experiment->key,
491             );
492 8         59 return $self->_get_experiment_result($experiment, feature_id => $feature_id);
493             }
494              
495             # 11. If experiment is forced, return immediately
496 57 100       221 if (defined $experiment->force) {
497 3         14 $log->debugf(
498             "Force variation %d in experiment %s", $experiment->force, $experiment->key
499             );
500 3         21 return $self->_get_experiment_result(
501             $experiment, feature_id => $feature_id, variation_id => $experiment->force
502             );
503             }
504              
505             # 12. Exclude if in QA mode
506 54 100       164 if ($qa_mode) {
507 1         16 $log->debugf("Skip experiment %s because of QA Mode", $experiment->key);
508 1         8 return $self->_get_experiment_result($experiment, feature_id => $feature_id);
509             }
510              
511             # 12.5. If experiment is stopped, return immediately
512 53 50       167 if ($experiment->status eq "stopped") {
513 0         0 $log->debugf("Skip experiment %s because it is stopped", $experiment->key);
514 0         0 return $self->_get_experiment_result($experiment, feature_id => $feature_id);
515             }
516              
517             # 13. Build the result object
518 53         277 my $result = $self->_get_experiment_result(
519             $experiment,
520             variation_id => $assigned,
521             hash_used => 1,
522             feature_id => $feature_id,
523             bucket => $n,
524             sticky_bucket_used => $found_sticky_bucket
525             );
526              
527             # 13.5 Persist sticky bucket
528 53 50 33     214 if ($sticky_bucket_service && !$experiment->disable_sticky_bucketing) {
529 0         0 my %assignment;
530 0         0 $assignment{$self->_get_sticky_bucket_experiment_key(
531             $experiment->key,
532             $experiment->bucketVersion
533             )} = $result->key;
534 0         0 my $data = $self->_generate_sticky_bucket_assignment_doc(
535             $hash_attribute,
536             $hash_value,
537             \%assignment
538             );
539 0         0 my $doc = $data->{doc};
540 0 0 0     0 if ($doc && $data->{changed}) {
541 0   0     0 $sticky_bucket_assignment_docs //= {};
542 0         0 $sticky_bucket_assignment_docs->{$data->{key}} = $doc;
543 0         0 $sticky_bucket_service->save_assignments($doc);
544             }
545             }
546             # 14. Fire the tracking callback if set
547 53         236 $self->_track($experiment, $result);
548              
549             # 15. Return the result
550 53         185 $log->debugf("Assigned variation %d in experiment %s", $assigned, $experiment->key);
551 53         276 return $result;
552             }
553              
554 53     53   92 method _track($experiment, $result) {
  53         135  
  53         85  
  53         77  
  53         74  
555              
556 53 50       152 return unless $tracking_callback;
557              
558 0         0 my $key = $result->hash_attribute
559             . $result->hash_value
560             . $experiment->key
561             . $result->variation_id;
562              
563 0 0       0 unless ($tracked->{$key}) {
564             eval {
565 0         0 $tracking_callback->($experiment, $result);
566 0         0 $tracked->{$key} = 1;
567 0 0       0 } or do {
568             # Handle exception silently
569             };
570             }
571             }
572              
573 0     0   0 method _generate_sticky_bucket_assignment_doc($attribute_name, $attribute_value, $assignments){
  0         0  
  0         0  
  0         0  
  0         0  
  0         0  
574 0         0 my $key = $attribute_name . "||" . $attribute_value;
575 0   0     0 my $existing_assignments = $sticky_bucket_assignment_docs->{$key}{assignments} // {};
576              
577 0         0 my %new_assignments = (%$existing_assignments, %$assignments);
578              
579 0         0 my $changed = !Compare($existing_assignments, \%new_assignments);
580              
581             return {
582 0         0 key => $key,
583             doc => {
584             attribute_name => $attribute_name,
585             attribute_value => $attribute_value,
586             assignments => \%new_assignments
587             },
588             changed => $changed
589             };
590             }
591              
592 0     0   0 method _url_is_valid($pattern) {
  0         0  
  0         0  
  0         0  
593              
594 0 0       0 return 0 unless $url;
595              
596             eval {
597 0         0 my $r = qr/$pattern/;
598 0 0       0 if ($self->{_url} =~ $r) {
599 0         0 return 1;
600             }
601              
602 0         0 my $path_only = $url;
603 0         0 $path_only =~ s/^[^\/]*\//\//;
604 0         0 $path_only =~ s/^https?:\/\///;
605              
606 0 0       0 if ($path_only =~ $r) {
607 0         0 return 1;
608             }
609 0         0 return 0;
610 0 0       0 } or do {
611 0         0 return 1;
612             };
613             }
614              
615 6     6   15 method _is_filtered_out($filters) {
  6         15  
  6         13  
  6         10  
616              
617 6         2107 foreach my $filter (@$filters) {
618 8   100     54 my ($dummy, $hash_value) = $self->_get_hash_value($filter->{attribute} // "id");
619 8 50       31 if ($hash_value eq "") {
620 0         0 return 0;
621             }
622              
623 8   50     64 my $n = gbhash($filter->{seed} // "", $hash_value, $filter->{hashVersion} // 2);
      50        
624 8 50       28 if (!defined $n) {
625 0         0 return 0;
626             }
627              
628 8         17 my $filtered = 0;
629 8         16 foreach my $range (@{$filter->{ranges}}) {
  8         23  
630 11 100       32 if (in_range($n, $range)) {
631 6         9 $filtered = 1;
632 6         13 last;
633             }
634             }
635 8 100       29 if (!$filtered) {
636 2         9 return 1;
637             }
638             }
639 4         18 return 0;
640             }
641              
642 0     0   0 method _get_sticky_bucket_assignments($attr = '', $fallback = ''){
  0         0  
  0         0  
  0         0  
  0         0  
643 0         0 my %merged;
644              
645 0         0 my ($dummy, $hash_value) = $self->_get_hash_value($attr);
646 0         0 my $key = "$attr||$hash_value";
647 0 0       0 if (exists $sticky_bucket_assignment_docs->{$key}) {
648 0         0 %merged = %{ $sticky_bucket_assignment_docs->{$key}{assignments} };
  0         0  
649             }
650              
651 0 0       0 if ($fallback) {
652 0         0 ($dummy, $hash_value) = $self->_get_hash_value($fallback);
653 0         0 $key = "$fallback||$hash_value";
654 0 0       0 if (exists $self->{_sticky_bucket_assignment_docs}{$key}) {
655             # Merge the fallback assignments, but don't overwrite existing ones
656 0         0 for my $k (keys %{ $sticky_bucket_assignment_docs->{$key}{assignments} }) {
  0         0  
657 0   0     0 $merged{$k} //= $sticky_bucket_assignment_docs->{$key}{assignments}{$k};
658             }
659             }
660             }
661              
662 0         0 return \%merged;
663             }
664              
665 0     0   0 method _get_sticky_bucket_variation($experiment_key, $bucket_version = 0, $min_bucket_version = 0, $meta = {}, $hash_attribute = undef, $fallback_attribute = undef){
  0         0  
  0         0  
  0         0  
  0         0  
  0         0  
  0         0  
  0         0  
  0         0  
666 0         0 my $id = $self->_get_sticky_bucket_experiment_key($experiment_key, $bucket_version);
667              
668              
669 0         0 my $assignments = $self->_get_sticky_bucket_assignments($hash_attribute, $fallback_attribute);
670 0 0       0 if ($self->_is_blocked($assignments, $experiment_key, $min_bucket_version)) {
671             return {
672 0         0 variation => -1,
673             versionIsBlocked => 1
674             };
675             }
676              
677 0         0 my $variation_key = $assignments->{$id};
678 0 0       0 if (!$variation_key) {
679             return {
680 0         0 variation => -1
681             };
682             }
683              
684             # Find the key in meta
685 0         0 my $variation = -1;
686 0         0 for (my $i = 0; $i < @$meta; $i++) {
687 0 0       0 if ($meta->[$i]->{key} eq $variation_key) {
688 0         0 $variation = $i;
689 0         0 last;
690             }
691             }
692              
693 0 0       0 if ($variation < 0) {
694             return {
695 0         0 variation => -1
696             };
697             }
698              
699 0         0 return { variation => $variation };
700             }
701              
702 0     0   0 method _is_blocked($assignments, $experiment_key, $min_bucket_version = 0){
  0         0  
  0         0  
  0         0  
  0         0  
  0         0  
703 0 0       0 if ($min_bucket_version > 0) {
704 0         0 for my $i (0 .. $min_bucket_version - 1) {
705 0         0 my $blocked_key = $self->_get_sticky_bucket_experiment_key($experiment_key, $i);
706 0 0       0 if (exists $assignments->{$blocked_key}) {
707 0         0 return 1;
708             }
709             }
710             }
711 0         0 return 0;
712             }
713              
714 0     0   0 method _get_sticky_bucket_experiment_key($experiment_key, $bucket_version = 0){
  0         0  
  0         0  
  0         0  
  0         0  
715 0         0 return $experiment_key . "__" . $bucket_version;
716             }
717              
718 82     82   147 method _get_experiment_result($experiment, %args){
  82         205  
  82         151  
  82         413  
  82         137  
719 82   100     307 my $variation_id = $args{variation_id} // -1;
720 82   100     403 my $hash_used = $args{hash_used} // 0;
721 82         159 my $feature_id = $args{feature_id};
722 82         154 my $bucket = $args{bucket};
723 82   100     304 my $sticky_bucket_used = $args{sticky_bucket_used} // 0;
724 82         133 my $in_experiment = 1;
725 82 100 100     315 if ($variation_id < 0 || $variation_id > @{$experiment->variations} - 1) {
  60         164  
726 23         39 $variation_id = 0;
727 23         59 $in_experiment = 0;
728             }
729              
730 82         160 my $meta;
731 82 100       308 if ($experiment->meta) {
732 3         8 $meta = $experiment->meta->[$variation_id];
733             }
734              
735 82         238 my ($hash_attribute, $hash_value) = $self->_get_orig_hash_value($experiment->hash_attribute, $experiment->fallback_attribute);
736 82         318 return WebService::GrowthBook::Result->new(
737             feature_id => $feature_id,
738             in_experiment => $in_experiment,
739             variation_id => $variation_id,
740             value => $experiment->variations->[$variation_id],
741             hash_used => $hash_used,
742             hash_attribute => $hash_attribute,
743             hash_value => $hash_value,
744             meta => $meta,
745             bucket => $bucket,
746             sticky_bucket_used => $sticky_bucket_used
747             );
748             }
749              
750 16     16   25 method _is_included_in_rollout($seed, $hash_attribute, $fallback_attribute, $range, $coverage, $hash_version){
  16         38  
  16         74  
  16         40  
  16         24  
  16         22  
  16         19  
  16         18  
  16         18  
751 16 100 100     52 if (!defined($coverage) && !defined($range)){
752 8         30 return 1;
753             }
754 8         12 my $hash_value;
755 8         28 (undef, $hash_value) = $self->_get_hash_value($hash_attribute, $fallback_attribute);
756 8 100       21 if($hash_value eq "") {
757              
758 1         3 return 0;
759             }
760              
761 7   50     28 my $n = gbhash($seed, $hash_value, $hash_version || 1);
762 7 100       18 if (!defined($n)){
763              
764 1         4 return 0;
765             }
766              
767 6 100       15 if($range){
    50          
768              
769 4         9 return in_range($n, $range);
770             }
771             elsif($coverage){
772 2         8 return $n < $coverage;
773             }
774              
775 0         0 return 1;
776             }
777              
778 90     90   162 method _get_hash_value($attr, $fallback_attr = undef){
  90         239  
  90         160  
  90         155  
  90         541  
779 90         186 my $val;
780 90         273 ($attr, $val) = $self->_get_orig_hash_value($attr, $fallback_attr);
781 90         316 return ($attr, "$val");
782             }
783              
784 172     172   253 method _get_orig_hash_value($attr, $fallback_attr){
  172         330  
  172         327  
  172         252  
  172         245  
785 172   50     401 $attr ||= "id";
786 172         296 my $val = "";
787              
788 172 100       520 if (exists $attributes->{$attr}) {
    50          
789 165   100     462 $val = $attributes->{$attr} || "";
790             } elsif (exists $user->{$attr}) {
791 0   0     0 $val = $user->{$attr} || "";
792             }
793              
794             # If no match, try fallback
795 172 0 66     728 if ((!$val || $val eq "") && $fallback_attr && $self->{sticky_bucket_service}) {
      66        
      33        
796 0 0       0 if (exists $attributes->{$fallback_attr}) {
    0          
797 0   0     0 $val = $attributes->{$fallback_attr} || "";
798             } elsif (exists $user->{$fallback_attr}) {
799 0   0     0 $val = $user->{$fallback_attr} || "";
800             }
801              
802 0 0 0     0 if (!$val || $val ne "") {
803 0         0 $attr = $fallback_attr;
804             }
805             }
806              
807 172         640 return ($attr, $val);
808             }
809              
810 0     0 0 0 method eval_prereqs($parent_conditions, $stack){
  0         0  
  0         0  
  0         0  
  0         0  
811 0         0 foreach my $parent_condition (@$parent_conditions) {
812 0         0 my $parent_res = $self->_eval_feature($parent_condition->{id}, $stack);
813              
814 0 0       0 if ($parent_res->{source} eq "cyclicPrerequisite") {
815 0         0 return "cyclic";
816             }
817              
818 0 0       0 if (!eval_condition({ value => $parent_res->{value} }, $parent_condition->{condition})) {
819 0 0       0 if ($parent_condition->{gate}) {
820 0         0 return "gate";
821             }
822 0         0 return "fail";
823             }
824             }
825 0         0 return "pass";
826             }
827 44     44 1 193 method eval_feature($feature_name){
  44         133  
  44         99  
  44         53  
828 44         144 return $self->_eval_feature($feature_name, {});
829             }
830              
831 6     6 1 19 method get_feature_value($feature_name, $fallback = undef){
  6         29  
  6         14  
  6         13  
  6         37  
832 6         20 my $result = $self->eval_feature($feature_name);
833 6 100       27 return $fallback unless defined($result->value);
834 4         12 return $result->value;
835             }
836              
837 70     70 0 143 method run($experiment){
  70         206  
  70         122  
  70         133  
838 70         296 my $result = $self->_run($experiment);
839 70         317 $self->_fire_subscriptions($experiment, $result);
840 70         251 return $result;
841             }
842             }
843              
844             =head1 METHODS
845              
846             =head2 load_features
847              
848             load features from growthbook API
849              
850             $instance->load_features;
851              
852             =head2 is_on
853              
854             check if a feature is on
855              
856             $instance->is_on('feature_name');
857              
858             Please note it will return undef if the feature does not exist.
859              
860             =head2 is_off
861              
862             check if a feature is off
863              
864             $instance->is_off('feature_name');
865              
866             Please note it will return undef if the feature does not exist.
867              
868             =head2 get_feature_value
869              
870             get the value of a feature
871              
872             $instance->get_feature_value('feature_name');
873              
874             Please note it will return undef if the feature does not exist.
875              
876             =head2 set_features
877              
878             set features
879              
880             $instance->set_features($features);
881              
882             =head2 eval_feature
883              
884             evaluate a feature to get the value
885              
886             $instance->eval_feature('feature_name');
887              
888             =head2 set_attributes
889              
890             set attributes (can be set when creating gb object) and evaluate features
891              
892             $instance->set_attributes({attr1 => 'value1', attr2 => 'value2'});
893             $instance->eval_feature('feature_name');
894              
895             =cut
896              
897              
898             1;
899              
900              
901             =head1 SEE ALSO
902              
903             =over 4
904              
905             =item * L
906              
907             =item * L
908              
909             =back
910