shopcircle-orbit 1.0.0 → 1.1.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/shopcircle/orbit/client.rb +23 -16
- data/lib/shopcircle/orbit/configuration.rb +43 -4
- data/lib/shopcircle/orbit/errors.rb +49 -0
- data/lib/shopcircle/orbit/rails/middleware.rb +24 -15
- data/lib/shopcircle/orbit/transport.rb +99 -46
- data/lib/shopcircle/orbit/validation.rb +23 -7
- data/lib/shopcircle/orbit/version.rb +1 -1
- data/lib/shopcircle/orbit/worker.rb +47 -4
- data/lib/shopcircle_orbit.rb +13 -4
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fc9150b18fb5fafd2bfffa30c16ab606e828e430e0a256f8bf7b26b99b6b9b24
|
|
4
|
+
data.tar.gz: 5f80e5a8c9c68835749e856191903c165d8f4ce7dfaf8f6b2be037b935d348e1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 29e8608f43338f1f5fceaf2b059c1cf9b70ce93368d609a5406dda26e418c3d207ee44043fd9b3d99c67e43b9b71bab4755714fa6dc58d0552a63507939da2b3
|
|
7
|
+
data.tar.gz: d479a929d7d21e3759f09a0324014ba2aaf811ab9dceb5105e4841faca25322a767ee00c11f01bb249066c1d316f61520675819e1859ad9a2b6692485e65f213
|
|
@@ -9,8 +9,7 @@ module ShopCircle
|
|
|
9
9
|
@config = resolve_config(config, options)
|
|
10
10
|
@config.validate!
|
|
11
11
|
|
|
12
|
-
@
|
|
13
|
-
@device_id = @config.device_id
|
|
12
|
+
@default_device_id = @config.device_id
|
|
14
13
|
@queue = Thread::Queue.new
|
|
15
14
|
@mutex = Mutex.new
|
|
16
15
|
@shutdown = false
|
|
@@ -52,9 +51,9 @@ module ShopCircle
|
|
|
52
51
|
# first_name / firstName, last_name / lastName
|
|
53
52
|
#
|
|
54
53
|
def identify(profile_id, traits = {})
|
|
55
|
-
raise
|
|
54
|
+
raise ValidationError, "profile_id is required" if profile_id.nil? || profile_id.to_s.strip.empty?
|
|
56
55
|
|
|
57
|
-
|
|
56
|
+
Thread.current[:shopcircle_orbit_profile_id] = profile_id.to_s
|
|
58
57
|
|
|
59
58
|
traits = traits.dup
|
|
60
59
|
first_name = traits.delete(:first_name) || traits.delete(:firstName)
|
|
@@ -76,13 +75,14 @@ module ShopCircle
|
|
|
76
75
|
|
|
77
76
|
# Clear current profile association (e.g. on logout).
|
|
78
77
|
def reset!
|
|
79
|
-
|
|
78
|
+
Thread.current[:shopcircle_orbit_profile_id] = nil
|
|
79
|
+
Thread.current[:shopcircle_orbit_device_id] = nil
|
|
80
80
|
end
|
|
81
81
|
|
|
82
82
|
# Set or update the device identifier at runtime.
|
|
83
83
|
def set_device_id(device_id)
|
|
84
84
|
Validation.validate_device_id!(device_id)
|
|
85
|
-
|
|
85
|
+
Thread.current[:shopcircle_orbit_device_id] = device_id&.to_s
|
|
86
86
|
end
|
|
87
87
|
|
|
88
88
|
# Force an immediate flush of queued events.
|
|
@@ -94,6 +94,7 @@ module ShopCircle
|
|
|
94
94
|
def shutdown!
|
|
95
95
|
@mutex.synchronize { @shutdown = true }
|
|
96
96
|
@worker.stop
|
|
97
|
+
@worker.transport.shutdown if @worker.transport.respond_to?(:shutdown)
|
|
97
98
|
end
|
|
98
99
|
|
|
99
100
|
# Returns recorded events (stub mode only).
|
|
@@ -105,22 +106,24 @@ module ShopCircle
|
|
|
105
106
|
private
|
|
106
107
|
|
|
107
108
|
def enqueue(event)
|
|
108
|
-
|
|
109
|
+
@mutex.synchronize do
|
|
110
|
+
return if @shutdown
|
|
109
111
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
112
|
+
if @queue.size >= @config.max_queue_size
|
|
113
|
+
@queue.pop(true) rescue nil
|
|
114
|
+
@config.resolved_logger&.warn("[shopcircle-orbit] Event queue full (#{@config.max_queue_size}), dropping oldest event")
|
|
115
|
+
end
|
|
114
116
|
|
|
115
|
-
|
|
117
|
+
@queue.push(event)
|
|
118
|
+
end
|
|
116
119
|
end
|
|
117
120
|
|
|
118
121
|
def current_profile_id
|
|
119
|
-
|
|
122
|
+
Thread.current[:shopcircle_orbit_profile_id]
|
|
120
123
|
end
|
|
121
124
|
|
|
122
125
|
def current_device_id
|
|
123
|
-
|
|
126
|
+
Thread.current[:shopcircle_orbit_device_id] || @default_device_id
|
|
124
127
|
end
|
|
125
128
|
|
|
126
129
|
def register_at_exit_hook
|
|
@@ -140,7 +143,7 @@ module ShopCircle
|
|
|
140
143
|
build_config(options)
|
|
141
144
|
end
|
|
142
145
|
else
|
|
143
|
-
raise
|
|
146
|
+
raise ConfigurationError, "Expected Configuration, Hash, or keyword arguments"
|
|
144
147
|
end
|
|
145
148
|
end
|
|
146
149
|
|
|
@@ -148,7 +151,11 @@ module ShopCircle
|
|
|
148
151
|
c = Configuration.new
|
|
149
152
|
hash.each do |k, v|
|
|
150
153
|
setter = :"#{k}="
|
|
151
|
-
|
|
154
|
+
if c.respond_to?(setter)
|
|
155
|
+
c.public_send(setter, v)
|
|
156
|
+
else
|
|
157
|
+
c.resolved_logger.warn("[shopcircle-orbit] Unknown configuration key: #{k}")
|
|
158
|
+
end
|
|
152
159
|
end
|
|
153
160
|
c
|
|
154
161
|
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
3
5
|
module ShopCircle
|
|
4
6
|
module Orbit
|
|
5
7
|
class Configuration
|
|
@@ -15,7 +17,9 @@ module ShopCircle
|
|
|
15
17
|
:request_timeout,
|
|
16
18
|
:logger,
|
|
17
19
|
:stub,
|
|
18
|
-
:on_error
|
|
20
|
+
:on_error,
|
|
21
|
+
:redact_pii,
|
|
22
|
+
:exclude_paths
|
|
19
23
|
|
|
20
24
|
def initialize
|
|
21
25
|
@client_id = nil
|
|
@@ -31,20 +35,55 @@ module ShopCircle
|
|
|
31
35
|
@logger = nil
|
|
32
36
|
@stub = false
|
|
33
37
|
@on_error = nil
|
|
38
|
+
@redact_pii = true
|
|
39
|
+
@exclude_paths = []
|
|
34
40
|
end
|
|
35
41
|
|
|
36
42
|
def validate!
|
|
37
|
-
raise
|
|
38
|
-
raise
|
|
39
|
-
raise
|
|
43
|
+
raise ConfigurationError, "client_id is required" if @client_id.nil? || @client_id.to_s.empty?
|
|
44
|
+
raise ConfigurationError, "api_url is required" if @api_url.nil? || @api_url.to_s.empty?
|
|
45
|
+
raise ConfigurationError, "device_id max 128 chars" if @device_id && @device_id.to_s.length > 128
|
|
46
|
+
|
|
47
|
+
validate_positive!(:flush_interval, @flush_interval)
|
|
48
|
+
validate_positive!(:max_batch_size, @max_batch_size)
|
|
49
|
+
validate_positive!(:max_queue_size, @max_queue_size)
|
|
50
|
+
validate_positive!(:request_timeout, @request_timeout)
|
|
51
|
+
validate_positive!(:base_retry_delay, @base_retry_delay)
|
|
52
|
+
raise ConfigurationError, "max_retries must be >= 0" if !@max_retries.is_a?(Numeric) || @max_retries < 0
|
|
53
|
+
|
|
54
|
+
uri = URI.parse(@api_url.to_s)
|
|
55
|
+
unless uri.scheme == "https" || ENV["ORBIT_ALLOW_HTTP"] == "1"
|
|
56
|
+
raise ConfigurationError, "api_url must use https:// in production. Set ORBIT_ALLOW_HTTP=1 for local development."
|
|
57
|
+
end
|
|
58
|
+
if uri.scheme != "https"
|
|
59
|
+
resolved_logger.warn("[shopcircle-orbit] WARNING: Sending data over plaintext HTTP. Do not use in production.")
|
|
60
|
+
end
|
|
40
61
|
end
|
|
41
62
|
|
|
42
63
|
def resolved_logger
|
|
43
64
|
@logger || default_logger
|
|
44
65
|
end
|
|
45
66
|
|
|
67
|
+
# Redact sensitive fields from inspect output to prevent
|
|
68
|
+
# accidental credential leakage in logs/debugging.
|
|
69
|
+
def inspect
|
|
70
|
+
redacted = instance_variables.each_with_object({}) do |var, hash|
|
|
71
|
+
val = instance_variable_get(var)
|
|
72
|
+
hash[var] = if var == :@client_secret && val
|
|
73
|
+
"[REDACTED]"
|
|
74
|
+
else
|
|
75
|
+
val
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
"#<#{self.class} #{redacted.map { |k, v| "#{k}=#{v.inspect}" }.join(', ')}>"
|
|
79
|
+
end
|
|
80
|
+
|
|
46
81
|
private
|
|
47
82
|
|
|
83
|
+
def validate_positive!(name, value)
|
|
84
|
+
raise ConfigurationError, "#{name} must be a positive number" if !value.is_a?(Numeric) || value <= 0
|
|
85
|
+
end
|
|
86
|
+
|
|
48
87
|
def default_logger
|
|
49
88
|
@default_logger ||= begin
|
|
50
89
|
require "logger"
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ShopCircle
|
|
4
|
+
module Orbit
|
|
5
|
+
# Base error for all SDK errors. Rescue this to catch everything.
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
|
|
8
|
+
# Raised when configuration is invalid (missing keys, bad URLs, etc.)
|
|
9
|
+
class ConfigurationError < Error; end
|
|
10
|
+
|
|
11
|
+
# Raised when input validation fails (event names, properties, etc.)
|
|
12
|
+
class ValidationError < Error; end
|
|
13
|
+
|
|
14
|
+
# Raised on 401 responses.
|
|
15
|
+
class AuthenticationError < Error; end
|
|
16
|
+
|
|
17
|
+
# Raised on 403 responses.
|
|
18
|
+
class AuthorizationError < Error; end
|
|
19
|
+
|
|
20
|
+
# Raised on 429 responses.
|
|
21
|
+
class RateLimitError < Error; end
|
|
22
|
+
|
|
23
|
+
# Raised on 4xx responses not covered above.
|
|
24
|
+
class ClientError < Error
|
|
25
|
+
attr_reader :status
|
|
26
|
+
|
|
27
|
+
def initialize(message, status: nil)
|
|
28
|
+
@status = status
|
|
29
|
+
super(message)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Raised on 5xx responses.
|
|
34
|
+
class APIError < Error
|
|
35
|
+
attr_reader :status
|
|
36
|
+
|
|
37
|
+
def initialize(message, status: nil)
|
|
38
|
+
@status = status
|
|
39
|
+
super(message)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Raised on network failures (connection refused, reset, etc.)
|
|
44
|
+
class ConnectionError < Error; end
|
|
45
|
+
|
|
46
|
+
# Raised on request timeouts.
|
|
47
|
+
class TimeoutError < ConnectionError; end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -20,22 +20,27 @@ module ShopCircle
|
|
|
20
20
|
|
|
21
21
|
def call(env)
|
|
22
22
|
status, headers, response = @app.call(env)
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
23
|
+
begin
|
|
24
|
+
if trackable?(env, status)
|
|
25
|
+
request = Rack::Request.new(env)
|
|
26
|
+
props = {
|
|
27
|
+
path: request.path,
|
|
28
|
+
method: request.request_method,
|
|
29
|
+
status: status
|
|
30
|
+
}
|
|
31
|
+
unless ShopCircle::Orbit.configuration.redact_pii
|
|
32
|
+
props[:user_agent] = request.user_agent
|
|
33
|
+
props[:ip] = request.ip
|
|
34
|
+
props[:referrer] = request.referrer
|
|
35
|
+
end
|
|
36
|
+
ShopCircle::Orbit.track("page_view", props)
|
|
37
|
+
end
|
|
38
|
+
rescue StandardError => e
|
|
39
|
+
ShopCircle::Orbit.configuration.resolved_logger.warn("[shopcircle-orbit] Tracking error: #{e.message}")
|
|
40
|
+
ensure
|
|
41
|
+
Thread.current[:shopcircle_orbit_profile_id] = nil
|
|
42
|
+
Thread.current[:shopcircle_orbit_device_id] = nil
|
|
34
43
|
end
|
|
35
|
-
|
|
36
|
-
[status, headers, response]
|
|
37
|
-
rescue StandardError
|
|
38
|
-
# Never break the request due to tracking errors
|
|
39
44
|
[status, headers, response]
|
|
40
45
|
end
|
|
41
46
|
|
|
@@ -48,6 +53,10 @@ module ShopCircle
|
|
|
48
53
|
path = env["PATH_INFO"].to_s
|
|
49
54
|
return false if SKIP_PREFIXES.any? { |p| path.start_with?(p) }
|
|
50
55
|
|
|
56
|
+
# User-configurable path exclusions
|
|
57
|
+
config_excludes = ShopCircle::Orbit.configuration.exclude_paths
|
|
58
|
+
return false if config_excludes.any? { |p| path.start_with?(p) }
|
|
59
|
+
|
|
51
60
|
# Skip XHR / fetch requests
|
|
52
61
|
return false if env["HTTP_X_REQUESTED_WITH"] == "XMLHttpRequest"
|
|
53
62
|
return false if env["HTTP_ACCEPT"]&.include?("application/json")
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "net/http"
|
|
4
4
|
require "uri"
|
|
5
5
|
require "json"
|
|
6
|
+
require "openssl"
|
|
6
7
|
|
|
7
8
|
module ShopCircle
|
|
8
9
|
module Orbit
|
|
@@ -12,6 +13,8 @@ module ShopCircle
|
|
|
12
13
|
def initialize(config)
|
|
13
14
|
@config = config
|
|
14
15
|
@api_uri = URI.parse(config.api_url.to_s.chomp("/"))
|
|
16
|
+
@http = nil
|
|
17
|
+
@mutex = Mutex.new
|
|
15
18
|
end
|
|
16
19
|
|
|
17
20
|
def send_single(event)
|
|
@@ -24,66 +27,116 @@ module ShopCircle
|
|
|
24
27
|
events: events.map(&:to_payload),
|
|
25
28
|
clientId: @config.client_id,
|
|
26
29
|
sdkName: SDK_NAME,
|
|
27
|
-
sdkVersion: ShopCircle::Orbit::VERSION
|
|
28
|
-
deviceId: events.first&.device_id
|
|
30
|
+
sdkVersion: ShopCircle::Orbit::VERSION
|
|
29
31
|
})
|
|
30
32
|
post("/api/track/batch", body)
|
|
31
33
|
end
|
|
32
34
|
|
|
35
|
+
def shutdown
|
|
36
|
+
@mutex.synchronize do
|
|
37
|
+
@http&.finish rescue nil
|
|
38
|
+
@http = nil
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
33
42
|
private
|
|
34
43
|
|
|
35
|
-
def
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
44
|
+
def ensure_connection
|
|
45
|
+
if @http.nil? || !@http.started?
|
|
46
|
+
@http = Net::HTTP.new(@api_uri.host, @api_uri.port)
|
|
47
|
+
@http.use_ssl = (@api_uri.scheme == "https")
|
|
48
|
+
@http.verify_mode = OpenSSL::SSL::VERIFY_PEER if @http.use_ssl?
|
|
49
|
+
@http.open_timeout = @config.request_timeout
|
|
50
|
+
@http.read_timeout = @config.request_timeout
|
|
51
|
+
@http.write_timeout = @config.request_timeout if @http.respond_to?(:write_timeout=)
|
|
52
|
+
@http.keep_alive_timeout = 30
|
|
53
|
+
@http.start
|
|
62
54
|
end
|
|
55
|
+
end
|
|
63
56
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
57
|
+
def close_connection
|
|
58
|
+
@http&.finish rescue nil
|
|
59
|
+
@http = nil
|
|
72
60
|
end
|
|
73
61
|
|
|
74
|
-
def
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
62
|
+
def post(path, body)
|
|
63
|
+
attempt = 0
|
|
64
|
+
|
|
65
|
+
@mutex.synchronize do
|
|
66
|
+
loop do
|
|
67
|
+
ensure_connection
|
|
68
|
+
|
|
69
|
+
request = Net::HTTP::Post.new(path)
|
|
70
|
+
request["Content-Type"] = "application/json"
|
|
71
|
+
request["shopcircle-client-id"] = @config.client_id
|
|
72
|
+
request["shopcircle-sdk-name"] = SDK_NAME
|
|
73
|
+
request["shopcircle-sdk-version"] = ShopCircle::Orbit::VERSION
|
|
74
|
+
request["Authorization"] = "Bearer #{@config.client_secret}" if @config.client_secret
|
|
75
|
+
request.body = body
|
|
76
|
+
|
|
77
|
+
response = @http.request(request)
|
|
78
|
+
status = response.code.to_i
|
|
79
|
+
|
|
80
|
+
# 2xx/3xx: success
|
|
81
|
+
return if status >= 200 && status < 400
|
|
82
|
+
|
|
83
|
+
# 4xx except 429: client error, don't retry
|
|
84
|
+
if status >= 400 && status < 500 && status != 429
|
|
85
|
+
log_error("HTTP #{status}", response.body)
|
|
86
|
+
return
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# 429 or 5xx: retryable
|
|
90
|
+
if attempt < @config.max_retries
|
|
91
|
+
delay = retry_delay(attempt, response)
|
|
92
|
+
sleep(delay)
|
|
93
|
+
attempt += 1
|
|
94
|
+
else
|
|
95
|
+
log_error("Failed after #{@config.max_retries + 1} attempts (status=#{status})", response.body)
|
|
96
|
+
return
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout => e
|
|
100
|
+
close_connection
|
|
101
|
+
if attempt < @config.max_retries
|
|
102
|
+
sleep(retry_delay(attempt))
|
|
103
|
+
attempt += 1
|
|
104
|
+
else
|
|
105
|
+
log_error("Timeout after #{@config.max_retries + 1} attempts", e.message)
|
|
106
|
+
return
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
rescue Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EPIPE,
|
|
110
|
+
SocketError, IOError => e
|
|
111
|
+
close_connection
|
|
112
|
+
if attempt < @config.max_retries
|
|
113
|
+
sleep(retry_delay(attempt))
|
|
114
|
+
attempt += 1
|
|
115
|
+
else
|
|
116
|
+
log_error("Connection error after #{@config.max_retries + 1} attempts", e.message)
|
|
117
|
+
return
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Exponential backoff with jitter.
|
|
124
|
+
# Respects Retry-After header when present (429 responses).
|
|
125
|
+
def retry_delay(attempt, response = nil)
|
|
126
|
+
if response
|
|
127
|
+
retry_after = response["Retry-After"]
|
|
128
|
+
return retry_after.to_f if retry_after && retry_after.match?(/\A\d+(\.\d+)?\z/)
|
|
81
129
|
end
|
|
130
|
+
|
|
131
|
+
base = @config.base_retry_delay * (2**attempt)
|
|
132
|
+
# Full jitter: random value between 50%-100% of base delay
|
|
133
|
+
base * (0.5 + rand * 0.5)
|
|
82
134
|
end
|
|
83
135
|
|
|
84
136
|
def log_error(msg, detail)
|
|
85
|
-
|
|
86
|
-
@config.
|
|
137
|
+
safe_detail = detail.to_s[0, 200]
|
|
138
|
+
@config.on_error&.call(msg, safe_detail)
|
|
139
|
+
@config.resolved_logger.warn("[shopcircle-orbit] #{msg}: #{safe_detail}")
|
|
87
140
|
rescue StandardError
|
|
88
141
|
# Never crash the worker thread due to logging/callback errors
|
|
89
142
|
end
|
|
@@ -12,23 +12,39 @@ module ShopCircle
|
|
|
12
12
|
|
|
13
13
|
class << self
|
|
14
14
|
def validate_event_name!(name)
|
|
15
|
-
raise
|
|
16
|
-
raise
|
|
17
|
-
raise
|
|
15
|
+
raise ValidationError, "event name is required" if name.nil? || name.to_s.strip.empty?
|
|
16
|
+
raise ValidationError, "event name max #{MAX_EVENT_NAME_LENGTH} chars" if name.to_s.length > MAX_EVENT_NAME_LENGTH
|
|
17
|
+
raise ValidationError, "event name '#{name}' is reserved" if RESERVED_NAMES.include?(name.to_s)
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
def validate_properties!(props)
|
|
21
21
|
return if props.nil? || props.empty?
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
if
|
|
25
|
-
raise
|
|
23
|
+
size = estimate_byte_size(props)
|
|
24
|
+
if size > MAX_PROPERTIES_BYTES
|
|
25
|
+
raise ValidationError, "properties too large (~#{size} bytes, max #{MAX_PROPERTIES_BYTES})"
|
|
26
26
|
end
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
def validate_device_id!(device_id)
|
|
30
30
|
return if device_id.nil?
|
|
31
|
-
raise
|
|
31
|
+
raise ValidationError, "device_id max #{MAX_DEVICE_ID_LENGTH} chars" if device_id.to_s.length > MAX_DEVICE_ID_LENGTH
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def estimate_byte_size(obj)
|
|
37
|
+
case obj
|
|
38
|
+
when Hash
|
|
39
|
+
2 + obj.sum { |k, v| k.to_s.bytesize + estimate_byte_size(v) + 4 }
|
|
40
|
+
when Array
|
|
41
|
+
2 + obj.sum { |v| estimate_byte_size(v) + 1 }
|
|
42
|
+
when String then obj.bytesize + 2
|
|
43
|
+
when Numeric then obj.to_s.bytesize
|
|
44
|
+
when TrueClass, FalseClass then obj.to_s.bytesize
|
|
45
|
+
when NilClass then 4
|
|
46
|
+
else obj.to_s.bytesize + 2
|
|
47
|
+
end
|
|
32
48
|
end
|
|
33
49
|
end
|
|
34
50
|
end
|
|
@@ -5,6 +5,9 @@ module ShopCircle
|
|
|
5
5
|
class Worker
|
|
6
6
|
attr_reader :transport
|
|
7
7
|
|
|
8
|
+
FLUSH_TIMEOUT = 5
|
|
9
|
+
MAX_CONSECUTIVE_ERRORS = 5
|
|
10
|
+
|
|
8
11
|
def initialize(queue:, config:, transport:)
|
|
9
12
|
@queue = queue
|
|
10
13
|
@config = config
|
|
@@ -13,6 +16,8 @@ module ShopCircle
|
|
|
13
16
|
@mutex = Mutex.new
|
|
14
17
|
@condvar = ConditionVariable.new
|
|
15
18
|
@running = false
|
|
19
|
+
@flush_waiters = []
|
|
20
|
+
@consecutive_errors = 0
|
|
16
21
|
end
|
|
17
22
|
|
|
18
23
|
def start
|
|
@@ -30,14 +35,30 @@ module ShopCircle
|
|
|
30
35
|
@running = false
|
|
31
36
|
@condvar.signal
|
|
32
37
|
end
|
|
33
|
-
flush_all
|
|
34
38
|
@thread&.join(5)
|
|
39
|
+
flush_all
|
|
40
|
+
notify_all_waiters
|
|
35
41
|
end
|
|
36
42
|
|
|
37
43
|
def flush
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
44
|
+
return unless running?
|
|
45
|
+
|
|
46
|
+
done = Queue.new
|
|
47
|
+
@mutex.synchronize do
|
|
48
|
+
@flush_waiters << done
|
|
49
|
+
@condvar.signal
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Poll with short sleeps instead of Timeout (which uses Thread.raise)
|
|
53
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + FLUSH_TIMEOUT
|
|
54
|
+
loop do
|
|
55
|
+
return done.pop(true) if !done.empty?
|
|
56
|
+
if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
57
|
+
@mutex.synchronize { @flush_waiters.delete(done) }
|
|
58
|
+
return nil
|
|
59
|
+
end
|
|
60
|
+
sleep(0.01)
|
|
61
|
+
end
|
|
41
62
|
end
|
|
42
63
|
|
|
43
64
|
private
|
|
@@ -48,9 +69,22 @@ module ShopCircle
|
|
|
48
69
|
@condvar.wait(@mutex, @config.flush_interval)
|
|
49
70
|
end
|
|
50
71
|
flush_all
|
|
72
|
+
@consecutive_errors = 0
|
|
73
|
+
notify_all_waiters
|
|
51
74
|
end
|
|
52
75
|
rescue StandardError => e
|
|
76
|
+
@consecutive_errors += 1
|
|
53
77
|
@config.resolved_logger.error("[shopcircle-orbit] Worker error: #{e.message}")
|
|
78
|
+
notify_all_waiters
|
|
79
|
+
|
|
80
|
+
if @consecutive_errors >= MAX_CONSECUTIVE_ERRORS
|
|
81
|
+
@config.resolved_logger.error("[shopcircle-orbit] Worker stopped after #{MAX_CONSECUTIVE_ERRORS} consecutive errors")
|
|
82
|
+
@mutex.synchronize { @running = false }
|
|
83
|
+
return
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
backoff = [2**@consecutive_errors, 30].min
|
|
87
|
+
sleep(backoff)
|
|
54
88
|
retry if running?
|
|
55
89
|
end
|
|
56
90
|
|
|
@@ -58,6 +92,15 @@ module ShopCircle
|
|
|
58
92
|
@mutex.synchronize { @running }
|
|
59
93
|
end
|
|
60
94
|
|
|
95
|
+
def notify_all_waiters
|
|
96
|
+
@mutex.synchronize do
|
|
97
|
+
@flush_waiters.each { |q| q.push(true) }
|
|
98
|
+
@flush_waiters.clear
|
|
99
|
+
end
|
|
100
|
+
rescue StandardError
|
|
101
|
+
# Never crash the worker thread due to notification errors
|
|
102
|
+
end
|
|
103
|
+
|
|
61
104
|
def flush_all
|
|
62
105
|
batch = drain_queue
|
|
63
106
|
return if batch.empty?
|
data/lib/shopcircle_orbit.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "shopcircle/orbit/version"
|
|
4
|
+
require_relative "shopcircle/orbit/errors"
|
|
4
5
|
require_relative "shopcircle/orbit/configuration"
|
|
5
6
|
require_relative "shopcircle/orbit/validation"
|
|
6
7
|
require_relative "shopcircle/orbit/event"
|
|
@@ -10,6 +11,8 @@ require_relative "shopcircle/orbit/client"
|
|
|
10
11
|
|
|
11
12
|
module ShopCircle
|
|
12
13
|
module Orbit
|
|
14
|
+
@client_mutex = Mutex.new
|
|
15
|
+
|
|
13
16
|
class << self
|
|
14
17
|
attr_writer :configuration
|
|
15
18
|
|
|
@@ -30,7 +33,11 @@ module ShopCircle
|
|
|
30
33
|
|
|
31
34
|
# Lazily initialized singleton client.
|
|
32
35
|
def client
|
|
33
|
-
@client
|
|
36
|
+
return @client if @client
|
|
37
|
+
|
|
38
|
+
@client_mutex.synchronize do
|
|
39
|
+
@client ||= Client.new(configuration)
|
|
40
|
+
end
|
|
34
41
|
end
|
|
35
42
|
|
|
36
43
|
# Track a custom event.
|
|
@@ -68,9 +75,11 @@ module ShopCircle
|
|
|
68
75
|
|
|
69
76
|
# Flush and stop the singleton client.
|
|
70
77
|
def shutdown!
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
78
|
+
@client_mutex.synchronize do
|
|
79
|
+
return unless @client
|
|
80
|
+
@client.shutdown!
|
|
81
|
+
@client = nil
|
|
82
|
+
end
|
|
74
83
|
end
|
|
75
84
|
end
|
|
76
85
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: shopcircle-orbit
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- ShopCircle
|
|
@@ -64,6 +64,7 @@ files:
|
|
|
64
64
|
- README.md
|
|
65
65
|
- lib/shopcircle/orbit/client.rb
|
|
66
66
|
- lib/shopcircle/orbit/configuration.rb
|
|
67
|
+
- lib/shopcircle/orbit/errors.rb
|
|
67
68
|
- lib/shopcircle/orbit/event.rb
|
|
68
69
|
- lib/shopcircle/orbit/rails/generators/install_generator.rb
|
|
69
70
|
- lib/shopcircle/orbit/rails/middleware.rb
|