hackle-ruby-sdk 0.0.1 → 1.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 -3
- data/hackle-ruby-sdk.gemspec +1 -1
- data/lib/hackle-ruby-sdk.rb +2 -21
- data/lib/hackle.rb +76 -0
- data/lib/hackle/client.rb +127 -0
- data/lib/{hackle-ruby-sdk → hackle}/config.rb +1 -1
- data/lib/hackle/decision/bucketer.rb +44 -0
- data/lib/hackle/decision/decider.rb +69 -0
- data/lib/{hackle-ruby-sdk → hackle}/events/event_dispatcher.rb +31 -24
- data/lib/{hackle-ruby-sdk → hackle}/events/event_processor.rb +22 -11
- data/lib/hackle/events/user_event.rb +61 -0
- data/lib/{hackle-ruby-sdk → hackle}/http/http.rb +7 -9
- data/lib/hackle/models/bucket.rb +26 -0
- data/lib/hackle/models/event.rb +26 -0
- data/lib/hackle/models/event_type.rb +22 -0
- data/lib/hackle/models/experiment.rb +69 -0
- data/lib/hackle/models/slot.rb +22 -0
- data/lib/hackle/models/user.rb +24 -0
- data/lib/hackle/models/variation.rb +21 -0
- data/lib/{hackle-ruby-sdk → hackle}/version.rb +2 -2
- data/lib/hackle/workspaces/http_workspace_fetcher.rb +24 -0
- data/lib/{hackle-ruby-sdk → hackle}/workspaces/polling_workspace_fetcher.rb +4 -1
- data/lib/hackle/workspaces/workspace.rb +100 -0
- metadata +22 -19
- data/lib/hackle-ruby-sdk/client.rb +0 -108
- data/lib/hackle-ruby-sdk/decision/bucketer.rb +0 -26
- data/lib/hackle-ruby-sdk/decision/decider.rb +0 -54
- data/lib/hackle-ruby-sdk/events/event.rb +0 -33
- data/lib/hackle-ruby-sdk/models/bucket.rb +0 -15
- data/lib/hackle-ruby-sdk/models/event_type.rb +0 -10
- data/lib/hackle-ruby-sdk/models/experiment.rb +0 -39
- data/lib/hackle-ruby-sdk/models/slot.rb +0 -15
- data/lib/hackle-ruby-sdk/models/variation.rb +0 -11
- data/lib/hackle-ruby-sdk/workspaces/http_workspace_fetcher.rb +0 -24
- data/lib/hackle-ruby-sdk/workspaces/workspace.rb +0 -78
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 2eb9518d11c4980c9a6abf036b8400fad5de381db6c60d2fc98b68e37cda8851
|
4
|
+
data.tar.gz: faf6536122c01e9a55f95eef4e6497313a7e09e2ff16ea1123ca6c7fdc56470a
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: cbf193163d0b5926f052e76b7b01e0ca472297ef5da32b2317e90a1f35450cefa6496a72bcd0e9d6c7faf83947b6a9379e07be7111f5fa0a5030b891013c6c1c
|
7
|
+
data.tar.gz: e9d8ed3a4439684fb0b3308edb0a2e48c36ba041fa8f081e33ddad237ee8a5b546f0c98a1ec05b5c4123f76fe9289bc2ea3e84d9b02b068a3833472eec77691e
|
data/README.md
CHANGED
@@ -1,8 +1,6 @@
|
|
1
1
|
# Hackle::Ruby::Sdk
|
2
2
|
|
3
|
-
Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/hackle
|
4
|
-
|
5
|
-
TODO: Delete this and the text above, and describe your gem
|
3
|
+
Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/hackle`.
|
6
4
|
|
7
5
|
## Installation
|
8
6
|
|
data/hackle-ruby-sdk.gemspec
CHANGED
data/lib/hackle-ruby-sdk.rb
CHANGED
@@ -1,22 +1,3 @@
|
|
1
|
-
|
2
|
-
require 'hackle-ruby-sdk/decision/decider'
|
1
|
+
# frozen_string_literal: true
|
3
2
|
|
4
|
-
require 'hackle
|
5
|
-
require 'hackle-ruby-sdk/events/event_dispatcher'
|
6
|
-
require 'hackle-ruby-sdk/events/event_processor'
|
7
|
-
|
8
|
-
require 'hackle-ruby-sdk/http/http'
|
9
|
-
|
10
|
-
require 'hackle-ruby-sdk/models/bucket'
|
11
|
-
require 'hackle-ruby-sdk/models/event_type'
|
12
|
-
require 'hackle-ruby-sdk/models/experiment'
|
13
|
-
require 'hackle-ruby-sdk/models/slot'
|
14
|
-
require 'hackle-ruby-sdk/models/variation'
|
15
|
-
|
16
|
-
require 'hackle-ruby-sdk/workspaces/http_workspace_fetcher'
|
17
|
-
require 'hackle-ruby-sdk/workspaces/polling_workspace_fetcher'
|
18
|
-
require 'hackle-ruby-sdk/workspaces/workspace'
|
19
|
-
|
20
|
-
require 'hackle-ruby-sdk/client'
|
21
|
-
require 'hackle-ruby-sdk/config'
|
22
|
-
require 'hackle-ruby-sdk/version'
|
3
|
+
require 'hackle'
|
data/lib/hackle.rb
ADDED
@@ -0,0 +1,76 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'hackle/client'
|
4
|
+
require 'hackle/config'
|
5
|
+
require 'hackle/version'
|
6
|
+
|
7
|
+
module Hackle
|
8
|
+
|
9
|
+
#
|
10
|
+
# Instantiates a Hackle client.
|
11
|
+
#
|
12
|
+
# @see Client#initialize
|
13
|
+
#
|
14
|
+
# @param sdk_key [String] The SDK key of your Hackle environment
|
15
|
+
# @param options Optional parameters of configuration options
|
16
|
+
#
|
17
|
+
# @return [Client] The Hackle client instance.
|
18
|
+
#
|
19
|
+
def self.client(sdk_key:, **options)
|
20
|
+
config = Config.new(options)
|
21
|
+
sdk_info = SdkInfo.new(key: sdk_key)
|
22
|
+
|
23
|
+
http_workspace_fetcher = HttpWorkspaceFetcher.new(config: config, sdk_info: sdk_info)
|
24
|
+
polling_workspace_fetcher = PollingWorkspaceFetcher.new(config: config, http_fetcher: http_workspace_fetcher)
|
25
|
+
|
26
|
+
event_dispatcher = EventDispatcher.new(config: config, sdk_info: sdk_info)
|
27
|
+
event_processor = EventProcessor.new(config: config, event_dispatcher: event_dispatcher)
|
28
|
+
|
29
|
+
polling_workspace_fetcher.start!
|
30
|
+
event_processor.start!
|
31
|
+
|
32
|
+
Client.new(
|
33
|
+
config: config,
|
34
|
+
workspace_fetcher: polling_workspace_fetcher,
|
35
|
+
event_processor: event_processor,
|
36
|
+
decider: Decider.new
|
37
|
+
)
|
38
|
+
end
|
39
|
+
|
40
|
+
#
|
41
|
+
# Instantiate a user to be used for the hackle sdk.
|
42
|
+
#
|
43
|
+
# The only required parameter is `id`, which must uniquely identify each user.
|
44
|
+
#
|
45
|
+
# @example
|
46
|
+
# Hackle.user(id: 'ae2182e0')
|
47
|
+
# Hackle.user(id: 'ae2182e0', app_version: '1.0.1', paying_customer: false)
|
48
|
+
#
|
49
|
+
# @param id [String] The identifier of the user. (e.g. device_id, account_id etc.)
|
50
|
+
# @param properties Additional properties of the user. (e.g. app_version, membership_grade, etc.)
|
51
|
+
#
|
52
|
+
# @return [User] The configured user object.
|
53
|
+
#
|
54
|
+
def self.user(id:, **properties)
|
55
|
+
User.new(id: id, properties: properties)
|
56
|
+
end
|
57
|
+
|
58
|
+
#
|
59
|
+
# Instantiate an event to be used for the hackle sdk.
|
60
|
+
#
|
61
|
+
# The only required parameter is `key`, which must uniquely identify each event.
|
62
|
+
#
|
63
|
+
# @example
|
64
|
+
# Hackle.event(key: 'purchase')
|
65
|
+
# Hackle.event(key: 'purchase', value: 42000.0, app_version: '1.0.1', payment_method: 'CARD' )
|
66
|
+
#
|
67
|
+
# @param key [String] The unique key of the events.
|
68
|
+
# @param value [Float] Optional numeric value of the events (e.g. purchase_amount, quantity, etc.)
|
69
|
+
# @param properties Additional properties of the events (e.g. app_version, os_type, etc.)
|
70
|
+
#
|
71
|
+
# @return [Event] The configured event object.
|
72
|
+
#
|
73
|
+
def self.event(key:, value: nil, **properties)
|
74
|
+
Event.new(key: key, value: value, properties: properties)
|
75
|
+
end
|
76
|
+
end
|
@@ -0,0 +1,127 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'hackle/decision/bucketer'
|
4
|
+
require 'hackle/decision/decider'
|
5
|
+
|
6
|
+
require 'hackle/events/user_event'
|
7
|
+
require 'hackle/events/event_dispatcher'
|
8
|
+
require 'hackle/events/event_processor'
|
9
|
+
|
10
|
+
require 'hackle/http/http'
|
11
|
+
|
12
|
+
require 'hackle/models/bucket'
|
13
|
+
require 'hackle/models/event'
|
14
|
+
require 'hackle/models/event_type'
|
15
|
+
require 'hackle/models/experiment'
|
16
|
+
require 'hackle/models/slot'
|
17
|
+
require 'hackle/models/user'
|
18
|
+
require 'hackle/models/variation'
|
19
|
+
|
20
|
+
require 'hackle/workspaces/http_workspace_fetcher'
|
21
|
+
require 'hackle/workspaces/polling_workspace_fetcher'
|
22
|
+
require 'hackle/workspaces/workspace'
|
23
|
+
|
24
|
+
module Hackle
|
25
|
+
|
26
|
+
#
|
27
|
+
# A client for Hackle API.
|
28
|
+
#
|
29
|
+
class Client
|
30
|
+
|
31
|
+
#
|
32
|
+
# Initializes a Hackle client.
|
33
|
+
#
|
34
|
+
# @param config [Config]
|
35
|
+
# @param workspace_fetcher [PollingWorkspaceFetcher]
|
36
|
+
# @param event_processor [EventProcessor]
|
37
|
+
# @param decider [Decider]
|
38
|
+
#
|
39
|
+
def initialize(config:, workspace_fetcher:, event_processor:, decider:)
|
40
|
+
@logger = config.logger
|
41
|
+
|
42
|
+
# @type [PollingWorkspaceFetcher]
|
43
|
+
@workspace_fetcher = workspace_fetcher
|
44
|
+
|
45
|
+
# @type [EventProcessor]
|
46
|
+
@event_processor = event_processor
|
47
|
+
|
48
|
+
# @type [Decider]
|
49
|
+
@decider = decider
|
50
|
+
end
|
51
|
+
|
52
|
+
#
|
53
|
+
# Decide the variation to expose to the user for experiment.
|
54
|
+
#
|
55
|
+
# This method return the control variation 'A' if:
|
56
|
+
# - The experiment key is invalid
|
57
|
+
# - The experiment has not started yet
|
58
|
+
# - The user is not allocated to the experiment
|
59
|
+
# - The decided variation has been dropped
|
60
|
+
#
|
61
|
+
# @param experiment_key [Integer] The unique key of the experiment. MUST NOT be nil.
|
62
|
+
# @param user [User] the user to participate in the experiment. MUST NOT be nil.
|
63
|
+
# @param default_variation [String] The default variation of the experiment.
|
64
|
+
#
|
65
|
+
# @return [String] The decided variation for the user, or default variation
|
66
|
+
#
|
67
|
+
def variation(experiment_key:, user:, default_variation: 'A')
|
68
|
+
|
69
|
+
return default_variation if experiment_key.nil? || !experiment_key.is_a?(Integer)
|
70
|
+
return default_variation if user.nil? || !user.is_a?(User) || !user.valid?
|
71
|
+
|
72
|
+
workspace = @workspace_fetcher.fetch
|
73
|
+
return default_variation if workspace.nil?
|
74
|
+
|
75
|
+
experiment = workspace.get_experiment(experiment_key: experiment_key)
|
76
|
+
return default_variation if experiment.nil?
|
77
|
+
|
78
|
+
decision = @decider.decide(experiment: experiment, user: user)
|
79
|
+
case decision
|
80
|
+
when Decision::NotAllocated
|
81
|
+
default_variation
|
82
|
+
when Decision::ForcedAllocated
|
83
|
+
decision.variation_key
|
84
|
+
when Decision::NaturalAllocated
|
85
|
+
exposure_event = UserEvent::Exposure.new(user: user, experiment: experiment, variation: decision.variation)
|
86
|
+
@event_processor.process(event: exposure_event)
|
87
|
+
decision.variation.key
|
88
|
+
else
|
89
|
+
default_variation
|
90
|
+
end
|
91
|
+
|
92
|
+
rescue => e
|
93
|
+
@logger.error { "Unexpected error while deciding variation for experiment[#{experiment_key}]. Returning default variation[#{default_variation}]: #{e.inspect}" }
|
94
|
+
default_variation
|
95
|
+
end
|
96
|
+
|
97
|
+
#
|
98
|
+
# Records the event that occurred by the user.
|
99
|
+
#
|
100
|
+
# @param event [Event] the event that occurred.
|
101
|
+
# @param user [User] the user that occurred the event.
|
102
|
+
#
|
103
|
+
def track(event:, user:)
|
104
|
+
|
105
|
+
return if event.nil? || !event.is_a?(Event) || !event.valid?
|
106
|
+
return if user.nil? || !user.is_a?(User) || !user.valid?
|
107
|
+
|
108
|
+
workspace = @workspace_fetcher.fetch
|
109
|
+
return if workspace.nil?
|
110
|
+
|
111
|
+
event_type = workspace.get_event_type(event_type_key: event.key)
|
112
|
+
track_event = UserEvent::Track.new(user: user, event_type: event_type, event: event)
|
113
|
+
@event_processor.process(event: track_event)
|
114
|
+
|
115
|
+
rescue => e
|
116
|
+
@logger.error { "Unexpected error while tracking event: #{e.inspect}" }
|
117
|
+
end
|
118
|
+
|
119
|
+
#
|
120
|
+
# Shutdown the background task and release the resources used for the background task.
|
121
|
+
#
|
122
|
+
def close
|
123
|
+
@workspace_fetcher.stop!
|
124
|
+
@event_processor.stop!
|
125
|
+
end
|
126
|
+
end
|
127
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'murmurhash3'
|
4
|
+
|
5
|
+
module Hackle
|
6
|
+
class Bucketer
|
7
|
+
|
8
|
+
# @param bucket [Bucket]
|
9
|
+
# @param user [User]
|
10
|
+
#
|
11
|
+
# @return [Slot, nil]
|
12
|
+
def bucketing(bucket:, user:)
|
13
|
+
slot_number = calculate_slot_number(
|
14
|
+
seed: bucket.seed,
|
15
|
+
slot_size: bucket.slot_size,
|
16
|
+
user_id: user.id
|
17
|
+
)
|
18
|
+
bucket.get_slot(slot_number: slot_number)
|
19
|
+
end
|
20
|
+
|
21
|
+
# @param seed [Integer]
|
22
|
+
# @param slot_size [Integer]
|
23
|
+
# @param user_id [String]
|
24
|
+
#
|
25
|
+
# @return [Integer]
|
26
|
+
def calculate_slot_number(seed:, slot_size:, user_id:)
|
27
|
+
hash_value = hash(data: user_id, seed: seed)
|
28
|
+
hash_value.abs % slot_size
|
29
|
+
end
|
30
|
+
|
31
|
+
# @param data [String]
|
32
|
+
# @param seed [Integer]
|
33
|
+
#
|
34
|
+
# @return [Integer]
|
35
|
+
def hash(data:, seed:)
|
36
|
+
unsigned_value = MurmurHash3::V32.str_hash(data, seed)
|
37
|
+
if (unsigned_value & 0x80000000).zero?
|
38
|
+
unsigned_value
|
39
|
+
else
|
40
|
+
-((unsigned_value ^ 0xFFFFFFFF) + 1)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,69 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Hackle
|
4
|
+
class Decision
|
5
|
+
|
6
|
+
class NotAllocated < Decision
|
7
|
+
end
|
8
|
+
|
9
|
+
class ForcedAllocated < Decision
|
10
|
+
# @return [String]
|
11
|
+
attr_reader :variation_key
|
12
|
+
|
13
|
+
# @param variation_key [String]
|
14
|
+
def initialize(variation_key:)
|
15
|
+
@variation_key = variation_key
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
class NaturalAllocated < Decision
|
20
|
+
# @return [Variation]
|
21
|
+
attr_reader :variation
|
22
|
+
|
23
|
+
# @param variation [Variation]
|
24
|
+
def initialize(variation:)
|
25
|
+
@variation = variation
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
class Decider
|
31
|
+
def initialize
|
32
|
+
@bucketer = Bucketer.new
|
33
|
+
end
|
34
|
+
|
35
|
+
# @param experiment [Experiment]
|
36
|
+
# @param user [User]
|
37
|
+
#
|
38
|
+
# @return [Decision]
|
39
|
+
def decide(experiment:, user:)
|
40
|
+
case experiment
|
41
|
+
when Experiment::Completed
|
42
|
+
Decision::ForcedAllocated.new(variation_key: experiment.winner_variation_key)
|
43
|
+
when Experiment::Running
|
44
|
+
decide_running(running_experiment: experiment, user: user)
|
45
|
+
else
|
46
|
+
NotAllocated.new
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
# @param running_experiment [Experiment::Running]
|
51
|
+
# @param user [User]
|
52
|
+
#
|
53
|
+
# @return [Decision]
|
54
|
+
def decide_running(running_experiment:, user:)
|
55
|
+
|
56
|
+
overridden_variation = running_experiment.get_overridden_variation(user: user)
|
57
|
+
return Decision::ForcedAllocated.new(variation_key: overridden_variation.key) unless overridden_variation.nil?
|
58
|
+
|
59
|
+
allocated_slot = @bucketer.bucketing(bucket: running_experiment.bucket, user: user)
|
60
|
+
return Decision::NotAllocated.new if allocated_slot.nil?
|
61
|
+
|
62
|
+
allocated_variation = running_experiment.get_variation(variation_id: allocated_slot.variation_id)
|
63
|
+
return Decision::NotAllocated.new if allocated_variation.nil?
|
64
|
+
return Decision::NotAllocated.new if allocated_variation.dropped
|
65
|
+
|
66
|
+
Decision::NaturalAllocated.new(variation: allocated_variation)
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
@@ -6,10 +6,10 @@ module Hackle
|
|
6
6
|
DEFAULT_DISPATCH_WORKER_SIZE = 2
|
7
7
|
DEFAULT_DISPATCH_QUEUE_CAPACITY = 50
|
8
8
|
|
9
|
-
def initialize(config
|
9
|
+
def initialize(config:, sdk_info:)
|
10
10
|
@logger = config.logger
|
11
|
-
@client = HTTP.client(config.event_uri)
|
12
|
-
@headers = HTTP.sdk_headers(sdk_info)
|
11
|
+
@client = HTTP.client(base_uri: config.event_uri)
|
12
|
+
@headers = HTTP.sdk_headers(sdk_info: sdk_info)
|
13
13
|
@dispatcher_executor = Concurrent::ThreadPoolExecutor.new(
|
14
14
|
min_threads: DEFAULT_DISPATCH_WORKER_SIZE,
|
15
15
|
max_threads: DEFAULT_DISPATCH_WORKER_SIZE,
|
@@ -17,10 +17,10 @@ module Hackle
|
|
17
17
|
)
|
18
18
|
end
|
19
19
|
|
20
|
-
def dispatch(events)
|
21
|
-
payload = create_payload(events)
|
20
|
+
def dispatch(events:)
|
21
|
+
payload = create_payload(events: events)
|
22
22
|
begin
|
23
|
-
@dispatcher_executor.post { dispatch_payload(payload) }
|
23
|
+
@dispatcher_executor.post { dispatch_payload(payload: payload) }
|
24
24
|
rescue Concurrent::RejectedExecutionError
|
25
25
|
@logger.warn { 'Dispatcher executor queue is full. Event dispatch rejected' }
|
26
26
|
end
|
@@ -35,7 +35,7 @@ module Hackle
|
|
35
35
|
|
36
36
|
private
|
37
37
|
|
38
|
-
def dispatch_payload(payload)
|
38
|
+
def dispatch_payload(payload:)
|
39
39
|
request = Net::HTTP::Post.new('/api/v1/events', @headers)
|
40
40
|
request.content_type = 'application/json'
|
41
41
|
request.body = payload.to_json
|
@@ -43,19 +43,19 @@ module Hackle
|
|
43
43
|
response = @client.request(request)
|
44
44
|
|
45
45
|
status_code = response.code.to_i
|
46
|
-
HTTP.check_successful(status_code)
|
46
|
+
HTTP.check_successful(status_code: status_code)
|
47
47
|
rescue => e
|
48
48
|
@logger.error { "Failed to dispatch events: #{e.inspect}" }
|
49
49
|
end
|
50
50
|
|
51
|
-
def create_payload(events)
|
51
|
+
def create_payload(events:)
|
52
52
|
exposure_events = []
|
53
53
|
track_events = []
|
54
54
|
events.each do |event|
|
55
55
|
case event
|
56
|
-
when
|
56
|
+
when UserEvent::Exposure
|
57
57
|
exposure_events << create_exposure_event(event)
|
58
|
-
when
|
58
|
+
when UserEvent::Track
|
59
59
|
track_events << create_track_event(event)
|
60
60
|
end
|
61
61
|
end
|
@@ -65,24 +65,31 @@ module Hackle
|
|
65
65
|
}
|
66
66
|
end
|
67
67
|
|
68
|
-
|
68
|
+
#
|
69
|
+
# @param exposure [UserEvent::Exposure]
|
70
|
+
#
|
71
|
+
def create_exposure_event(exposure)
|
69
72
|
{
|
70
|
-
timestamp:
|
71
|
-
userId:
|
72
|
-
experimentId:
|
73
|
-
experimentKey:
|
74
|
-
variationId:
|
75
|
-
variationKey:
|
73
|
+
timestamp: exposure.timestamp,
|
74
|
+
userId: exposure.user.id,
|
75
|
+
experimentId: exposure.experiment.id,
|
76
|
+
experimentKey: exposure.experiment.key,
|
77
|
+
variationId: exposure.variation.id,
|
78
|
+
variationKey: exposure.variation.key
|
76
79
|
}
|
77
80
|
end
|
78
81
|
|
79
|
-
|
82
|
+
#
|
83
|
+
# @param track [UserEvent::Track]
|
84
|
+
#
|
85
|
+
def create_track_event(track)
|
80
86
|
{
|
81
|
-
timestamp:
|
82
|
-
userId:
|
83
|
-
eventTypeId:
|
84
|
-
eventTypeKey:
|
85
|
-
value: event.value
|
87
|
+
timestamp: track.timestamp,
|
88
|
+
userId: track.user.id,
|
89
|
+
eventTypeId: track.event_type.id,
|
90
|
+
eventTypeKey: track.event_type.key,
|
91
|
+
value: track.event.value,
|
92
|
+
properties: track.event.properties
|
86
93
|
}
|
87
94
|
end
|
88
95
|
end
|