growthbook 1.3.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/growthbook/conditions.rb +409 -102
- data/lib/growthbook/context.rb +70 -20
- data/lib/growthbook/feature_repository.rb +24 -2
- data/lib/growthbook/feature_result.rb +10 -2
- data/lib/growthbook/feature_rule.rb +10 -1
- data/lib/growthbook/fnv.rb +5 -2
- data/lib/growthbook/inline_experiment.rb +7 -1
- data/lib/growthbook/payload_reducer.rb +278 -0
- data/lib/growthbook/util.rb +36 -0
- data/lib/growthbook.rb +47 -0
- metadata +35 -6
data/lib/growthbook/context.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'set'
|
|
4
|
+
|
|
3
5
|
module Growthbook
|
|
4
6
|
# Context object passed into the GrowthBook constructor.
|
|
5
7
|
class Context # rubocop:disable Metrics/ClassLength
|
|
@@ -33,6 +35,9 @@ module Growthbook
|
|
|
33
35
|
# @return [Hash[String, Any]] Forced feature values
|
|
34
36
|
attr_reader :forced_features
|
|
35
37
|
|
|
38
|
+
# @return [Hash[String, Array]] Saved groups, used by the $inGroup/$notInGroup operators
|
|
39
|
+
attr_reader :saved_groups
|
|
40
|
+
|
|
36
41
|
# @return [Growthbook::StickyBucketService] Sticky bucket service for sticky bucketing
|
|
37
42
|
attr_reader :sticky_bucket_service
|
|
38
43
|
|
|
@@ -57,6 +62,7 @@ module Growthbook
|
|
|
57
62
|
@enabled = true
|
|
58
63
|
@impressions = {}
|
|
59
64
|
@sticky_bucket_assignment_docs = {}
|
|
65
|
+
@saved_groups = {}
|
|
60
66
|
|
|
61
67
|
features = {}
|
|
62
68
|
attributes = {}
|
|
@@ -90,6 +96,8 @@ module Growthbook
|
|
|
90
96
|
@sticky_bucket_service = value
|
|
91
97
|
when :sticky_bucket_identifier_attributes
|
|
92
98
|
@sticky_bucket_identifier_attributes = value
|
|
99
|
+
when :saved_groups, :savedGroups
|
|
100
|
+
@saved_groups = value || {}
|
|
93
101
|
else
|
|
94
102
|
warn("Unknown context option: #{key}")
|
|
95
103
|
end
|
|
@@ -158,7 +166,7 @@ module Growthbook
|
|
|
158
166
|
|
|
159
167
|
return 'cyclic' if parent_res.source == 'cyclicPrerequisite'
|
|
160
168
|
|
|
161
|
-
next if
|
|
169
|
+
next if parent_condition_passes?(parent_res.value, parent_condition['condition'])
|
|
162
170
|
return 'gate' if parent_condition['gate']
|
|
163
171
|
|
|
164
172
|
return 'fail'
|
|
@@ -176,6 +184,9 @@ module Growthbook
|
|
|
176
184
|
|
|
177
185
|
return get_feature_result(key.to_s, nil, 'cyclicPrerequisite', nil, nil) if stack.include?(key.to_s)
|
|
178
186
|
|
|
187
|
+
# Work on a copy so sibling prerequisite evaluations don't pollute each
|
|
188
|
+
# other's path (matches the by-value stack semantics of the other SDKs)
|
|
189
|
+
stack = stack.dup
|
|
179
190
|
stack.add(key.to_s)
|
|
180
191
|
|
|
181
192
|
feature.rules.each do |rule|
|
|
@@ -212,7 +223,7 @@ module Growthbook
|
|
|
212
223
|
)
|
|
213
224
|
next unless included_in_rollout
|
|
214
225
|
|
|
215
|
-
return get_feature_result(key.to_s, rule.force, 'force', nil, nil)
|
|
226
|
+
return get_feature_result(key.to_s, rule.force, 'force', nil, nil, rule.id)
|
|
216
227
|
end
|
|
217
228
|
# Experiment rule
|
|
218
229
|
next unless rule.experiment?
|
|
@@ -224,7 +235,7 @@ module Growthbook
|
|
|
224
235
|
|
|
225
236
|
next unless result.in_experiment && !result.passthrough
|
|
226
237
|
|
|
227
|
-
return get_feature_result(key.to_s, result.value, 'experiment', exp, result)
|
|
238
|
+
return get_feature_result(key.to_s, result.value, 'experiment', exp, result, rule.id)
|
|
228
239
|
end
|
|
229
240
|
|
|
230
241
|
# Fallback
|
|
@@ -261,8 +272,8 @@ module Growthbook
|
|
|
261
272
|
return get_experiment_result(exp, -1, hash_used: false, feature_id: feature_id) unless exp.active
|
|
262
273
|
|
|
263
274
|
# 6. Get hash_attribute/value and return if empty
|
|
264
|
-
hash_attribute, hash_value_raw = get_hash_attribute(exp.hash_attribute, exp
|
|
265
|
-
hash_value = hash_value_raw
|
|
275
|
+
hash_attribute, hash_value_raw = get_hash_attribute(exp.hash_attribute, experiment_fallback_attribute(exp))
|
|
276
|
+
hash_value = hash_string(hash_value_raw)
|
|
266
277
|
return get_experiment_result(exp, -1, hash_used: false, feature_id: feature_id) if hash_value.empty?
|
|
267
278
|
|
|
268
279
|
assigned = -1
|
|
@@ -368,10 +379,20 @@ module Growthbook
|
|
|
368
379
|
new_hash
|
|
369
380
|
end
|
|
370
381
|
|
|
382
|
+
# A malformed prerequisite condition never matches, rather than raising into the app
|
|
383
|
+
def parent_condition_passes?(parent_value, condition)
|
|
384
|
+
Growthbook::Conditions.eval_condition({ 'value' => parent_value }, condition || {}, @saved_groups)
|
|
385
|
+
rescue StandardError
|
|
386
|
+
false
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# A condition that raises, such as a malformed one, fails rather than breaking evaluation
|
|
371
390
|
def condition_passes?(condition)
|
|
372
391
|
return false if condition.nil?
|
|
373
392
|
|
|
374
|
-
Growthbook::Conditions.eval_condition(@attributes, condition)
|
|
393
|
+
Growthbook::Conditions.eval_condition(@attributes, condition, @saved_groups)
|
|
394
|
+
rescue StandardError
|
|
395
|
+
false
|
|
375
396
|
end
|
|
376
397
|
|
|
377
398
|
def get_experiment_result(experiment, variation_index = -1, hash_used: false, feature_id: '', bucket: nil, sticky_bucket_used: false)
|
|
@@ -381,7 +402,7 @@ module Growthbook
|
|
|
381
402
|
in_experiment = false
|
|
382
403
|
end
|
|
383
404
|
|
|
384
|
-
hash_attribute, hash_value = get_hash_attribute(experiment.hash_attribute, experiment
|
|
405
|
+
hash_attribute, hash_value = get_hash_attribute(experiment.hash_attribute, experiment_fallback_attribute(experiment))
|
|
385
406
|
meta = experiment.meta ? experiment.meta[variation_index] : {}
|
|
386
407
|
|
|
387
408
|
result = Growthbook::InlineExperimentResult.new(
|
|
@@ -405,8 +426,8 @@ module Growthbook
|
|
|
405
426
|
result
|
|
406
427
|
end
|
|
407
428
|
|
|
408
|
-
def get_feature_result(key, value, source, experiment, experiment_result)
|
|
409
|
-
res = Growthbook::FeatureResult.new(value, source, experiment, experiment_result)
|
|
429
|
+
def get_feature_result(key, value, source, experiment, experiment_result, rule_id = '')
|
|
430
|
+
res = Growthbook::FeatureResult.new(value, source, experiment, experiment_result, rule_id)
|
|
410
431
|
|
|
411
432
|
track_feature_usage(key, res)
|
|
412
433
|
|
|
@@ -427,20 +448,45 @@ module Growthbook
|
|
|
427
448
|
nil
|
|
428
449
|
end
|
|
429
450
|
|
|
451
|
+
# Returns the hash attribute and its raw value ('' when missing). Like JS,
|
|
452
|
+
# only a non-empty string or a finite, non-zero number identifies a user;
|
|
453
|
+
# anything else counts as missing, and then the fallback attribute is tried
|
|
454
|
+
# with the same rule.
|
|
430
455
|
def get_hash_attribute(attr, fallback_attr)
|
|
431
|
-
attr
|
|
456
|
+
attr = 'id' if attr.nil? || attr == ''
|
|
432
457
|
|
|
433
458
|
val = get_attribute(attr)
|
|
459
|
+
val = '' unless hashable?(val)
|
|
434
460
|
|
|
435
461
|
# If no match, try fallback
|
|
436
|
-
if
|
|
462
|
+
if val == '' && fallback_attr && hashable?(get_attribute(fallback_attr))
|
|
437
463
|
val = get_attribute(fallback_attr)
|
|
438
|
-
attr = fallback_attr
|
|
464
|
+
attr = fallback_attr
|
|
439
465
|
end
|
|
440
466
|
|
|
441
467
|
[attr, val]
|
|
442
468
|
end
|
|
443
469
|
|
|
470
|
+
def hashable?(value)
|
|
471
|
+
return value != '' if value.is_a?(String)
|
|
472
|
+
return value != 0 && value.to_f.finite? if value.is_a?(Integer) || value.is_a?(Float)
|
|
473
|
+
|
|
474
|
+
false
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
# Formats a hash value like JS String(): 1.0 as "1"
|
|
478
|
+
def hash_string(value)
|
|
479
|
+
return value if value.is_a?(String)
|
|
480
|
+
return Growthbook::Util.js_number_to_s(value) if value.is_a?(Integer) || value.is_a?(Float)
|
|
481
|
+
|
|
482
|
+
''
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
# Experiments only use their fallback attribute with sticky bucketing, like JS
|
|
486
|
+
def experiment_fallback_attribute(experiment)
|
|
487
|
+
experiment.fallback_attribute if @sticky_bucket_service && !experiment.disable_sticky_bucketing
|
|
488
|
+
end
|
|
489
|
+
|
|
444
490
|
def get_attribute(key)
|
|
445
491
|
return '' if key.nil?
|
|
446
492
|
|
|
@@ -459,10 +505,11 @@ module Growthbook
|
|
|
459
505
|
|
|
460
506
|
def included_in_rollout?(seed:, hash_attribute:, fallback_attribute:, hash_version:, range:, coverage:)
|
|
461
507
|
return true if range.nil? && coverage.nil?
|
|
508
|
+
return false if range.nil? && coverage&.zero?
|
|
462
509
|
|
|
463
510
|
_, hash_value_raw = get_hash_attribute(hash_attribute, fallback_attribute)
|
|
464
511
|
|
|
465
|
-
hash_value = hash_value_raw
|
|
512
|
+
hash_value = hash_string(hash_value_raw)
|
|
466
513
|
|
|
467
514
|
return false if hash_value.empty?
|
|
468
515
|
|
|
@@ -470,17 +517,20 @@ module Growthbook
|
|
|
470
517
|
return false if n.nil?
|
|
471
518
|
|
|
472
519
|
return Growthbook::Util.in_range?(n, range) if range
|
|
473
|
-
|
|
520
|
+
# Half-open, like in_range?, so a coverage of k/10000 includes exactly k hash buckets
|
|
521
|
+
return n < coverage if coverage
|
|
474
522
|
|
|
475
523
|
true
|
|
476
524
|
end
|
|
477
525
|
|
|
478
526
|
def filtered_out?(filters)
|
|
479
527
|
filters.any? do |filter|
|
|
480
|
-
|
|
528
|
+
_, hash_value_raw = get_hash_attribute(filter['attribute'], nil)
|
|
529
|
+
hash_value = hash_string(hash_value_raw)
|
|
481
530
|
|
|
531
|
+
# Like JS, a user without a hash value is filtered out
|
|
482
532
|
if hash_value.empty?
|
|
483
|
-
|
|
533
|
+
true
|
|
484
534
|
else
|
|
485
535
|
n = Growthbook::Util.get_hash(seed: filter['seed'] || '', value: hash_value, version: filter['hashVersion'] || 2)
|
|
486
536
|
|
|
@@ -522,7 +572,7 @@ module Growthbook
|
|
|
522
572
|
|
|
523
573
|
@sticky_bucket_identifier_attributes.each do |attr|
|
|
524
574
|
_, hash_value = get_hash_attribute(attr, nil)
|
|
525
|
-
attributes[attr] = hash_value
|
|
575
|
+
attributes[attr] = hash_string(hash_value)
|
|
526
576
|
end
|
|
527
577
|
attributes
|
|
528
578
|
end
|
|
@@ -530,13 +580,13 @@ module Growthbook
|
|
|
530
580
|
def _get_sticky_bucket_assignments(attr = nil, fallback = nil)
|
|
531
581
|
merged = {}
|
|
532
582
|
|
|
533
|
-
|
|
534
|
-
key = "#{attr}||#{hash_value}"
|
|
583
|
+
attr, hash_value = get_hash_attribute(attr, nil)
|
|
584
|
+
key = "#{attr}||#{hash_string(hash_value)}"
|
|
535
585
|
merged = @sticky_bucket_assignment_docs[key]['assignments'] if @sticky_bucket_assignment_docs.key?(key)
|
|
536
586
|
|
|
537
587
|
if fallback
|
|
538
588
|
_, hash_value = get_hash_attribute(fallback, nil)
|
|
539
|
-
key = "#{fallback}||#{hash_value}"
|
|
589
|
+
key = "#{fallback}||#{hash_string(hash_value)}"
|
|
540
590
|
if @sticky_bucket_assignment_docs.key?(key)
|
|
541
591
|
@sticky_bucket_assignment_docs[key]['assignments'].each do |k, v|
|
|
542
592
|
merged[k] = v unless merged.key?(k)
|
|
@@ -15,10 +15,14 @@ module Growthbook
|
|
|
15
15
|
# Parsed features JSON
|
|
16
16
|
attr_reader :features_json
|
|
17
17
|
|
|
18
|
+
# Parsed saved groups JSON (used by the $inGroup/$notInGroup operators)
|
|
19
|
+
attr_reader :saved_groups_json
|
|
20
|
+
|
|
18
21
|
def initialize(endpoint:, decryption_key:)
|
|
19
22
|
@endpoint = endpoint
|
|
20
23
|
@decryption_key = decryption_key
|
|
21
24
|
@features_json = {}
|
|
25
|
+
@saved_groups_json = {}
|
|
22
26
|
end
|
|
23
27
|
|
|
24
28
|
def fetch
|
|
@@ -56,7 +60,11 @@ module Growthbook
|
|
|
56
60
|
end
|
|
57
61
|
|
|
58
62
|
def parsed_plain_text_response
|
|
59
|
-
|
|
63
|
+
parsed = parsed_response
|
|
64
|
+
return nil if parsed.nil?
|
|
65
|
+
|
|
66
|
+
@saved_groups_json = parsed['savedGroups'] || {}
|
|
67
|
+
@features_json = parsed['features']
|
|
60
68
|
rescue StandardError
|
|
61
69
|
nil
|
|
62
70
|
end
|
|
@@ -65,12 +73,26 @@ module Growthbook
|
|
|
65
73
|
k = decryption_key
|
|
66
74
|
return nil if k.nil?
|
|
67
75
|
|
|
68
|
-
|
|
76
|
+
parsed = parsed_response
|
|
77
|
+
return nil if parsed.nil?
|
|
78
|
+
|
|
79
|
+
@saved_groups_json = decrypted_saved_groups(parsed, k)
|
|
80
|
+
|
|
81
|
+
decrypted_str = Growthbook::DecryptionUtil.decrypt(parsed['encryptedFeatures'], key: k)
|
|
69
82
|
@features_json = JSON.parse(decrypted_str) unless decrypted_str.nil?
|
|
70
83
|
rescue StandardError
|
|
71
84
|
nil
|
|
72
85
|
end
|
|
73
86
|
|
|
87
|
+
def decrypted_saved_groups(parsed, key)
|
|
88
|
+
if parsed['encryptedSavedGroups']
|
|
89
|
+
decrypted = Growthbook::DecryptionUtil.decrypt(parsed['encryptedSavedGroups'], key: key)
|
|
90
|
+
return JSON.parse(decrypted) unless decrypted.nil?
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
parsed['savedGroups'] || {}
|
|
94
|
+
end
|
|
95
|
+
|
|
74
96
|
def parsed_response
|
|
75
97
|
res = response
|
|
76
98
|
return {} if res.nil?
|
|
@@ -27,14 +27,20 @@ module Growthbook
|
|
|
27
27
|
# @return [Growthbook::InlineExperimentResult, nil]
|
|
28
28
|
attr_reader :experiment_result
|
|
29
29
|
|
|
30
|
+
# The id of the rule (if any) that was used to assign the value
|
|
31
|
+
# @return [String]
|
|
32
|
+
attr_reader :rule_id
|
|
33
|
+
|
|
30
34
|
def initialize(
|
|
31
35
|
value,
|
|
32
36
|
source,
|
|
33
37
|
experiment,
|
|
34
|
-
experiment_result
|
|
38
|
+
experiment_result,
|
|
39
|
+
rule_id = ''
|
|
35
40
|
)
|
|
36
41
|
|
|
37
|
-
|
|
42
|
+
# JS truthiness: [], {} and "off" are on
|
|
43
|
+
on = !value.nil? && value != 0 && value != '' && value != false && !(value.is_a?(Float) && value.nan?)
|
|
38
44
|
|
|
39
45
|
@value = value
|
|
40
46
|
@on = on
|
|
@@ -42,6 +48,7 @@ module Growthbook
|
|
|
42
48
|
@source = source
|
|
43
49
|
@experiment = experiment
|
|
44
50
|
@experiment_result = experiment_result
|
|
51
|
+
@rule_id = rule_id || ''
|
|
45
52
|
end
|
|
46
53
|
|
|
47
54
|
def to_json(*_args)
|
|
@@ -50,6 +57,7 @@ module Growthbook
|
|
|
50
57
|
json['off'] = @off
|
|
51
58
|
json['value'] = @value
|
|
52
59
|
json['source'] = @source
|
|
60
|
+
json['ruleId'] = @rule_id
|
|
53
61
|
|
|
54
62
|
if @experiment
|
|
55
63
|
json['experiment'] = @experiment.to_json
|
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
module Growthbook
|
|
4
4
|
# Internal class that overrides the default value of a Feature based on a set of requirements.
|
|
5
5
|
class FeatureRule
|
|
6
|
+
# @return [String , nil] Unique identifier for this rule
|
|
7
|
+
attr_reader :id
|
|
8
|
+
|
|
6
9
|
# @return [Hash , nil] Optional targeting condition
|
|
7
10
|
attr_reader :condition
|
|
8
11
|
|
|
@@ -69,7 +72,11 @@ module Growthbook
|
|
|
69
72
|
# @return [Array<Hash>] Array of prerequisite flags
|
|
70
73
|
attr_accessor :parent_conditions
|
|
71
74
|
|
|
75
|
+
# @return [Hash, nil] Arbitrary custom fields attached to the rule
|
|
76
|
+
attr_reader :custom_fields
|
|
77
|
+
|
|
72
78
|
def initialize(rule)
|
|
79
|
+
@id = get_option(rule, :id)
|
|
73
80
|
@coverage = get_option(rule, :coverage)
|
|
74
81
|
@force = get_option(rule, :force)
|
|
75
82
|
@variations = get_option(rule, :variations)
|
|
@@ -95,6 +102,7 @@ module Growthbook
|
|
|
95
102
|
@bucket_version = get_option(rule, :bucket_version) || get_option(rule, :bucketVersion) || 0
|
|
96
103
|
@min_bucket_version = get_option(rule, :min_bucket_version) || get_option(rule, :minBucketVersion) || 0
|
|
97
104
|
@parent_conditions = get_option(rule, :parent_conditions) || get_option(rule, :parentConditions) || []
|
|
105
|
+
@custom_fields = get_option(rule, :custom_fields) || get_option(rule, :customFields)
|
|
98
106
|
|
|
99
107
|
return unless @disable_sticky_bucketing
|
|
100
108
|
|
|
@@ -123,7 +131,8 @@ module Growthbook
|
|
|
123
131
|
fallback_attribute: @fallback_attribute,
|
|
124
132
|
disable_sticky_bucketing: @disable_sticky_bucketing,
|
|
125
133
|
bucket_version: @bucket_version,
|
|
126
|
-
min_bucket_version: @min_bucket_version
|
|
134
|
+
min_bucket_version: @min_bucket_version,
|
|
135
|
+
custom_fields: @custom_fields
|
|
127
136
|
)
|
|
128
137
|
end
|
|
129
138
|
|
data/lib/growthbook/fnv.rb
CHANGED
|
@@ -10,11 +10,14 @@ class FNV
|
|
|
10
10
|
MOD32 = 4_294_967_296
|
|
11
11
|
MOD64 = 18_446_744_073_709_551_616
|
|
12
12
|
|
|
13
|
+
# Hashes UTF-16 code units, like JavaScript's charCodeAt, so a character
|
|
14
|
+
# above U+FFFF counts as two units
|
|
13
15
|
def fnv1a_32(data)
|
|
14
16
|
hash = INIT32
|
|
15
17
|
|
|
16
|
-
data.bytes
|
|
17
|
-
|
|
18
|
+
units = data.ascii_only? ? data.bytes : data.encode('UTF-16LE').unpack('v*').map(&:to_i)
|
|
19
|
+
units.each do |unit|
|
|
20
|
+
hash = hash ^ unit
|
|
18
21
|
hash = (hash * PRIME32) % MOD32
|
|
19
22
|
end
|
|
20
23
|
|
|
@@ -66,12 +66,17 @@ module Growthbook
|
|
|
66
66
|
# @return [Array<Hash>] Array of prerequisite flags
|
|
67
67
|
attr_accessor :parent_conditions
|
|
68
68
|
|
|
69
|
+
# @return [Hash, nil] Arbitrary custom fields attached to the experiment
|
|
70
|
+
attr_accessor :custom_fields
|
|
71
|
+
|
|
69
72
|
def initialize(options = {})
|
|
70
73
|
@key = get_option(options, :key, '').to_s
|
|
71
74
|
@variations = get_option(options, :variations, [])
|
|
72
75
|
@weights = get_option(options, :weights)
|
|
73
76
|
@active = get_option(options, :active, true)
|
|
74
77
|
@coverage = get_option(options, :coverage, 1.0)
|
|
78
|
+
# Serialize coverage whenever it was given, even when it's 1 (like the JS SDK)
|
|
79
|
+
@coverage_given = !get_option(options, :coverage).nil?
|
|
75
80
|
@ranges = get_option(options, :ranges)
|
|
76
81
|
@condition = get_option(options, :condition)
|
|
77
82
|
@namespace = get_option(options, :namespace)
|
|
@@ -88,6 +93,7 @@ module Growthbook
|
|
|
88
93
|
@bucket_version = get_option(options, :bucket_version) || get_option(options, :bucketVersion) || 0
|
|
89
94
|
@min_bucket_version = get_option(options, :min_bucket_version) || get_option(options, :minBucketVersion) || 0
|
|
90
95
|
@parent_conditions = get_option(options, :parent_conditions) || get_option(options, :parentConditions) || []
|
|
96
|
+
@custom_fields = get_option(options, :custom_fields) || get_option(options, :customFields)
|
|
91
97
|
|
|
92
98
|
return unless @disable_sticky_bucketing
|
|
93
99
|
|
|
@@ -100,7 +106,7 @@ module Growthbook
|
|
|
100
106
|
res['variations'] = @variations
|
|
101
107
|
res['weights'] = @weights unless @weights.nil?
|
|
102
108
|
res['active'] = @active if @active != true && !@active.nil?
|
|
103
|
-
res['coverage'] = @coverage if @coverage != 1 && !@coverage.nil?
|
|
109
|
+
res['coverage'] = @coverage if @coverage_given || (@coverage != 1 && !@coverage.nil?)
|
|
104
110
|
res['ranges'] = @ranges
|
|
105
111
|
res['condition'] = @condition
|
|
106
112
|
res['namespace'] = @namespace
|