launchdarkly-server-sdk 6.2.5 → 7.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +1 -2
- data/lib/ldclient-rb/config.rb +203 -43
- data/lib/ldclient-rb/context.rb +487 -0
- data/lib/ldclient-rb/evaluation_detail.rb +85 -26
- data/lib/ldclient-rb/events.rb +185 -146
- data/lib/ldclient-rb/flags_state.rb +25 -14
- data/lib/ldclient-rb/impl/big_segments.rb +117 -0
- data/lib/ldclient-rb/impl/context.rb +96 -0
- data/lib/ldclient-rb/impl/context_filter.rb +145 -0
- data/lib/ldclient-rb/impl/diagnostic_events.rb +9 -10
- data/lib/ldclient-rb/impl/evaluator.rb +428 -132
- data/lib/ldclient-rb/impl/evaluator_bucketing.rb +40 -41
- data/lib/ldclient-rb/impl/evaluator_helpers.rb +50 -0
- data/lib/ldclient-rb/impl/evaluator_operators.rb +26 -55
- data/lib/ldclient-rb/impl/event_sender.rb +6 -6
- data/lib/ldclient-rb/impl/event_summarizer.rb +68 -0
- data/lib/ldclient-rb/impl/event_types.rb +78 -0
- data/lib/ldclient-rb/impl/integrations/consul_impl.rb +7 -7
- data/lib/ldclient-rb/impl/integrations/dynamodb_impl.rb +92 -28
- data/lib/ldclient-rb/impl/integrations/file_data_source.rb +212 -0
- data/lib/ldclient-rb/impl/integrations/redis_impl.rb +165 -32
- data/lib/ldclient-rb/impl/integrations/test_data/test_data_source.rb +40 -0
- data/lib/ldclient-rb/impl/model/clause.rb +39 -0
- data/lib/ldclient-rb/impl/model/feature_flag.rb +213 -0
- data/lib/ldclient-rb/impl/model/preprocessed_data.rb +64 -0
- data/lib/ldclient-rb/impl/model/segment.rb +126 -0
- data/lib/ldclient-rb/impl/model/serialization.rb +54 -44
- data/lib/ldclient-rb/impl/repeating_task.rb +47 -0
- data/lib/ldclient-rb/impl/store_data_set_sorter.rb +2 -2
- data/lib/ldclient-rb/impl/unbounded_pool.rb +1 -1
- data/lib/ldclient-rb/impl/util.rb +62 -1
- data/lib/ldclient-rb/in_memory_store.rb +2 -2
- data/lib/ldclient-rb/integrations/consul.rb +9 -2
- data/lib/ldclient-rb/integrations/dynamodb.rb +47 -2
- data/lib/ldclient-rb/integrations/file_data.rb +108 -0
- data/lib/ldclient-rb/integrations/redis.rb +43 -3
- data/lib/ldclient-rb/integrations/test_data/flag_builder.rb +594 -0
- data/lib/ldclient-rb/integrations/test_data.rb +213 -0
- data/lib/ldclient-rb/integrations/util/store_wrapper.rb +14 -9
- data/lib/ldclient-rb/integrations.rb +2 -51
- data/lib/ldclient-rb/interfaces.rb +151 -1
- data/lib/ldclient-rb/ldclient.rb +175 -133
- data/lib/ldclient-rb/memoized_value.rb +1 -1
- data/lib/ldclient-rb/non_blocking_thread_pool.rb +1 -1
- data/lib/ldclient-rb/polling.rb +22 -41
- data/lib/ldclient-rb/reference.rb +274 -0
- data/lib/ldclient-rb/requestor.rb +7 -7
- data/lib/ldclient-rb/stream.rb +9 -9
- data/lib/ldclient-rb/util.rb +11 -17
- data/lib/ldclient-rb/version.rb +1 -1
- data/lib/ldclient-rb.rb +2 -4
- metadata +49 -23
- data/lib/ldclient-rb/event_summarizer.rb +0 -55
- data/lib/ldclient-rb/file_data_source.rb +0 -314
- data/lib/ldclient-rb/impl/event_factory.rb +0 -126
- data/lib/ldclient-rb/newrelic.rb +0 -17
- data/lib/ldclient-rb/redis_store.rb +0 -88
- data/lib/ldclient-rb/user_filter.rb +0 -52
@@ -0,0 +1,117 @@
|
|
1
|
+
require "ldclient-rb/config"
|
2
|
+
require "ldclient-rb/expiring_cache"
|
3
|
+
require "ldclient-rb/impl/repeating_task"
|
4
|
+
require "ldclient-rb/interfaces"
|
5
|
+
require "ldclient-rb/util"
|
6
|
+
|
7
|
+
require "digest"
|
8
|
+
|
9
|
+
module LaunchDarkly
|
10
|
+
module Impl
|
11
|
+
BigSegmentMembershipResult = Struct.new(:membership, :status)
|
12
|
+
|
13
|
+
class BigSegmentStoreManager
|
14
|
+
# use this as a singleton whenever a membership query returns nil; it's safe to reuse it because
|
15
|
+
# we will never modify the membership properties after they're queried
|
16
|
+
EMPTY_MEMBERSHIP = {}
|
17
|
+
|
18
|
+
def initialize(big_segments_config, logger)
|
19
|
+
@store = big_segments_config.store
|
20
|
+
@stale_after_millis = big_segments_config.stale_after * 1000
|
21
|
+
@status_provider = BigSegmentStoreStatusProviderImpl.new(-> { get_status })
|
22
|
+
@logger = logger
|
23
|
+
@last_status = nil
|
24
|
+
|
25
|
+
unless @store.nil?
|
26
|
+
@cache = ExpiringCache.new(big_segments_config.user_cache_size, big_segments_config.user_cache_time)
|
27
|
+
@poll_worker = RepeatingTask.new(big_segments_config.status_poll_interval, 0, -> { poll_store_and_update_status }, logger)
|
28
|
+
@poll_worker.start
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
attr_reader :status_provider
|
33
|
+
|
34
|
+
def stop
|
35
|
+
@poll_worker.stop unless @poll_worker.nil?
|
36
|
+
@store.stop unless @store.nil?
|
37
|
+
end
|
38
|
+
|
39
|
+
def get_context_membership(context_key)
|
40
|
+
return nil unless @store
|
41
|
+
membership = @cache[context_key]
|
42
|
+
unless membership
|
43
|
+
begin
|
44
|
+
membership = @store.get_membership(BigSegmentStoreManager.hash_for_context_key(context_key))
|
45
|
+
membership = EMPTY_MEMBERSHIP if membership.nil?
|
46
|
+
@cache[context_key] = membership
|
47
|
+
rescue => e
|
48
|
+
LaunchDarkly::Util.log_exception(@logger, "Big Segment store membership query returned error", e)
|
49
|
+
return BigSegmentMembershipResult.new(nil, BigSegmentsStatus::STORE_ERROR)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
poll_store_and_update_status unless @last_status
|
53
|
+
unless @last_status.available
|
54
|
+
return BigSegmentMembershipResult.new(membership, BigSegmentsStatus::STORE_ERROR)
|
55
|
+
end
|
56
|
+
BigSegmentMembershipResult.new(membership, @last_status.stale ? BigSegmentsStatus::STALE : BigSegmentsStatus::HEALTHY)
|
57
|
+
end
|
58
|
+
|
59
|
+
def get_status
|
60
|
+
@last_status || poll_store_and_update_status
|
61
|
+
end
|
62
|
+
|
63
|
+
def poll_store_and_update_status
|
64
|
+
new_status = Interfaces::BigSegmentStoreStatus.new(false, false) # default to "unavailable" if we don't get a new status below
|
65
|
+
unless @store.nil?
|
66
|
+
begin
|
67
|
+
metadata = @store.get_metadata
|
68
|
+
new_status = Interfaces::BigSegmentStoreStatus.new(true, !metadata || stale?(metadata.last_up_to_date))
|
69
|
+
rescue => e
|
70
|
+
LaunchDarkly::Util.log_exception(@logger, "Big Segment store status query returned error", e)
|
71
|
+
end
|
72
|
+
end
|
73
|
+
@last_status = new_status
|
74
|
+
@status_provider.update_status(new_status)
|
75
|
+
|
76
|
+
new_status
|
77
|
+
end
|
78
|
+
|
79
|
+
def stale?(timestamp)
|
80
|
+
!timestamp || ((Impl::Util.current_time_millis - timestamp) >= @stale_after_millis)
|
81
|
+
end
|
82
|
+
|
83
|
+
def self.hash_for_context_key(context_key)
|
84
|
+
Digest::SHA256.base64digest(context_key)
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
#
|
89
|
+
# Default implementation of the BigSegmentStoreStatusProvider interface.
|
90
|
+
#
|
91
|
+
# There isn't much to this because the real implementation is in BigSegmentStoreManager - we pass in a lambda
|
92
|
+
# that allows us to get the current status from that class. Also, the standard Observer methods such as
|
93
|
+
# add_observer are provided for us because BigSegmentStoreStatusProvider mixes in Observer, so all we need to
|
94
|
+
# to do make notifications happen is to call the Observer methods "changed" and "notify_observers".
|
95
|
+
#
|
96
|
+
class BigSegmentStoreStatusProviderImpl
|
97
|
+
include LaunchDarkly::Interfaces::BigSegmentStoreStatusProvider
|
98
|
+
|
99
|
+
def initialize(status_fn)
|
100
|
+
@status_fn = status_fn
|
101
|
+
@last_status = nil
|
102
|
+
end
|
103
|
+
|
104
|
+
def status
|
105
|
+
@status_fn.call
|
106
|
+
end
|
107
|
+
|
108
|
+
def update_status(new_status)
|
109
|
+
if !@last_status || new_status != @last_status
|
110
|
+
@last_status = new_status
|
111
|
+
changed
|
112
|
+
notify_observers(new_status)
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|
@@ -0,0 +1,96 @@
|
|
1
|
+
require "erb"
|
2
|
+
|
3
|
+
module LaunchDarkly
|
4
|
+
module Impl
|
5
|
+
module Context
|
6
|
+
ERR_KIND_NON_STRING = 'context kind must be a string'
|
7
|
+
ERR_KIND_CANNOT_BE_KIND = '"kind" is not a valid context kind'
|
8
|
+
ERR_KIND_CANNOT_BE_MULTI = '"multi" is not a valid context kind'
|
9
|
+
ERR_KIND_INVALID_CHARS = 'context kind contains disallowed characters'
|
10
|
+
|
11
|
+
ERR_KEY_NON_STRING = 'context key must be a string'
|
12
|
+
ERR_KEY_EMPTY = 'context key must not be empty'
|
13
|
+
|
14
|
+
ERR_NAME_NON_STRING = 'context name must be a string'
|
15
|
+
|
16
|
+
ERR_ANONYMOUS_NON_BOOLEAN = 'context anonymous must be a boolean'
|
17
|
+
|
18
|
+
#
|
19
|
+
# We allow consumers of this SDK to provide us with either a Hash or an
|
20
|
+
# instance of an LDContext. This is convenient for them but not as much
|
21
|
+
# for us. To make the conversion slightly more convenient for us, we have
|
22
|
+
# created this method.
|
23
|
+
#
|
24
|
+
# @param context [Hash, LDContext]
|
25
|
+
# @return [LDContext]
|
26
|
+
#
|
27
|
+
def self.make_context(context)
|
28
|
+
return context if context.is_a?(LDContext)
|
29
|
+
|
30
|
+
LDContext.create(context)
|
31
|
+
end
|
32
|
+
|
33
|
+
#
|
34
|
+
# Returns an error message if the kind is invalid; nil otherwise.
|
35
|
+
#
|
36
|
+
# @param kind [any]
|
37
|
+
# @return [String, nil]
|
38
|
+
#
|
39
|
+
def self.validate_kind(kind)
|
40
|
+
return ERR_KIND_NON_STRING unless kind.is_a?(String)
|
41
|
+
return ERR_KIND_CANNOT_BE_KIND if kind == "kind"
|
42
|
+
return ERR_KIND_CANNOT_BE_MULTI if kind == "multi"
|
43
|
+
return ERR_KIND_INVALID_CHARS unless kind.match?(/^[\w.-]+$/)
|
44
|
+
end
|
45
|
+
|
46
|
+
#
|
47
|
+
# Returns an error message if the key is invalid; nil otherwise.
|
48
|
+
#
|
49
|
+
# @param key [any]
|
50
|
+
# @return [String, nil]
|
51
|
+
#
|
52
|
+
def self.validate_key(key)
|
53
|
+
return ERR_KEY_NON_STRING unless key.is_a?(String)
|
54
|
+
return ERR_KEY_EMPTY if key == ""
|
55
|
+
end
|
56
|
+
|
57
|
+
#
|
58
|
+
# Returns an error message if the name is invalid; nil otherwise.
|
59
|
+
#
|
60
|
+
# @param name [any]
|
61
|
+
# @return [String, nil]
|
62
|
+
#
|
63
|
+
def self.validate_name(name)
|
64
|
+
return ERR_NAME_NON_STRING unless name.nil? || name.is_a?(String)
|
65
|
+
end
|
66
|
+
|
67
|
+
#
|
68
|
+
# Returns an error message if anonymous is invalid; nil otherwise.
|
69
|
+
#
|
70
|
+
# @param anonymous [any]
|
71
|
+
# @param allow_nil [Boolean]
|
72
|
+
# @return [String, nil]
|
73
|
+
#
|
74
|
+
def self.validate_anonymous(anonymous, allow_nil)
|
75
|
+
return nil if anonymous.nil? && allow_nil
|
76
|
+
return nil if [true, false].include? anonymous
|
77
|
+
|
78
|
+
ERR_ANONYMOUS_NON_BOOLEAN
|
79
|
+
end
|
80
|
+
|
81
|
+
#
|
82
|
+
# @param kind [String]
|
83
|
+
# @param key [String]
|
84
|
+
# @return [String]
|
85
|
+
#
|
86
|
+
def self.canonicalize_key_for_kind(kind, key)
|
87
|
+
# When building a FullyQualifiedKey, ':' and '%' are percent-escaped;
|
88
|
+
# we do not use a full URL-encoding function because implementations of
|
89
|
+
# this are inconsistent across platforms.
|
90
|
+
encoded = key.gsub("%", "%25").gsub(":", "%3A")
|
91
|
+
|
92
|
+
"#{kind}:#{encoded}"
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
@@ -0,0 +1,145 @@
|
|
1
|
+
module LaunchDarkly
|
2
|
+
module Impl
|
3
|
+
class ContextFilter
|
4
|
+
#
|
5
|
+
# @param all_attributes_private [Boolean]
|
6
|
+
# @param private_attributes [Array<String>]
|
7
|
+
#
|
8
|
+
def initialize(all_attributes_private, private_attributes)
|
9
|
+
@all_attributes_private = all_attributes_private
|
10
|
+
|
11
|
+
@private_attributes = []
|
12
|
+
private_attributes.each do |attribute|
|
13
|
+
reference = LaunchDarkly::Reference.create(attribute)
|
14
|
+
@private_attributes << reference if reference.error.nil?
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
#
|
19
|
+
# Return a hash representation of the provided context with attribute
|
20
|
+
# redaction applied.
|
21
|
+
#
|
22
|
+
# @param context [LaunchDarkly::LDContext]
|
23
|
+
# @return [Hash]
|
24
|
+
#
|
25
|
+
def filter(context)
|
26
|
+
return filter_single_context(context, true) unless context.multi_kind?
|
27
|
+
|
28
|
+
filtered = {kind: 'multi'}
|
29
|
+
(0...context.individual_context_count).each do |i|
|
30
|
+
c = context.individual_context(i)
|
31
|
+
next if c.nil?
|
32
|
+
|
33
|
+
filtered[c.kind] = filter_single_context(c, false)
|
34
|
+
end
|
35
|
+
|
36
|
+
filtered
|
37
|
+
end
|
38
|
+
|
39
|
+
#
|
40
|
+
# Apply redaction rules for a single context.
|
41
|
+
#
|
42
|
+
# @param context [LaunchDarkly::LDContext]
|
43
|
+
# @param include_kind [Boolean]
|
44
|
+
# @return [Hash]
|
45
|
+
#
|
46
|
+
private def filter_single_context(context, include_kind)
|
47
|
+
filtered = {key: context.key}
|
48
|
+
|
49
|
+
filtered[:kind] = context.kind if include_kind
|
50
|
+
filtered[:anonymous] = true if context.get_value(:anonymous)
|
51
|
+
|
52
|
+
redacted = []
|
53
|
+
private_attributes = @private_attributes.concat(context.private_attributes)
|
54
|
+
|
55
|
+
name = context.get_value(:name)
|
56
|
+
if !name.nil? && !check_whole_attribute_private(:name, private_attributes, redacted)
|
57
|
+
filtered[:name] = name
|
58
|
+
end
|
59
|
+
|
60
|
+
context.get_custom_attribute_names.each do |attribute|
|
61
|
+
unless check_whole_attribute_private(attribute, private_attributes, redacted)
|
62
|
+
value = context.get_value(attribute)
|
63
|
+
filtered[attribute] = redact_json_value(nil, attribute, value, private_attributes, redacted)
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
filtered[:_meta] = {redactedAttributes: redacted} unless redacted.empty?
|
68
|
+
|
69
|
+
filtered
|
70
|
+
end
|
71
|
+
|
72
|
+
#
|
73
|
+
# Check if an entire attribute should be redacted.
|
74
|
+
#
|
75
|
+
# @param attribute [Symbol]
|
76
|
+
# @param private_attributes [Array<Reference>]
|
77
|
+
# @param redacted [Array<Symbol>]
|
78
|
+
# @return [Boolean]
|
79
|
+
#
|
80
|
+
private def check_whole_attribute_private(attribute, private_attributes, redacted)
|
81
|
+
if @all_attributes_private
|
82
|
+
redacted << attribute
|
83
|
+
return true
|
84
|
+
end
|
85
|
+
|
86
|
+
private_attributes.each do |private_attribute|
|
87
|
+
if private_attribute.component(0) == attribute && private_attribute.depth == 1
|
88
|
+
redacted << attribute
|
89
|
+
return true
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
false
|
94
|
+
end
|
95
|
+
|
96
|
+
#
|
97
|
+
# Apply redaction rules to the provided value.
|
98
|
+
#
|
99
|
+
# @param parent_path [Array<String>, nil]
|
100
|
+
# @param name [String]
|
101
|
+
# @param value [any]
|
102
|
+
# @param private_attributes [Array<Reference>]
|
103
|
+
# @param redacted [Array<Symbol>]
|
104
|
+
# @return [any]
|
105
|
+
#
|
106
|
+
private def redact_json_value(parent_path, name, value, private_attributes, redacted)
|
107
|
+
return value unless value.is_a?(Hash)
|
108
|
+
|
109
|
+
ret = {}
|
110
|
+
current_path = parent_path.clone || []
|
111
|
+
current_path << name
|
112
|
+
|
113
|
+
value.each do |k, v|
|
114
|
+
was_redacted = false
|
115
|
+
private_attributes.each do |private_attribute|
|
116
|
+
next unless private_attribute.depth == (current_path.count + 1)
|
117
|
+
|
118
|
+
component = private_attribute.component(current_path.count)
|
119
|
+
next unless component == k
|
120
|
+
|
121
|
+
match = true
|
122
|
+
(0...current_path.count).each do |i|
|
123
|
+
unless private_attribute.component(i) == current_path[i]
|
124
|
+
match = false
|
125
|
+
break
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
if match
|
130
|
+
redacted << private_attribute.raw_path.to_sym
|
131
|
+
was_redacted = true
|
132
|
+
break
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
unless was_redacted
|
137
|
+
ret[k] = redact_json_value(current_path, k, v, private_attributes, redacted)
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
ret
|
142
|
+
end
|
143
|
+
end
|
144
|
+
end
|
145
|
+
end
|
@@ -9,7 +9,7 @@ module LaunchDarkly
|
|
9
9
|
def self.create_diagnostic_id(sdk_key)
|
10
10
|
{
|
11
11
|
diagnosticId: SecureRandom.uuid,
|
12
|
-
sdkKeySuffix: sdk_key[-6..-1] || sdk_key
|
12
|
+
sdkKeySuffix: sdk_key[-6..-1] || sdk_key,
|
13
13
|
}
|
14
14
|
end
|
15
15
|
|
@@ -25,16 +25,16 @@ module LaunchDarkly
|
|
25
25
|
end
|
26
26
|
|
27
27
|
def create_init_event(config)
|
28
|
-
|
28
|
+
{
|
29
29
|
kind: 'diagnostic-init',
|
30
30
|
creationDate: Util.current_time_millis,
|
31
31
|
id: @id,
|
32
32
|
configuration: DiagnosticAccumulator.make_config_data(config),
|
33
33
|
sdk: DiagnosticAccumulator.make_sdk_data(config),
|
34
|
-
platform: DiagnosticAccumulator.make_platform_data
|
34
|
+
platform: DiagnosticAccumulator.make_platform_data,
|
35
35
|
}
|
36
36
|
end
|
37
|
-
|
37
|
+
|
38
38
|
def record_stream_init(timestamp, failed, duration_millis)
|
39
39
|
@lock.synchronize do
|
40
40
|
@stream_inits.push({ timestamp: timestamp, failed: failed, durationMillis: duration_millis })
|
@@ -57,7 +57,7 @@ module LaunchDarkly
|
|
57
57
|
droppedEvents: dropped_events,
|
58
58
|
deduplicatedUsers: deduplicated_users,
|
59
59
|
eventsInLastBatch: events_in_last_batch,
|
60
|
-
streamInits: previous_stream_inits
|
60
|
+
streamInits: previous_stream_inits,
|
61
61
|
}
|
62
62
|
@data_since_date = current_time
|
63
63
|
event
|
@@ -73,12 +73,11 @@ module LaunchDarkly
|
|
73
73
|
diagnosticRecordingIntervalMillis: self.seconds_to_millis(config.diagnostic_recording_interval),
|
74
74
|
eventsCapacity: config.capacity,
|
75
75
|
eventsFlushIntervalMillis: self.seconds_to_millis(config.flush_interval),
|
76
|
-
inlineUsersInEvents: config.inline_users_in_events,
|
77
76
|
pollingIntervalMillis: self.seconds_to_millis(config.poll_interval),
|
78
77
|
socketTimeoutMillis: self.seconds_to_millis(config.read_timeout),
|
79
78
|
streamingDisabled: !config.stream?,
|
80
|
-
userKeysCapacity: config.
|
81
|
-
userKeysFlushIntervalMillis: self.seconds_to_millis(config.
|
79
|
+
userKeysCapacity: config.context_keys_capacity,
|
80
|
+
userKeysFlushIntervalMillis: self.seconds_to_millis(config.context_keys_flush_interval),
|
82
81
|
usingProxy: ENV.has_key?('http_proxy') || ENV.has_key?('https_proxy') || ENV.has_key?('HTTP_PROXY') || ENV.has_key?('HTTPS_PROXY'),
|
83
82
|
usingRelayDaemon: config.use_ldd?,
|
84
83
|
}
|
@@ -88,7 +87,7 @@ module LaunchDarkly
|
|
88
87
|
def self.make_sdk_data(config)
|
89
88
|
ret = {
|
90
89
|
name: 'ruby-server-sdk',
|
91
|
-
version: LaunchDarkly::VERSION
|
90
|
+
version: LaunchDarkly::VERSION,
|
92
91
|
}
|
93
92
|
if config.wrapper_name
|
94
93
|
ret[:wrapperName] = config.wrapper_name
|
@@ -105,7 +104,7 @@ module LaunchDarkly
|
|
105
104
|
osName: self.normalize_os_name(conf['host_os']),
|
106
105
|
osVersion: 'unknown', # there seems to be no portable way to detect this in Ruby
|
107
106
|
rubyVersion: conf['ruby_version'],
|
108
|
-
rubyImplementation: Object.constants.include?(:RUBY_ENGINE) ? RUBY_ENGINE : 'unknown'
|
107
|
+
rubyImplementation: Object.constants.include?(:RUBY_ENGINE) ? RUBY_ENGINE : 'unknown',
|
109
108
|
}
|
110
109
|
end
|
111
110
|
|