shopcircle-orbit 1.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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +178 -0
- data/lib/shopcircle/orbit/client.rb +157 -0
- data/lib/shopcircle/orbit/configuration.rb +56 -0
- data/lib/shopcircle/orbit/event.rb +48 -0
- data/lib/shopcircle/orbit/rails/generators/install_generator.rb +16 -0
- data/lib/shopcircle/orbit/rails/middleware.rb +60 -0
- data/lib/shopcircle/orbit/rails/railtie.rb +23 -0
- data/lib/shopcircle/orbit/transport.rb +122 -0
- data/lib/shopcircle/orbit/validation.rb +36 -0
- data/lib/shopcircle/orbit/version.rb +7 -0
- data/lib/shopcircle/orbit/worker.rb +89 -0
- data/lib/shopcircle_orbit.rb +80 -0
- data/templates/shopcircle_orbit.rb.erb +8 -0
- metadata +102 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: ea60f7805d0aa67d6d2748586dc5342bc88e12f2b4b61b4aab4c8cd1ca5519f1
|
|
4
|
+
data.tar.gz: 87bf73def7abdfac9818392a21b80a161f67b5ecbcdd6c64df5dc37d4ebcd838
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: '067937e4dc01ec32494a35cfdba85fb923a6c0c10e4730a03bdbf5c5368d38773d46af351c36b73cbee9efa8491df6eed787afe97e67de1008e5400d52defb23'
|
|
7
|
+
data.tar.gz: 3379decabf5a9596d9a21fe7be34d30f7c0af5045d2e4917e5f12f165ebb27bcb1a8d00fa16b4d2bb9baaab9ad295d035346ce3cfdf51961ec2509fee51b0bd5
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ShopCircle
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# ShopCircle Orbit Ruby SDK
|
|
2
|
+
|
|
3
|
+
Server-side Ruby SDK for [ShopCircle Orbit](https://orbit.shopcircle.co) analytics. Track events, identify users, and measure engagement from any Ruby backend.
|
|
4
|
+
|
|
5
|
+
- Thread-safe background batching with automatic retry
|
|
6
|
+
- Zero runtime dependencies (Ruby stdlib only)
|
|
7
|
+
- Optional Rails integration with auto page view tracking
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
# Gemfile
|
|
13
|
+
gem "shopcircle-orbit"
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
bundle install
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Quick Start
|
|
21
|
+
|
|
22
|
+
### Plain Ruby
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
require "shopcircle_orbit"
|
|
26
|
+
|
|
27
|
+
orbit = ShopCircle::Orbit::Client.new(
|
|
28
|
+
client_id: "your-client-id",
|
|
29
|
+
api_url: "https://orbit.shopcircle.co"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
orbit.identify("user_123", first_name: "John", email: "john@example.com")
|
|
33
|
+
orbit.track("purchase", amount: 99.99, currency: "USD")
|
|
34
|
+
orbit.shutdown!
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Rails
|
|
38
|
+
|
|
39
|
+
Generate the initializer:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
rails generate shopcircle_orbit:install
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
This creates `config/initializers/shopcircle_orbit.rb`. Then use anywhere:
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
ShopCircle::Orbit.identify(current_user.id, first_name: current_user.first_name, email: current_user.email)
|
|
49
|
+
ShopCircle::Orbit.track("order_created", order_id: order.id, amount: order.total)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Configuration
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
ShopCircle::Orbit.configure do |config|
|
|
56
|
+
config.client_id = ENV.fetch("SHOPCIRCLE_CLIENT_ID")
|
|
57
|
+
config.client_secret = ENV["SHOPCIRCLE_CLIENT_SECRET"] # optional
|
|
58
|
+
config.api_url = "https://orbit.shopcircle.co"
|
|
59
|
+
config.device_id = nil # optional device identifier
|
|
60
|
+
config.flush_interval = 2 # seconds between flushes
|
|
61
|
+
config.max_batch_size = 10 # events per HTTP request
|
|
62
|
+
config.max_queue_size = 1_000 # max queued events
|
|
63
|
+
config.max_retries = 3 # retry attempts for failures
|
|
64
|
+
config.request_timeout = 10 # HTTP timeout in seconds
|
|
65
|
+
config.stub = Rails.env.test? # test mode (no HTTP)
|
|
66
|
+
config.on_error = ->(msg, detail) { Rails.logger.error("[Orbit] #{msg}") }
|
|
67
|
+
end
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## API
|
|
71
|
+
|
|
72
|
+
### `track(name, properties = {})`
|
|
73
|
+
|
|
74
|
+
Track a custom event:
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
ShopCircle::Orbit.track("button_click", label: "Sign Up", page: "/pricing")
|
|
78
|
+
ShopCircle::Orbit.track("purchase", amount: 49.99, currency: "USD", product_id: "prod_abc")
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### `identify(profile_id, traits = {})`
|
|
82
|
+
|
|
83
|
+
Identify a user with profile attributes:
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
ShopCircle::Orbit.identify("user_123",
|
|
87
|
+
first_name: "John",
|
|
88
|
+
last_name: "Doe",
|
|
89
|
+
email: "john@example.com",
|
|
90
|
+
plan: "premium"
|
|
91
|
+
)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### `reset!`
|
|
95
|
+
|
|
96
|
+
Clear the current user identity (e.g. on logout):
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
ShopCircle::Orbit.reset!
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### `set_device_id(device_id)`
|
|
103
|
+
|
|
104
|
+
Set a device identifier for cross-session linking:
|
|
105
|
+
|
|
106
|
+
```ruby
|
|
107
|
+
ShopCircle::Orbit.set_device_id("device-fingerprint-from-frontend")
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### `flush`
|
|
111
|
+
|
|
112
|
+
Force an immediate flush of queued events:
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
ShopCircle::Orbit.flush
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### `shutdown!`
|
|
119
|
+
|
|
120
|
+
Flush remaining events and stop the background worker:
|
|
121
|
+
|
|
122
|
+
```ruby
|
|
123
|
+
ShopCircle::Orbit.shutdown!
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Multiple Clients
|
|
127
|
+
|
|
128
|
+
For multi-tenant or multi-project use cases, create separate client instances:
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
project_a = ShopCircle::Orbit::Client.new(client_id: "id-a", api_url: "https://orbit.shopcircle.co")
|
|
132
|
+
project_b = ShopCircle::Orbit::Client.new(client_id: "id-b", api_url: "https://orbit.shopcircle.co")
|
|
133
|
+
|
|
134
|
+
project_a.track("event_a")
|
|
135
|
+
project_b.track("event_b")
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Testing
|
|
139
|
+
|
|
140
|
+
Enable stub mode to record events without making HTTP calls:
|
|
141
|
+
|
|
142
|
+
```ruby
|
|
143
|
+
# test/test_helper.rb
|
|
144
|
+
ShopCircle::Orbit.configure do |config|
|
|
145
|
+
config.stub = true
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# In your test
|
|
149
|
+
class OrderTest < ActiveSupport::TestCase
|
|
150
|
+
setup do
|
|
151
|
+
ShopCircle::Orbit.shutdown! # reset between tests
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
test "tracks purchase event" do
|
|
155
|
+
OrderService.create!(user: users(:alice), amount: 99.99)
|
|
156
|
+
|
|
157
|
+
events = ShopCircle::Orbit.client.events
|
|
158
|
+
purchase = events.find { |e| e[:payload][:name] == "purchase" }
|
|
159
|
+
assert purchase
|
|
160
|
+
assert_equal 99.99, purchase[:payload][:properties]["amount"]
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
## Rails Middleware
|
|
166
|
+
|
|
167
|
+
The SDK automatically inserts Rack middleware that tracks `page_view` events for HTML GET requests. Asset paths, XHR, and JSON requests are excluded.
|
|
168
|
+
|
|
169
|
+
To disable:
|
|
170
|
+
|
|
171
|
+
```ruby
|
|
172
|
+
# config/application.rb
|
|
173
|
+
config.middleware.delete ShopCircle::Orbit::Rails::Middleware
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## License
|
|
177
|
+
|
|
178
|
+
MIT
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thread"
|
|
4
|
+
|
|
5
|
+
module ShopCircle
|
|
6
|
+
module Orbit
|
|
7
|
+
class Client
|
|
8
|
+
def initialize(config = nil, **options)
|
|
9
|
+
@config = resolve_config(config, options)
|
|
10
|
+
@config.validate!
|
|
11
|
+
|
|
12
|
+
@profile_id = nil
|
|
13
|
+
@device_id = @config.device_id
|
|
14
|
+
@queue = Thread::Queue.new
|
|
15
|
+
@mutex = Mutex.new
|
|
16
|
+
@shutdown = false
|
|
17
|
+
|
|
18
|
+
transport = @config.stub ? TestTransport.new : Transport.new(@config)
|
|
19
|
+
|
|
20
|
+
@worker = Worker.new(
|
|
21
|
+
queue: @queue,
|
|
22
|
+
config: @config,
|
|
23
|
+
transport: transport
|
|
24
|
+
)
|
|
25
|
+
@worker.start
|
|
26
|
+
|
|
27
|
+
register_at_exit_hook
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Track a custom event with optional properties.
|
|
31
|
+
#
|
|
32
|
+
# orbit.track("purchase", amount: 99.99, currency: "USD")
|
|
33
|
+
#
|
|
34
|
+
def track(name, properties = {})
|
|
35
|
+
Validation.validate_event_name!(name)
|
|
36
|
+
Validation.validate_properties!(properties)
|
|
37
|
+
|
|
38
|
+
enqueue(Event.new(
|
|
39
|
+
type: "track",
|
|
40
|
+
name: name.to_s,
|
|
41
|
+
profile_id: current_profile_id,
|
|
42
|
+
device_id: current_device_id,
|
|
43
|
+
properties: properties
|
|
44
|
+
))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Identify a user. Subsequent events will be associated with this profile.
|
|
48
|
+
#
|
|
49
|
+
# orbit.identify("user_123", first_name: "John", email: "john@example.com")
|
|
50
|
+
#
|
|
51
|
+
# Accepts both snake_case and camelCase trait keys:
|
|
52
|
+
# first_name / firstName, last_name / lastName
|
|
53
|
+
#
|
|
54
|
+
def identify(profile_id, traits = {})
|
|
55
|
+
raise ArgumentError, "profile_id is required" if profile_id.nil? || profile_id.to_s.strip.empty?
|
|
56
|
+
|
|
57
|
+
@mutex.synchronize { @profile_id = profile_id.to_s }
|
|
58
|
+
|
|
59
|
+
traits = traits.dup
|
|
60
|
+
first_name = traits.delete(:first_name) || traits.delete(:firstName)
|
|
61
|
+
last_name = traits.delete(:last_name) || traits.delete(:lastName)
|
|
62
|
+
email = traits.delete(:email)
|
|
63
|
+
avatar = traits.delete(:avatar)
|
|
64
|
+
|
|
65
|
+
enqueue(Event.new(
|
|
66
|
+
type: "identify",
|
|
67
|
+
profile_id: profile_id.to_s,
|
|
68
|
+
device_id: current_device_id,
|
|
69
|
+
first_name: first_name,
|
|
70
|
+
last_name: last_name,
|
|
71
|
+
email: email,
|
|
72
|
+
avatar: avatar,
|
|
73
|
+
properties: traits.empty? ? nil : traits
|
|
74
|
+
))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Clear current profile association (e.g. on logout).
|
|
78
|
+
def reset!
|
|
79
|
+
@mutex.synchronize { @profile_id = nil }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Set or update the device identifier at runtime.
|
|
83
|
+
def set_device_id(device_id)
|
|
84
|
+
Validation.validate_device_id!(device_id)
|
|
85
|
+
@mutex.synchronize { @device_id = device_id&.to_s }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Force an immediate flush of queued events.
|
|
89
|
+
def flush
|
|
90
|
+
@worker.flush
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Flush remaining events and stop the background worker.
|
|
94
|
+
def shutdown!
|
|
95
|
+
@mutex.synchronize { @shutdown = true }
|
|
96
|
+
@worker.stop
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Returns recorded events (stub mode only).
|
|
100
|
+
def events
|
|
101
|
+
return [] unless @config.stub
|
|
102
|
+
@worker.transport.events
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def enqueue(event)
|
|
108
|
+
return if @mutex.synchronize { @shutdown }
|
|
109
|
+
|
|
110
|
+
# Drop oldest if at capacity
|
|
111
|
+
if @queue.size >= @config.max_queue_size
|
|
112
|
+
@queue.pop(true) rescue nil
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
@queue.push(event)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def current_profile_id
|
|
119
|
+
@mutex.synchronize { @profile_id }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def current_device_id
|
|
123
|
+
@mutex.synchronize { @device_id }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def register_at_exit_hook
|
|
127
|
+
at_exit { shutdown! }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def resolve_config(config, options)
|
|
131
|
+
case config
|
|
132
|
+
when Configuration
|
|
133
|
+
config
|
|
134
|
+
when Hash
|
|
135
|
+
build_config(config.merge(options))
|
|
136
|
+
when nil
|
|
137
|
+
if options.empty?
|
|
138
|
+
ShopCircle::Orbit.configuration
|
|
139
|
+
else
|
|
140
|
+
build_config(options)
|
|
141
|
+
end
|
|
142
|
+
else
|
|
143
|
+
raise ArgumentError, "Expected Configuration, Hash, or keyword arguments"
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def build_config(hash)
|
|
148
|
+
c = Configuration.new
|
|
149
|
+
hash.each do |k, v|
|
|
150
|
+
setter = :"#{k}="
|
|
151
|
+
c.public_send(setter, v) if c.respond_to?(setter)
|
|
152
|
+
end
|
|
153
|
+
c
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ShopCircle
|
|
4
|
+
module Orbit
|
|
5
|
+
class Configuration
|
|
6
|
+
attr_accessor :client_id,
|
|
7
|
+
:client_secret,
|
|
8
|
+
:api_url,
|
|
9
|
+
:device_id,
|
|
10
|
+
:flush_interval,
|
|
11
|
+
:max_batch_size,
|
|
12
|
+
:max_queue_size,
|
|
13
|
+
:max_retries,
|
|
14
|
+
:base_retry_delay,
|
|
15
|
+
:request_timeout,
|
|
16
|
+
:logger,
|
|
17
|
+
:stub,
|
|
18
|
+
:on_error
|
|
19
|
+
|
|
20
|
+
def initialize
|
|
21
|
+
@client_id = nil
|
|
22
|
+
@client_secret = nil
|
|
23
|
+
@api_url = nil
|
|
24
|
+
@device_id = nil
|
|
25
|
+
@flush_interval = 2
|
|
26
|
+
@max_batch_size = 10
|
|
27
|
+
@max_queue_size = 1_000
|
|
28
|
+
@max_retries = 3
|
|
29
|
+
@base_retry_delay = 1
|
|
30
|
+
@request_timeout = 10
|
|
31
|
+
@logger = nil
|
|
32
|
+
@stub = false
|
|
33
|
+
@on_error = nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def validate!
|
|
37
|
+
raise ArgumentError, "client_id is required" if @client_id.nil? || @client_id.to_s.empty?
|
|
38
|
+
raise ArgumentError, "api_url is required" if @api_url.nil? || @api_url.to_s.empty?
|
|
39
|
+
raise ArgumentError, "device_id max 128 chars" if @device_id && @device_id.to_s.length > 128
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def resolved_logger
|
|
43
|
+
@logger || default_logger
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def default_logger
|
|
49
|
+
@default_logger ||= begin
|
|
50
|
+
require "logger"
|
|
51
|
+
Logger.new($stdout, level: Logger::WARN)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ShopCircle
|
|
4
|
+
module Orbit
|
|
5
|
+
class Event
|
|
6
|
+
attr_reader :type, :name, :profile_id, :device_id,
|
|
7
|
+
:properties, :first_name, :last_name,
|
|
8
|
+
:email, :avatar
|
|
9
|
+
|
|
10
|
+
def initialize(type:, name: nil, profile_id: nil, device_id: nil,
|
|
11
|
+
properties: nil, first_name: nil, last_name: nil,
|
|
12
|
+
email: nil, avatar: nil)
|
|
13
|
+
@type = type
|
|
14
|
+
@name = name
|
|
15
|
+
@profile_id = profile_id
|
|
16
|
+
@device_id = device_id
|
|
17
|
+
@properties = properties
|
|
18
|
+
@first_name = first_name
|
|
19
|
+
@last_name = last_name
|
|
20
|
+
@email = email
|
|
21
|
+
@avatar = avatar
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def to_payload
|
|
25
|
+
payload = {}
|
|
26
|
+
payload[:name] = @name if @name
|
|
27
|
+
payload[:profileId] = @profile_id if @profile_id
|
|
28
|
+
payload[:deviceId] = @device_id if @device_id
|
|
29
|
+
payload[:firstName] = @first_name if @first_name
|
|
30
|
+
payload[:lastName] = @last_name if @last_name
|
|
31
|
+
payload[:email] = @email if @email
|
|
32
|
+
payload[:avatar] = @avatar if @avatar
|
|
33
|
+
payload[:properties] = normalize_keys(@properties) if @properties && !@properties.empty?
|
|
34
|
+
{ type: @type, payload: payload }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def normalize_keys(hash)
|
|
40
|
+
return hash unless hash.is_a?(Hash)
|
|
41
|
+
|
|
42
|
+
hash.each_with_object({}) do |(k, v), result|
|
|
43
|
+
result[k.to_s] = v
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
|
|
5
|
+
module ShopCircleOrbit
|
|
6
|
+
module Generators
|
|
7
|
+
class InstallGenerator < ::Rails::Generators::Base
|
|
8
|
+
source_root File.expand_path("../../../../../templates", __dir__)
|
|
9
|
+
desc "Creates a ShopCircle Orbit initializer"
|
|
10
|
+
|
|
11
|
+
def create_initializer
|
|
12
|
+
template "shopcircle_orbit.rb.erb", "config/initializers/shopcircle_orbit.rb"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ShopCircle
|
|
4
|
+
module Orbit
|
|
5
|
+
module Rails
|
|
6
|
+
# Rack middleware that auto-tracks page_view events for HTML page requests.
|
|
7
|
+
# Skips assets, XHR, and non-GET requests.
|
|
8
|
+
#
|
|
9
|
+
# Opt out by removing from the middleware stack:
|
|
10
|
+
#
|
|
11
|
+
# # config/application.rb
|
|
12
|
+
# config.middleware.delete ShopCircle::Orbit::Rails::Middleware
|
|
13
|
+
#
|
|
14
|
+
class Middleware
|
|
15
|
+
SKIP_PREFIXES = %w[/assets /packs /rails /cable].freeze
|
|
16
|
+
|
|
17
|
+
def initialize(app)
|
|
18
|
+
@app = app
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call(env)
|
|
22
|
+
status, headers, response = @app.call(env)
|
|
23
|
+
|
|
24
|
+
if trackable?(env, status)
|
|
25
|
+
request = Rack::Request.new(env)
|
|
26
|
+
ShopCircle::Orbit.track("page_view", {
|
|
27
|
+
path: request.path,
|
|
28
|
+
method: request.request_method,
|
|
29
|
+
status: status,
|
|
30
|
+
user_agent: request.user_agent,
|
|
31
|
+
ip: request.ip,
|
|
32
|
+
referrer: request.referrer
|
|
33
|
+
})
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
[status, headers, response]
|
|
37
|
+
rescue StandardError
|
|
38
|
+
# Never break the request due to tracking errors
|
|
39
|
+
[status, headers, response]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def trackable?(env, status)
|
|
45
|
+
return false unless env["REQUEST_METHOD"] == "GET"
|
|
46
|
+
return false unless status >= 200 && status < 400
|
|
47
|
+
|
|
48
|
+
path = env["PATH_INFO"].to_s
|
|
49
|
+
return false if SKIP_PREFIXES.any? { |p| path.start_with?(p) }
|
|
50
|
+
|
|
51
|
+
# Skip XHR / fetch requests
|
|
52
|
+
return false if env["HTTP_X_REQUESTED_WITH"] == "XMLHttpRequest"
|
|
53
|
+
return false if env["HTTP_ACCEPT"]&.include?("application/json")
|
|
54
|
+
|
|
55
|
+
true
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "middleware"
|
|
4
|
+
|
|
5
|
+
module ShopCircle
|
|
6
|
+
module Orbit
|
|
7
|
+
module Rails
|
|
8
|
+
class Railtie < ::Rails::Railtie
|
|
9
|
+
initializer "shopcircle_orbit.configure" do
|
|
10
|
+
ShopCircle::Orbit.configuration.logger ||= ::Rails.logger
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
initializer "shopcircle_orbit.middleware" do |app|
|
|
14
|
+
app.middleware.use ShopCircle::Orbit::Rails::Middleware
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
config.after_initialize do
|
|
18
|
+
at_exit { ShopCircle::Orbit.shutdown! }
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module ShopCircle
|
|
8
|
+
module Orbit
|
|
9
|
+
class Transport
|
|
10
|
+
SDK_NAME = "shopcircle-orbit-ruby"
|
|
11
|
+
|
|
12
|
+
def initialize(config)
|
|
13
|
+
@config = config
|
|
14
|
+
@api_uri = URI.parse(config.api_url.to_s.chomp("/"))
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def send_single(event)
|
|
18
|
+
body = JSON.generate(event.to_payload)
|
|
19
|
+
post("/api/track", body)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def send_batch(events)
|
|
23
|
+
body = JSON.generate({
|
|
24
|
+
events: events.map(&:to_payload),
|
|
25
|
+
clientId: @config.client_id,
|
|
26
|
+
sdkName: SDK_NAME,
|
|
27
|
+
sdkVersion: ShopCircle::Orbit::VERSION,
|
|
28
|
+
deviceId: events.first&.device_id
|
|
29
|
+
})
|
|
30
|
+
post("/api/track/batch", body)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def post(path, body, attempt = 0)
|
|
36
|
+
uri = @api_uri.dup
|
|
37
|
+
uri.path = path
|
|
38
|
+
|
|
39
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
40
|
+
http.use_ssl = (uri.scheme == "https")
|
|
41
|
+
http.open_timeout = @config.request_timeout
|
|
42
|
+
http.read_timeout = @config.request_timeout
|
|
43
|
+
http.write_timeout = @config.request_timeout if http.respond_to?(:write_timeout=)
|
|
44
|
+
|
|
45
|
+
request = Net::HTTP::Post.new(uri.path)
|
|
46
|
+
request["Content-Type"] = "application/json"
|
|
47
|
+
request["shopcircle-client-id"] = @config.client_id
|
|
48
|
+
request["shopcircle-sdk-name"] = SDK_NAME
|
|
49
|
+
request["shopcircle-sdk-version"] = ShopCircle::Orbit::VERSION
|
|
50
|
+
request["shopcircle-client-secret"] = @config.client_secret if @config.client_secret
|
|
51
|
+
request.body = body
|
|
52
|
+
|
|
53
|
+
response = http.request(request)
|
|
54
|
+
status = response.code.to_i
|
|
55
|
+
|
|
56
|
+
return if status >= 200 && status < 300
|
|
57
|
+
|
|
58
|
+
# 4xx except 429: client error, don't retry
|
|
59
|
+
if status >= 400 && status < 500 && status != 429
|
|
60
|
+
log_error("HTTP #{status}", response.body)
|
|
61
|
+
return
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# 429, 5xx: retryable
|
|
65
|
+
maybe_retry(path, body, attempt, status, response.body)
|
|
66
|
+
rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout,
|
|
67
|
+
Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EPIPE,
|
|
68
|
+
SocketError, IOError => e
|
|
69
|
+
maybe_retry(path, body, attempt, nil, e.message)
|
|
70
|
+
ensure
|
|
71
|
+
http&.finish rescue nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def maybe_retry(path, body, attempt, status, message)
|
|
75
|
+
if attempt < @config.max_retries
|
|
76
|
+
delay = @config.base_retry_delay * (2**attempt)
|
|
77
|
+
sleep(delay)
|
|
78
|
+
post(path, body, attempt + 1)
|
|
79
|
+
else
|
|
80
|
+
log_error("Failed after #{@config.max_retries + 1} attempts (status=#{status})", message)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def log_error(msg, detail)
|
|
85
|
+
@config.on_error&.call(msg, detail)
|
|
86
|
+
@config.resolved_logger.warn("[shopcircle-orbit] #{msg}: #{detail}")
|
|
87
|
+
rescue StandardError
|
|
88
|
+
# Never crash the worker thread due to logging/callback errors
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Stub transport for testing - records events in memory
|
|
93
|
+
class TestTransport
|
|
94
|
+
attr_reader :events, :batches
|
|
95
|
+
|
|
96
|
+
def initialize
|
|
97
|
+
@events = []
|
|
98
|
+
@batches = []
|
|
99
|
+
@mutex = Mutex.new
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def send_single(event)
|
|
103
|
+
@mutex.synchronize { @events << event.to_payload }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def send_batch(events)
|
|
107
|
+
payloads = events.map(&:to_payload)
|
|
108
|
+
@mutex.synchronize do
|
|
109
|
+
@batches << payloads
|
|
110
|
+
@events.concat(payloads)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def clear!
|
|
115
|
+
@mutex.synchronize do
|
|
116
|
+
@events.clear
|
|
117
|
+
@batches.clear
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module ShopCircle
|
|
6
|
+
module Orbit
|
|
7
|
+
module Validation
|
|
8
|
+
MAX_EVENT_NAME_LENGTH = 80
|
|
9
|
+
MAX_DEVICE_ID_LENGTH = 128
|
|
10
|
+
MAX_PROPERTIES_BYTES = 10_240
|
|
11
|
+
RESERVED_NAMES = %w[session_start session_end].freeze
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
def validate_event_name!(name)
|
|
15
|
+
raise ArgumentError, "event name is required" if name.nil? || name.to_s.strip.empty?
|
|
16
|
+
raise ArgumentError, "event name max #{MAX_EVENT_NAME_LENGTH} chars" if name.to_s.length > MAX_EVENT_NAME_LENGTH
|
|
17
|
+
raise ArgumentError, "event name '#{name}' is reserved" if RESERVED_NAMES.include?(name.to_s)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def validate_properties!(props)
|
|
21
|
+
return if props.nil? || props.empty?
|
|
22
|
+
|
|
23
|
+
json = JSON.generate(props)
|
|
24
|
+
if json.bytesize > MAX_PROPERTIES_BYTES
|
|
25
|
+
raise ArgumentError, "properties too large (#{json.bytesize} bytes, max #{MAX_PROPERTIES_BYTES})"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def validate_device_id!(device_id)
|
|
30
|
+
return if device_id.nil?
|
|
31
|
+
raise ArgumentError, "device_id max #{MAX_DEVICE_ID_LENGTH} chars" if device_id.to_s.length > MAX_DEVICE_ID_LENGTH
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ShopCircle
|
|
4
|
+
module Orbit
|
|
5
|
+
class Worker
|
|
6
|
+
attr_reader :transport
|
|
7
|
+
|
|
8
|
+
def initialize(queue:, config:, transport:)
|
|
9
|
+
@queue = queue
|
|
10
|
+
@config = config
|
|
11
|
+
@transport = transport
|
|
12
|
+
@thread = nil
|
|
13
|
+
@mutex = Mutex.new
|
|
14
|
+
@condvar = ConditionVariable.new
|
|
15
|
+
@running = false
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def start
|
|
19
|
+
@mutex.synchronize do
|
|
20
|
+
return if @running
|
|
21
|
+
@running = true
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
@thread = Thread.new { run_loop }
|
|
25
|
+
@thread.abort_on_exception = false
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def stop
|
|
29
|
+
@mutex.synchronize do
|
|
30
|
+
@running = false
|
|
31
|
+
@condvar.signal
|
|
32
|
+
end
|
|
33
|
+
flush_all
|
|
34
|
+
@thread&.join(5)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def flush
|
|
38
|
+
@mutex.synchronize { @condvar.signal }
|
|
39
|
+
# Give worker thread a moment to process
|
|
40
|
+
sleep(0.05)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def run_loop
|
|
46
|
+
while running?
|
|
47
|
+
@mutex.synchronize do
|
|
48
|
+
@condvar.wait(@mutex, @config.flush_interval)
|
|
49
|
+
end
|
|
50
|
+
flush_all
|
|
51
|
+
end
|
|
52
|
+
rescue StandardError => e
|
|
53
|
+
@config.resolved_logger.error("[shopcircle-orbit] Worker error: #{e.message}")
|
|
54
|
+
retry if running?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def running?
|
|
58
|
+
@mutex.synchronize { @running }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def flush_all
|
|
62
|
+
batch = drain_queue
|
|
63
|
+
return if batch.empty?
|
|
64
|
+
|
|
65
|
+
batch.each_slice(@config.max_batch_size) do |chunk|
|
|
66
|
+
if chunk.size == 1
|
|
67
|
+
@transport.send_single(chunk.first)
|
|
68
|
+
else
|
|
69
|
+
@transport.send_batch(chunk)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
rescue StandardError => e
|
|
73
|
+
@config.resolved_logger.error("[shopcircle-orbit] Flush error: #{e.message}")
|
|
74
|
+
@config.on_error&.call("Flush error", e.message)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def drain_queue
|
|
78
|
+
events = []
|
|
79
|
+
loop do
|
|
80
|
+
event = @queue.pop(true) # non-blocking
|
|
81
|
+
events << event
|
|
82
|
+
rescue ThreadError
|
|
83
|
+
break
|
|
84
|
+
end
|
|
85
|
+
events
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "shopcircle/orbit/version"
|
|
4
|
+
require_relative "shopcircle/orbit/configuration"
|
|
5
|
+
require_relative "shopcircle/orbit/validation"
|
|
6
|
+
require_relative "shopcircle/orbit/event"
|
|
7
|
+
require_relative "shopcircle/orbit/transport"
|
|
8
|
+
require_relative "shopcircle/orbit/worker"
|
|
9
|
+
require_relative "shopcircle/orbit/client"
|
|
10
|
+
|
|
11
|
+
module ShopCircle
|
|
12
|
+
module Orbit
|
|
13
|
+
class << self
|
|
14
|
+
attr_writer :configuration
|
|
15
|
+
|
|
16
|
+
def configuration
|
|
17
|
+
@configuration ||= Configuration.new
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Configure the module-level singleton.
|
|
21
|
+
#
|
|
22
|
+
# ShopCircle::Orbit.configure do |config|
|
|
23
|
+
# config.client_id = "your-client-id"
|
|
24
|
+
# config.api_url = "https://orbit.shopcircle.co"
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
def configure
|
|
28
|
+
yield(configuration)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Lazily initialized singleton client.
|
|
32
|
+
def client
|
|
33
|
+
@client ||= Client.new(configuration)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Track a custom event.
|
|
37
|
+
#
|
|
38
|
+
# ShopCircle::Orbit.track("purchase", amount: 99.99)
|
|
39
|
+
#
|
|
40
|
+
def track(name, properties = {})
|
|
41
|
+
client.track(name, properties)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Identify a user.
|
|
45
|
+
#
|
|
46
|
+
# ShopCircle::Orbit.identify("user_123", first_name: "John", email: "john@example.com")
|
|
47
|
+
#
|
|
48
|
+
def identify(profile_id, traits = {})
|
|
49
|
+
client.identify(profile_id, traits)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Clear current profile association.
|
|
53
|
+
def reset!
|
|
54
|
+
return unless @client
|
|
55
|
+
@client.reset!
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Set device identifier.
|
|
59
|
+
def set_device_id(device_id)
|
|
60
|
+
client.set_device_id(device_id)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Force an immediate flush.
|
|
64
|
+
def flush
|
|
65
|
+
return unless @client
|
|
66
|
+
@client.flush
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Flush and stop the singleton client.
|
|
70
|
+
def shutdown!
|
|
71
|
+
return unless @client
|
|
72
|
+
@client.shutdown!
|
|
73
|
+
@client = nil
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Auto-load Rails integration if Rails is present
|
|
80
|
+
require_relative "shopcircle/orbit/rails/railtie" if defined?(::Rails::Railtie)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
ShopCircle::Orbit.configure do |config|
|
|
2
|
+
config.client_id = ENV.fetch("SHOPCIRCLE_CLIENT_ID")
|
|
3
|
+
config.client_secret = ENV["SHOPCIRCLE_CLIENT_SECRET"] # optional
|
|
4
|
+
config.api_url = ENV.fetch("SHOPCIRCLE_API_URL", "https://orbit.shopcircle.co")
|
|
5
|
+
# config.device_id = nil
|
|
6
|
+
# config.stub = Rails.env.test?
|
|
7
|
+
# config.on_error = ->(msg, detail) { Rails.logger.error("[Orbit] #{msg}: #{detail}") }
|
|
8
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: shopcircle-orbit
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- ShopCircle
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: rake
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '13.0'
|
|
19
|
+
type: :development
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '13.0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: minitest
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '5.0'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '5.0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: webmock
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '3.0'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '3.0'
|
|
54
|
+
description: Track events, identify users, and measure engagement from Ruby backends.
|
|
55
|
+
Thread-safe with background batching, zero runtime dependencies, and optional Rails
|
|
56
|
+
integration.
|
|
57
|
+
email:
|
|
58
|
+
- engineering@shopcircle.com
|
|
59
|
+
executables: []
|
|
60
|
+
extensions: []
|
|
61
|
+
extra_rdoc_files: []
|
|
62
|
+
files:
|
|
63
|
+
- LICENSE.txt
|
|
64
|
+
- README.md
|
|
65
|
+
- lib/shopcircle/orbit/client.rb
|
|
66
|
+
- lib/shopcircle/orbit/configuration.rb
|
|
67
|
+
- lib/shopcircle/orbit/event.rb
|
|
68
|
+
- lib/shopcircle/orbit/rails/generators/install_generator.rb
|
|
69
|
+
- lib/shopcircle/orbit/rails/middleware.rb
|
|
70
|
+
- lib/shopcircle/orbit/rails/railtie.rb
|
|
71
|
+
- lib/shopcircle/orbit/transport.rb
|
|
72
|
+
- lib/shopcircle/orbit/validation.rb
|
|
73
|
+
- lib/shopcircle/orbit/version.rb
|
|
74
|
+
- lib/shopcircle/orbit/worker.rb
|
|
75
|
+
- lib/shopcircle_orbit.rb
|
|
76
|
+
- templates/shopcircle_orbit.rb.erb
|
|
77
|
+
homepage: https://github.com/shopcircle/orbit-sdk-ruby
|
|
78
|
+
licenses:
|
|
79
|
+
- MIT
|
|
80
|
+
metadata:
|
|
81
|
+
homepage_uri: https://github.com/shopcircle/orbit-sdk-ruby
|
|
82
|
+
source_code_uri: https://github.com/shopcircle/orbit-sdk-ruby
|
|
83
|
+
changelog_uri: https://github.com/shopcircle/orbit-sdk-ruby/blob/main/CHANGELOG.md
|
|
84
|
+
rubygems_mfa_required: 'true'
|
|
85
|
+
rdoc_options: []
|
|
86
|
+
require_paths:
|
|
87
|
+
- lib
|
|
88
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
89
|
+
requirements:
|
|
90
|
+
- - ">="
|
|
91
|
+
- !ruby/object:Gem::Version
|
|
92
|
+
version: 2.7.0
|
|
93
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
94
|
+
requirements:
|
|
95
|
+
- - ">="
|
|
96
|
+
- !ruby/object:Gem::Version
|
|
97
|
+
version: '0'
|
|
98
|
+
requirements: []
|
|
99
|
+
rubygems_version: 3.6.7
|
|
100
|
+
specification_version: 4
|
|
101
|
+
summary: Server-side Ruby SDK for ShopCircle Orbit analytics
|
|
102
|
+
test_files: []
|