shopcircle-orbit 1.0.0 → 1.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ea60f7805d0aa67d6d2748586dc5342bc88e12f2b4b61b4aab4c8cd1ca5519f1
4
- data.tar.gz: 87bf73def7abdfac9818392a21b80a161f67b5ecbcdd6c64df5dc37d4ebcd838
3
+ metadata.gz: 13eadce37b88d6b83df93f3df4d4e79bb02c4109ef9200b8b6343c0a6f1404a1
4
+ data.tar.gz: eb467f5a94400e3beaa199c9ff4bcf167a15d1834f2f37da639f92c4dc50665e
5
5
  SHA512:
6
- metadata.gz: '067937e4dc01ec32494a35cfdba85fb923a6c0c10e4730a03bdbf5c5368d38773d46af351c36b73cbee9efa8491df6eed787afe97e67de1008e5400d52defb23'
7
- data.tar.gz: 3379decabf5a9596d9a21fe7be34d30f7c0af5045d2e4917e5f12f165ebb27bcb1a8d00fa16b4d2bb9baaab9ad295d035346ce3cfdf51961ec2509fee51b0bd5
6
+ metadata.gz: 47d718f705390d6a1f204fa3fdf4a826b8bcaac1b4fdb346b733e624d2612bb69e4d21758044b5f66a2867d907a6a8d4628104dab3bd01422291a3b36212101b
7
+ data.tar.gz: 410eadea7c619138940cf1ae5692d1d31061613f29bc737f4542e0ab67dffac09b31da111f465d3ac8c69fd69b93cacec7f24d673924e97a5b6228a9c20ca82c
@@ -9,8 +9,7 @@ module ShopCircle
9
9
  @config = resolve_config(config, options)
10
10
  @config.validate!
11
11
 
12
- @profile_id = nil
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 ArgumentError, "profile_id is required" if profile_id.nil? || profile_id.to_s.strip.empty?
54
+ raise ValidationError, "profile_id is required" if profile_id.nil? || profile_id.to_s.strip.empty?
56
55
 
57
- @mutex.synchronize { @profile_id = profile_id.to_s }
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
- @mutex.synchronize { @profile_id = nil }
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
- @mutex.synchronize { @device_id = device_id&.to_s }
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
- return if @mutex.synchronize { @shutdown }
109
+ @mutex.synchronize do
110
+ return if @shutdown
109
111
 
110
- # Drop oldest if at capacity
111
- if @queue.size >= @config.max_queue_size
112
- @queue.pop(true) rescue nil
113
- end
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
- @queue.push(event)
117
+ @queue.push(event)
118
+ end
116
119
  end
117
120
 
118
121
  def current_profile_id
119
- @mutex.synchronize { @profile_id }
122
+ Thread.current[:shopcircle_orbit_profile_id]
120
123
  end
121
124
 
122
125
  def current_device_id
123
- @mutex.synchronize { @device_id }
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 ArgumentError, "Expected Configuration, Hash, or keyword arguments"
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
- c.public_send(setter, v) if c.respond_to?(setter)
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 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
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
- 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
- })
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,10 @@ 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
18
+ @ingest_token = nil
19
+ @ingest_token_expires_at = 0
15
20
  end
16
21
 
17
22
  def send_single(event)
@@ -24,66 +29,152 @@ module ShopCircle
24
29
  events: events.map(&:to_payload),
25
30
  clientId: @config.client_id,
26
31
  sdkName: SDK_NAME,
27
- sdkVersion: ShopCircle::Orbit::VERSION,
28
- deviceId: events.first&.device_id
32
+ sdkVersion: ShopCircle::Orbit::VERSION
29
33
  })
30
34
  post("/api/track/batch", body)
31
35
  end
32
36
 
37
+ def shutdown
38
+ @mutex.synchronize do
39
+ @http&.finish rescue nil
40
+ @http = nil
41
+ end
42
+ end
43
+
33
44
  private
34
45
 
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
46
+ def ensure_connection
47
+ if @http.nil? || !@http.started?
48
+ @http = Net::HTTP.new(@api_uri.host, @api_uri.port)
49
+ @http.use_ssl = (@api_uri.scheme == "https")
50
+ @http.verify_mode = OpenSSL::SSL::VERIFY_PEER if @http.use_ssl?
51
+ @http.open_timeout = @config.request_timeout
52
+ @http.read_timeout = @config.request_timeout
53
+ @http.write_timeout = @config.request_timeout if @http.respond_to?(:write_timeout=)
54
+ @http.keep_alive_timeout = 30
55
+ @http.start
62
56
  end
57
+ end
63
58
 
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
59
+ def close_connection
60
+ @http&.finish rescue nil
61
+ @http = nil
72
62
  end
73
63
 
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)
64
+ def post(path, body)
65
+ attempt = 0
66
+
67
+ @mutex.synchronize do
68
+ loop do
69
+ ensure_connection
70
+
71
+ request = Net::HTTP::Post.new(path)
72
+ request["Content-Type"] = "application/json"
73
+ request["shopcircle-client-id"] = @config.client_id
74
+ request["shopcircle-sdk-name"] = SDK_NAME
75
+ request["shopcircle-sdk-version"] = ShopCircle::Orbit::VERSION
76
+
77
+ if @config.client_secret
78
+ request["shopcircle-client-secret"] = @config.client_secret
79
+ end
80
+
81
+ # For public clients (no secret), fetch and attach an ingest token
82
+ if !@config.client_secret
83
+ token = fetch_ingest_token
84
+ request["shopcircle-ingest-token"] = token if token
85
+ end
86
+
87
+ request.body = body
88
+
89
+ response = @http.request(request)
90
+ status = response.code.to_i
91
+
92
+ # 2xx/3xx: success
93
+ return if status >= 200 && status < 400
94
+
95
+ # 4xx except 429: client error, don't retry
96
+ if status >= 400 && status < 500 && status != 429
97
+ log_error("HTTP #{status}", response.body)
98
+ return
99
+ end
100
+
101
+ # 429 or 5xx: retryable
102
+ if attempt < @config.max_retries
103
+ delay = retry_delay(attempt, response)
104
+ sleep(delay)
105
+ attempt += 1
106
+ else
107
+ log_error("Failed after #{@config.max_retries + 1} attempts (status=#{status})", response.body)
108
+ return
109
+ end
110
+
111
+ rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout => e
112
+ close_connection
113
+ if attempt < @config.max_retries
114
+ sleep(retry_delay(attempt))
115
+ attempt += 1
116
+ else
117
+ log_error("Timeout after #{@config.max_retries + 1} attempts", e.message)
118
+ return
119
+ end
120
+
121
+ rescue Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EPIPE,
122
+ SocketError, IOError => e
123
+ close_connection
124
+ if attempt < @config.max_retries
125
+ sleep(retry_delay(attempt))
126
+ attempt += 1
127
+ else
128
+ log_error("Connection error after #{@config.max_retries + 1} attempts", e.message)
129
+ return
130
+ end
131
+ end
132
+ end
133
+ end
134
+
135
+ # Fetch a short-lived ingest token for public clients.
136
+ # Tokens are cached and refreshed 60 seconds before expiry.
137
+ def fetch_ingest_token
138
+ now_ms = (Time.now.to_f * 1000).to_i
139
+ return @ingest_token if @ingest_token && now_ms < (@ingest_token_expires_at - 60_000)
140
+
141
+ ensure_connection
142
+ req = Net::HTTP::Post.new("/api/track/token")
143
+ req["Content-Type"] = "application/json"
144
+ req.body = JSON.generate({ clientId: @config.client_id })
145
+
146
+ resp = @http.request(req)
147
+ if resp.code.to_i == 200
148
+ data = JSON.parse(resp.body)
149
+ @ingest_token = data["token"]
150
+ @ingest_token_expires_at = data["expiresAt"].to_i
151
+ @ingest_token
79
152
  else
80
- log_error("Failed after #{@config.max_retries + 1} attempts (status=#{status})", message)
153
+ log_error("Failed to fetch ingest token (#{resp.code})", resp.body)
154
+ nil
81
155
  end
156
+ rescue StandardError => e
157
+ log_error("Ingest token fetch error", e.message)
158
+ nil
159
+ end
160
+
161
+ # Exponential backoff with jitter.
162
+ # Respects Retry-After header when present (429 responses).
163
+ def retry_delay(attempt, response = nil)
164
+ if response
165
+ retry_after = response["Retry-After"]
166
+ return retry_after.to_f if retry_after && retry_after.match?(/\A\d+(\.\d+)?\z/)
167
+ end
168
+
169
+ base = @config.base_retry_delay * (2**attempt)
170
+ # Full jitter: random value between 50%-100% of base delay
171
+ base * (0.5 + rand * 0.5)
82
172
  end
83
173
 
84
174
  def log_error(msg, detail)
85
- @config.on_error&.call(msg, detail)
86
- @config.resolved_logger.warn("[shopcircle-orbit] #{msg}: #{detail}")
175
+ safe_detail = detail.to_s[0, 200]
176
+ @config.on_error&.call(msg, safe_detail)
177
+ @config.resolved_logger.warn("[shopcircle-orbit] #{msg}: #{safe_detail}")
87
178
  rescue StandardError
88
179
  # Never crash the worker thread due to logging/callback errors
89
180
  end
@@ -12,23 +12,39 @@ module ShopCircle
12
12
 
13
13
  class << self
14
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)
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
- json = JSON.generate(props)
24
- if json.bytesize > MAX_PROPERTIES_BYTES
25
- raise ArgumentError, "properties too large (#{json.bytesize} bytes, max #{MAX_PROPERTIES_BYTES})"
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 ArgumentError, "device_id max #{MAX_DEVICE_ID_LENGTH} chars" if device_id.to_s.length > MAX_DEVICE_ID_LENGTH
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module ShopCircle
4
4
  module Orbit
5
- VERSION = "1.0.0"
5
+ VERSION = "1.2.0"
6
6
  end
7
7
  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
- @mutex.synchronize { @condvar.signal }
39
- # Give worker thread a moment to process
40
- sleep(0.05)
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?
@@ -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 ||= Client.new(configuration)
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
- return unless @client
72
- @client.shutdown!
73
- @client = nil
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.0.0
4
+ version: 1.2.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