klime 1.0.4 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 330590153e5a8620ea195eeac0af41c0eacbc5d4619f3cf22c05192fcaa071ee
4
- data.tar.gz: 54cf4a7e4afb4680dcd7386c056058184ac5fb0f46132f980074a115c1f6ac47
3
+ metadata.gz: 1b96d506df543d39a095e29ded2bd3a55060d49669b579da2bb14a19cc1ada4f
4
+ data.tar.gz: c8df6be4c7ec823f7e9a3576a56c007ed0317da16620a43a7f8ed5e8d9a542d1
5
5
  SHA512:
6
- metadata.gz: b89311ba42ebcc5cf409fbd331e2f77dfcf72455dc2c832a34c9392aaabe216949adedb90f2999cb4a88ae3ede702452db82074694db79a87b33dbe4c5c01a9d
7
- data.tar.gz: 448ef16a67fc2ce3cf81766bce565cffc60a2ca5cb91dab198cd8a81ca825b643ec5b9c044605e436f56cdbb98ff88a4f9763b507bda1f929b3b05ca101a198a
6
+ metadata.gz: 7134190cf34f38279d6af0207f8646e485237df9488bc8b324de4a00881dcf56b4c111a976a14b70fa2949efdbb0c044d8821f83daebee754410640a0bfbb44d
7
+ data.tar.gz: 2347c49df142c0b4fc4334baf6dea7ab25faffd575fb1a9ff51586853bddb3638dfe3f1e3b8435bb2d2aa231e8bfefbea43b39fcba980da0f6a4b96d882809b4
data/README.md CHANGED
@@ -56,6 +56,86 @@ client.group('org_456', user_id: 'user_123')
56
56
  client.shutdown
57
57
  ```
58
58
 
59
+ ## Installation Prompt
60
+
61
+ Copy and paste this prompt into Cursor, Copilot, or your favorite AI editor to integrate Klime:
62
+
63
+ ```
64
+ Integrate Klime for customer analytics. Klime tracks user activity to identify which customers are healthy vs at risk of churning.
65
+
66
+ ANALYTICS MODES (determine which applies):
67
+ - Companies & Teams: Your customers are companies with multiple team members (SaaS, enterprise tools)
68
+ → Use identify() + group() + track()
69
+ - Individual Customers: Your customers are individuals with private accounts (consumer apps, creator tools)
70
+ → Use identify() + track() only (no group() needed)
71
+
72
+ KEY CONCEPTS:
73
+ - Every track() call requires either user_id OR group_id (no anonymous events)
74
+ - Use group_id alone for org-level events (webhooks, cron jobs, system metrics)
75
+ - group() links a user to a company AND sets company traits (only for Companies & Teams mode)
76
+ - Order doesn't matter - events before identify/group still get attributed correctly
77
+
78
+ BEST PRACTICES:
79
+ - Initialize client ONCE at app startup (initializer or singleton)
80
+ - Store write key in KLIME_WRITE_KEY environment variable
81
+ - Call shutdown on process exit to flush remaining events (auto-registered via at_exit)
82
+
83
+ Add to Gemfile: gem 'klime'
84
+ Then run: bundle install
85
+
86
+ Or install directly: gem install klime
87
+
88
+ require 'klime'
89
+
90
+ client = Klime::Client.new(write_key: ENV['KLIME_WRITE_KEY'])
91
+
92
+ # Identify users at signup/login:
93
+ client.identify('usr_abc123', { email: 'jane@acme.com', name: 'Jane Smith' })
94
+
95
+ # Track key activities:
96
+ client.track('Report Generated', { report_type: 'revenue' }, user_id: 'usr_abc123')
97
+ client.track('Feature Used', { feature: 'export', format: 'csv' }, user_id: 'usr_abc123')
98
+ client.track('Teammate Invited', { role: 'member' }, user_id: 'usr_abc123')
99
+
100
+ # If Companies & Teams mode: link user to their company and set company traits
101
+ client.group('org_456', { name: 'Acme Inc', plan: 'enterprise' }, user_id: 'usr_abc123')
102
+
103
+ INTEGRATION WORKFLOW:
104
+
105
+ Phase 1: Discover
106
+ Explore the codebase to understand:
107
+ 1. What framework is used? (Rails, Sinatra, Hanami, Rack, etc.)
108
+ 2. Where is user identity available? (e.g., current_user.id, @current_user.id, session[:user_id], warden.user)
109
+ 3. Is this Companies & Teams or Individual Customers?
110
+ - Look for: organization, workspace, tenant, team, account models → Companies & Teams (use group())
111
+ - No company/org concept, just individual users → Individual Customers (skip group())
112
+ 4. Where do core user actions happen? (controllers, service objects, jobs, callbacks)
113
+ 5. Is there existing analytics? (search: segment, posthog, mixpanel, amplitude, track)
114
+ Match your integration style to the framework's conventions.
115
+
116
+ Phase 2: Instrument
117
+ Add these calls using idiomatic patterns for the framework:
118
+ - Initialize client once (Rails: config/initializers/klime.rb, Sinatra: before app.run, Rack: middleware)
119
+ - identify() in auth/login success handler
120
+ - group() when user-org association is established (Companies & Teams mode only)
121
+ - track() for key user actions (see below)
122
+
123
+ WHAT TO TRACK:
124
+ Active engagement (primary): feature usage, resource creation, collaboration, completing flows
125
+ Session signals (secondary): login/session start, dashboard access - distinguishes "low usage" from "churned"
126
+ Do NOT track: every request, health checks, before_action filters, background jobs
127
+
128
+ Phase 3: Verify
129
+ Confirm: client initialized, shutdown handled, identify/group/track calls added
130
+
131
+ Phase 4: Summarize
132
+ Report what you added:
133
+ - Files modified and what was added to each
134
+ - Events being tracked (list event names and what triggers them)
135
+ - How user_id is obtained (and group_id if Companies & Teams mode)
136
+ - Any assumptions made or questions
137
+ ```
138
+
59
139
  ## API Reference
60
140
 
61
141
  ### Constructor
@@ -69,32 +149,37 @@ Klime::Client.new(
69
149
  max_queue_size: nil, # Optional: Max queued events (default: 1000)
70
150
  retry_max_attempts: nil, # Optional: Max retry attempts (default: 5)
71
151
  retry_initial_delay: nil, # Optional: Initial retry delay in ms (default: 1000)
72
- flush_on_shutdown: nil # Optional: Auto-flush on exit (default: true)
152
+ flush_on_shutdown: nil, # Optional: Auto-flush on exit (default: true)
153
+ on_error: nil, # Optional: Callback for batch failures
154
+ on_success: nil # Optional: Callback for successful sends
73
155
  )
74
156
  ```
75
157
 
76
158
  ### Methods
77
159
 
78
- #### `track(event_name, properties = nil, user_id: nil, group_id: nil, ip: nil)`
160
+ #### `track(event_name, properties = nil, user_id: nil, group_id: nil)`
79
161
 
80
- Track a user event. A `user_id` is required for events to be useful in Klime.
162
+ Track an event. Events can be attributed in two ways:
163
+ - **User events**: Provide `user_id` to track user activity (most common)
164
+ - **Group events**: Provide `group_id` without `user_id` for organization-level events
81
165
 
82
166
  ```ruby
167
+ # User event (most common)
83
168
  client.track('Button Clicked', {
84
169
  button_name: 'Sign up',
85
170
  plan: 'pro'
86
171
  }, user_id: 'user_123')
87
172
 
88
- # With IP address (for geolocation)
89
- client.track('Button Clicked', {
90
- button_name: 'Sign up',
91
- plan: 'pro'
92
- }, user_id: 'user_123', ip: '192.168.1.1')
173
+ # Group event (for webhooks, cron jobs, system events)
174
+ client.track('Events Received', {
175
+ count: 100,
176
+ source: 'webhook'
177
+ }, group_id: 'org_456')
93
178
  ```
94
179
 
95
- > **Advanced**: The `group_id` parameter is available for multi-tenant scenarios where a user belongs to multiple organizations and you need to specify which organization context the event occurred in.
180
+ > **Note**: The `group_id` parameter can also be combined with `user_id` for multi-tenant scenarios where you need to specify which organization context a user event occurred in.
96
181
 
97
- #### `identify(user_id, traits = nil, ip: nil)`
182
+ #### `identify(user_id, traits = nil)`
98
183
 
99
184
  Identify a user with traits.
100
185
 
@@ -102,10 +187,10 @@ Identify a user with traits.
102
187
  client.identify('user_123', {
103
188
  email: 'user@example.com',
104
189
  name: 'Stefan'
105
- }, ip: '192.168.1.1')
190
+ })
106
191
  ```
107
192
 
108
- #### `group(group_id, traits = nil, user_id: nil, ip: nil)`
193
+ #### `group(group_id, traits = nil, user_id: nil)`
109
194
 
110
195
  Associate a user with a group and/or set group traits.
111
196
 
@@ -147,9 +232,33 @@ client.shutdown
147
232
  - **Automatic Batching**: Events are automatically batched and sent every 2 seconds or when the batch size reaches 20 events
148
233
  - **Automatic Retries**: Failed requests are automatically retried with exponential backoff
149
234
  - **Thread-Safe**: Safe to use from multiple threads
235
+ - **Fork-Safe**: Automatically detects Puma/Unicorn forks and restarts the worker thread
150
236
  - **Process Exit Handling**: Automatically flushes events on process exit (via `at_exit`)
151
237
  - **Zero Dependencies**: Uses only Ruby standard library
152
238
 
239
+ ## Performance
240
+
241
+ When you call `track()`, `identify()`, or `group()`, the SDK:
242
+
243
+ 1. Adds the event to an in-memory queue (microseconds)
244
+ 2. Returns immediately without waiting for network I/O
245
+
246
+ Events are sent to Klime's servers in a background thread. This means:
247
+
248
+ - **No network blocking**: HTTP requests happen asynchronously in a background thread
249
+ - **No latency impact**: Tracking calls add < 1ms to your request handling time
250
+ - **Automatic batching**: Events are queued and sent in batches (default: every 2 seconds or 20 events)
251
+
252
+ ```ruby
253
+ # This returns immediately - no HTTP request is made here
254
+ client.track('Button Clicked', { button: 'signup' }, user_id: 'user_123')
255
+
256
+ # Your code continues without waiting
257
+ render json: { success: true }
258
+ ```
259
+
260
+ The only blocking operation is `flush()`, which waits for all queued events to be sent. This is typically only called during graceful shutdown.
261
+
153
262
  ## Configuration
154
263
 
155
264
  ### Default Values
@@ -161,6 +270,28 @@ client.shutdown
161
270
  - `retry_initial_delay`: 1000ms
162
271
  - `flush_on_shutdown`: true
163
272
 
273
+ ### Logging
274
+
275
+ ```ruby
276
+ Klime.configure do |config|
277
+ config.logger = Rails.logger
278
+ end
279
+ ```
280
+
281
+ ### Callbacks
282
+
283
+ ```ruby
284
+ Klime.configure do |config|
285
+ config.on_error = Proc.new { |error, batch|
286
+ Sentry.capture_exception(error)
287
+ }
288
+
289
+ config.on_success = Proc.new { |response|
290
+ Rails.logger.info "Sent #{response.accepted} events"
291
+ }
292
+ end
293
+ ```
294
+
164
295
  ## Error Handling
165
296
 
166
297
  The SDK automatically handles:
@@ -169,6 +300,8 @@ The SDK automatically handles:
169
300
  - **Permanent errors** (400, 401): Logs error and drops event
170
301
  - **Rate limiting**: Respects `Retry-After` header
171
302
 
303
+ For synchronous operations, use bang methods (`track!`, `identify!`, `group!`) which raise `Klime::SendError` on failure.
304
+
172
305
  ## Size Limits
173
306
 
174
307
  - Maximum event size: 200KB
@@ -183,12 +316,12 @@ Events exceeding these limits are rejected and logged.
183
316
  # config/initializers/klime.rb
184
317
  require 'klime'
185
318
 
186
- KLIME = Klime::Client.new(
187
- write_key: ENV['KLIME_WRITE_KEY']
188
- )
319
+ Klime.configure do |config|
320
+ config.logger = Rails.logger
321
+ end
189
322
 
190
- # Ensure graceful shutdown
191
- at_exit { KLIME.shutdown }
323
+ Klime.client = Klime::Client.new(write_key: ENV['KLIME_WRITE_KEY'])
324
+ KLIME = Klime.client
192
325
  ```
193
326
 
194
327
  ```ruby
@@ -197,13 +330,21 @@ class ButtonsController < ApplicationController
197
330
  def click
198
331
  KLIME.track('Button Clicked', {
199
332
  button_name: params[:button_name]
200
- }, user_id: current_user&.id&.to_s, ip: request.remote_ip)
333
+ }, user_id: current_user&.id&.to_s)
201
334
 
202
335
  render json: { success: true }
203
336
  end
204
337
  end
205
338
  ```
206
339
 
340
+ For Puma with multiple workers, add to `config/puma.rb`:
341
+
342
+ ```ruby
343
+ on_worker_boot do
344
+ Klime.restart!
345
+ end
346
+ ```
347
+
207
348
  ## Sinatra Example
208
349
 
209
350
  ```ruby
@@ -217,7 +358,7 @@ post '/api/button-clicked' do
217
358
 
218
359
  client.track('Button Clicked', {
219
360
  button_name: data['buttonName']
220
- }, user_id: data['userId'], ip: request.ip)
361
+ }, user_id: data['userId'])
221
362
 
222
363
  { success: true }.to_json
223
364
  end
@@ -226,35 +367,6 @@ end
226
367
  at_exit { client.shutdown }
227
368
  ```
228
369
 
229
- ## Rack Middleware Example
230
-
231
- ```ruby
232
- # lib/klime_middleware.rb
233
- require 'klime'
234
-
235
- class KlimeMiddleware
236
- def initialize(app, write_key:)
237
- @app = app
238
- @client = Klime::Client.new(write_key: write_key)
239
- end
240
-
241
- def call(env)
242
- status, headers, response = @app.call(env)
243
-
244
- # Track page views for authenticated users
245
- user_id = env['rack.session']&.dig('user_id')
246
- if user_id && env['REQUEST_METHOD'] == 'GET' && status == 200
247
- @client.track('Page View', {
248
- path: env['PATH_INFO'],
249
- method: env['REQUEST_METHOD']
250
- }, user_id: user_id, ip: env['REMOTE_ADDR'])
251
- end
252
-
253
- [status, headers, response]
254
- end
255
- end
256
- ```
257
-
258
370
  ## Requirements
259
371
 
260
372
  - Ruby 2.6 or higher
data/lib/klime/client.rb CHANGED
@@ -10,9 +10,14 @@ module Klime
10
10
  #
11
11
  # @example Basic usage
12
12
  # client = Klime::Client.new(write_key: 'your-write-key')
13
- # client.track('Button Clicked', { button_name: 'Sign up' })
13
+ # client.track('Button Clicked', { button_name: 'Sign up' }, user_id: 'user_123')
14
14
  # client.shutdown
15
+ #
16
+ # @example Synchronous usage for debugging
17
+ # client.track!('Button Clicked', { button_name: 'Sign up' }, user_id: 'user_123')
15
18
  class Client
19
+ include Logging
20
+
16
21
  DEFAULT_ENDPOINT = "https://i.klime.com"
17
22
  DEFAULT_FLUSH_INTERVAL = 2000 # milliseconds
18
23
  DEFAULT_MAX_BATCH_SIZE = 20
@@ -33,6 +38,8 @@ module Klime
33
38
  # @param retry_max_attempts [Integer] Maximum retry attempts (default: 5)
34
39
  # @param retry_initial_delay [Integer] Initial retry delay in milliseconds (default: 1000)
35
40
  # @param flush_on_shutdown [Boolean] Auto-flush on exit (default: true)
41
+ # @param on_error [Proc] Callback for batch send failures (overrides global config)
42
+ # @param on_success [Proc] Callback for successful batch sends (overrides global config)
36
43
  def initialize(
37
44
  write_key:,
38
45
  endpoint: nil,
@@ -41,7 +48,9 @@ module Klime
41
48
  max_queue_size: nil,
42
49
  retry_max_attempts: nil,
43
50
  retry_initial_delay: nil,
44
- flush_on_shutdown: nil
51
+ flush_on_shutdown: nil,
52
+ on_error: nil,
53
+ on_success: nil
45
54
  )
46
55
  raise ConfigurationError, "write_key is required" if write_key.nil? || write_key.empty?
47
56
 
@@ -54,32 +63,33 @@ module Klime
54
63
  @retry_initial_delay = retry_initial_delay || DEFAULT_RETRY_INITIAL_DELAY
55
64
  @flush_on_shutdown = flush_on_shutdown.nil? ? true : flush_on_shutdown
56
65
 
66
+ # Callbacks - client-level overrides global config
67
+ @on_error = on_error
68
+ @on_success = on_success
69
+
57
70
  @queue = Queue.new
58
- @mutex = Mutex.new
71
+ @worker_mutex = Mutex.new
72
+ @worker_thread = nil
73
+ @pid = Process.pid
59
74
  @shutdown = false
60
- @flush_in_progress = false
61
- @flush_thread = nil
75
+
76
+ logger.debug "Client initialized (endpoint: #{@endpoint}, flush_interval: #{@flush_interval}ms, pid: #{@pid})"
62
77
 
63
78
  setup_shutdown_hook if @flush_on_shutdown
64
- schedule_flush
65
79
  end
66
80
 
67
- # Track a user event
81
+ # Track a user event (async)
68
82
  #
69
83
  # @param event_name [String] Name of the event
70
84
  # @param properties [Hash] Event properties (optional)
71
85
  # @param user_id [String] User ID (optional)
72
86
  # @param group_id [String] Group ID (optional)
73
- # @param ip [String] IP address (optional)
74
- # @return [void]
75
- #
76
- # @example Simple usage
77
- # client.track('Button Clicked', { button_name: 'Sign up' })
87
+ # @return [self] Returns self for method chaining
78
88
  #
79
- # @example With user context
80
- # client.track('Button Clicked', { button_name: 'Sign up' }, user_id: 'user_123', group_id: 'org_456')
81
- def track(event_name, properties = nil, user_id: nil, group_id: nil, ip: nil)
82
- return if @shutdown
89
+ # @example
90
+ # client.track('Button Clicked', { button_name: 'Sign up' }, user_id: 'user_123')
91
+ def track(event_name, properties = nil, user_id: nil, group_id: nil)
92
+ return self if @shutdown
83
93
 
84
94
  event = Event.new(
85
95
  type: EventType::TRACK,
@@ -87,80 +97,141 @@ module Klime
87
97
  properties: properties || {},
88
98
  user_id: user_id,
89
99
  group_id: group_id,
90
- context: build_context(ip)
100
+ context: build_context
91
101
  )
92
102
 
93
103
  enqueue(event)
104
+ self
105
+ end
106
+
107
+ # Track a user event (sync) - blocks until sent, raises on error
108
+ #
109
+ # @param event_name [String] Name of the event
110
+ # @param properties [Hash] Event properties (optional)
111
+ # @param user_id [String] User ID (optional)
112
+ # @param group_id [String] Group ID (optional)
113
+ # @return [BatchResponse] The response from the server
114
+ # @raise [SendError] If the event fails to send
115
+ #
116
+ # @example
117
+ # client.track!('Button Clicked', { button_name: 'Sign up' }, user_id: 'user_123')
118
+ def track!(event_name, properties = nil, user_id: nil, group_id: nil)
119
+ raise SendError.new("Client is shutdown") if @shutdown
120
+
121
+ event = Event.new(
122
+ type: EventType::TRACK,
123
+ event: event_name,
124
+ properties: properties || {},
125
+ user_id: user_id,
126
+ group_id: group_id,
127
+ context: build_context
128
+ )
129
+
130
+ send_sync([event])
94
131
  end
95
132
 
96
- # Identify a user with traits
133
+ # Identify a user with traits (async)
97
134
  #
98
135
  # @param user_id [String] User ID (required)
99
136
  # @param traits [Hash] User traits (optional)
100
- # @param ip [String] IP address (optional)
101
- # @return [void]
137
+ # @return [self] Returns self for method chaining
102
138
  #
103
139
  # @example
104
140
  # client.identify('user_123', { email: 'user@example.com', name: 'Stefan' })
105
- def identify(user_id, traits = nil, ip: nil)
106
- return if @shutdown
141
+ def identify(user_id, traits = nil)
142
+ return self if @shutdown
107
143
 
108
144
  event = Event.new(
109
145
  type: EventType::IDENTIFY,
110
146
  user_id: user_id,
111
147
  traits: traits || {},
112
- context: build_context(ip)
148
+ context: build_context
113
149
  )
114
150
 
115
151
  enqueue(event)
152
+ self
116
153
  end
117
154
 
118
- # Associate a user with a group and set group traits
155
+ # Identify a user with traits (sync) - blocks until sent, raises on error
156
+ #
157
+ # @param user_id [String] User ID (required)
158
+ # @param traits [Hash] User traits (optional)
159
+ # @return [BatchResponse] The response from the server
160
+ # @raise [SendError] If the event fails to send
161
+ def identify!(user_id, traits = nil)
162
+ raise SendError.new("Client is shutdown") if @shutdown
163
+
164
+ event = Event.new(
165
+ type: EventType::IDENTIFY,
166
+ user_id: user_id,
167
+ traits: traits || {},
168
+ context: build_context
169
+ )
170
+
171
+ send_sync([event])
172
+ end
173
+
174
+ # Associate a user with a group and set group traits (async)
119
175
  #
120
176
  # @param group_id [String] Group ID (required)
121
177
  # @param traits [Hash] Group traits (optional)
122
178
  # @param user_id [String] User ID (optional)
123
- # @param ip [String] IP address (optional)
124
- # @return [void]
125
- #
126
- # @example Simple usage
127
- # client.group('org_456', { name: 'Acme Inc', plan: 'enterprise' })
179
+ # @return [self] Returns self for method chaining
128
180
  #
129
- # @example With user ID
181
+ # @example
130
182
  # client.group('org_456', { name: 'Acme Inc' }, user_id: 'user_123')
131
- def group(group_id, traits = nil, user_id: nil, ip: nil)
132
- return if @shutdown
183
+ def group(group_id, traits = nil, user_id: nil)
184
+ return self if @shutdown
133
185
 
134
186
  event = Event.new(
135
187
  type: EventType::GROUP,
136
188
  group_id: group_id,
137
189
  user_id: user_id,
138
190
  traits: traits || {},
139
- context: build_context(ip)
191
+ context: build_context
140
192
  )
141
193
 
142
194
  enqueue(event)
195
+ self
196
+ end
197
+
198
+ # Associate a user with a group (sync) - blocks until sent, raises on error
199
+ #
200
+ # @param group_id [String] Group ID (required)
201
+ # @param traits [Hash] Group traits (optional)
202
+ # @param user_id [String] User ID (optional)
203
+ # @return [BatchResponse] The response from the server
204
+ # @raise [SendError] If the event fails to send
205
+ def group!(group_id, traits = nil, user_id: nil)
206
+ raise SendError.new("Client is shutdown") if @shutdown
207
+
208
+ event = Event.new(
209
+ type: EventType::GROUP,
210
+ group_id: group_id,
211
+ user_id: user_id,
212
+ traits: traits || {},
213
+ context: build_context
214
+ )
215
+
216
+ send_sync([event])
143
217
  end
144
218
 
145
219
  # Manually flush queued events immediately
220
+ # Blocks until all queued events are sent
146
221
  #
147
- # @return [void]
222
+ # @return [self] Returns self for method chaining
148
223
  def flush
149
- return if @shutdown
224
+ return self if @shutdown
150
225
 
151
- @mutex.synchronize do
152
- return if @flush_in_progress
226
+ # Process all batches synchronously
227
+ loop do
228
+ batch = extract_batch
229
+ break if batch.empty?
153
230
 
154
- @flush_in_progress = true
231
+ send_batch(batch)
155
232
  end
156
233
 
157
- begin
158
- do_flush
159
- ensure
160
- @mutex.synchronize do
161
- @flush_in_progress = false
162
- end
163
- end
234
+ self
164
235
  end
165
236
 
166
237
  # Gracefully shutdown the client, flushing remaining events
@@ -169,67 +240,149 @@ module Klime
169
240
  def shutdown
170
241
  return if @shutdown
171
242
 
243
+ logger.info "Shutting down..."
172
244
  @shutdown = true
173
245
 
174
- # Cancel scheduled flush
175
- @flush_thread&.kill
246
+ # Signal worker thread to exit
247
+ @worker_thread[:should_exit] = true if @worker_thread&.alive?
176
248
 
177
- # Force flush remaining events (bypass normal flush which checks @shutdown)
249
+ # Flush remaining events synchronously
178
250
  flush_remaining
251
+
252
+ # Wait for worker thread to finish
253
+ @worker_thread&.join(5) # Wait up to 5 seconds
254
+
255
+ logger.info "Shutdown complete"
256
+ end
257
+
258
+ # Returns the current queue size (useful for debugging)
259
+ # @return [Integer]
260
+ def queue_size
261
+ @queue.size
262
+ end
263
+
264
+ # Restart the background worker thread
265
+ # Call this in Puma's on_worker_boot or after a fork
266
+ #
267
+ # @return [void]
268
+ def restart!
269
+ @worker_mutex.synchronize do
270
+ logger.info "Restarting worker (pid: #{Process.pid})"
271
+
272
+ # Update PID
273
+ @pid = Process.pid
274
+
275
+ # Create fresh queue (old one is from parent process)
276
+ @queue = Queue.new
277
+
278
+ # Clear old thread reference
279
+ @worker_thread = nil
280
+
281
+ # Reset shutdown flag
282
+ @shutdown = false
283
+ end
179
284
  end
180
285
 
181
286
  private
182
287
 
183
288
  def setup_shutdown_hook
184
- at_exit { shutdown }
289
+ at_exit do
290
+ @worker_thread[:should_exit] = true if @worker_thread&.alive?
291
+ shutdown
292
+ end
185
293
  end
186
294
 
187
- def schedule_flush
188
- return if @shutdown
295
+ # Check if we've forked and need to reinitialize
296
+ def check_for_fork!
297
+ return if @pid == Process.pid
298
+
299
+ logger.info "Fork detected (was: #{@pid}, now: #{Process.pid}), reinitializing"
300
+ restart!
301
+ end
302
+
303
+ # Ensure the background worker thread is running
304
+ # Called before every enqueue to handle thread death or fork
305
+ def ensure_worker_running
306
+ check_for_fork!
307
+
308
+ return if worker_running?
309
+
310
+ @worker_mutex.synchronize do
311
+ return if worker_running?
312
+
313
+ logger.debug "Starting background worker thread"
314
+ @worker_thread = Thread.new { run_worker }
315
+ end
316
+ end
317
+
318
+ def worker_running?
319
+ @worker_thread&.alive?
320
+ end
189
321
 
190
- @flush_thread = Thread.new do
322
+ # Background worker loop - runs continuously, flushing at intervals
323
+ def run_worker
324
+ until Thread.current[:should_exit]
191
325
  sleep(@flush_interval / 1000.0)
192
- flush unless @shutdown
193
- schedule_flush unless @shutdown
326
+
327
+ next if @queue.empty? || @shutdown
328
+
329
+ begin
330
+ batch = extract_batch
331
+ send_batch(batch) unless batch.empty?
332
+ rescue StandardError => e
333
+ logger.error "Worker error: #{e.message}"
334
+ end
194
335
  end
336
+
337
+ logger.debug "Worker thread exiting"
195
338
  end
196
339
 
197
340
  def enqueue(event)
198
341
  # Check event size
199
342
  event_size = estimate_event_size(event)
200
343
  if event_size > MAX_EVENT_SIZE_BYTES
201
- warn "Klime: Event size (#{event_size} bytes) exceeds #{MAX_EVENT_SIZE_BYTES} bytes limit"
202
- return
344
+ logger.warn "Event rejected: size (#{event_size} bytes) exceeds #{MAX_EVENT_SIZE_BYTES} bytes limit"
345
+ return false
203
346
  end
204
347
 
205
348
  # Drop oldest if queue is full
206
- @queue.pop(true) if @queue.size >= @max_queue_size rescue nil
349
+ if @queue.size >= @max_queue_size
350
+ begin
351
+ @queue.pop(true)
352
+ logger.warn "Queue full (#{@max_queue_size}), dropped oldest event"
353
+ rescue ThreadError
354
+ # Queue was empty, ignore
355
+ end
356
+ end
207
357
 
208
358
  @queue.push(event)
359
+ logger.debug "Event enqueued: #{event.type} (queue_size: #{@queue.size})"
209
360
 
210
- # Check if we should flush immediately
211
- flush if @queue.size >= @max_batch_size
212
- end
213
-
214
- def do_flush
215
- # Cancel any scheduled flush thread
216
- @flush_thread&.kill
361
+ # Ensure worker is running (lazy start, fork recovery)
362
+ ensure_worker_running
217
363
 
218
- # Process batches
219
- flush_remaining
364
+ # Check if we should flush immediately (batch size reached)
365
+ if @queue.size >= @max_batch_size
366
+ logger.debug "Batch size reached (#{@max_batch_size}), triggering immediate flush"
367
+ # Don't call flush directly - let worker handle it or extract batch here
368
+ batch = extract_batch
369
+ send_batch(batch) unless batch.empty?
370
+ end
220
371
 
221
- # Schedule next flush
222
- schedule_flush unless @shutdown
372
+ true
223
373
  end
224
374
 
225
- # Flush all queued events without scheduling (used by shutdown)
375
+ # Flush all queued events without worker (used by shutdown)
226
376
  def flush_remaining
377
+ total_sent = 0
227
378
  loop do
228
379
  batch = extract_batch
229
380
  break if batch.empty?
230
381
 
231
382
  send_batch(batch)
383
+ total_sent += batch.size
232
384
  end
385
+ logger.debug "Flush complete (sent: #{total_sent} events)" if total_sent > 0
233
386
  end
234
387
 
235
388
  def extract_batch
@@ -269,6 +422,8 @@ module Klime
269
422
  attempt = 0
270
423
  delay = @retry_initial_delay / 1000.0 # Convert to seconds
271
424
 
425
+ logger.debug "Sending batch (size: #{batch.size}, bytes: #{request_body.bytesize})"
426
+
272
427
  while attempt < @retry_max_attempts
273
428
  begin
274
429
  response = make_request(uri, request_body)
@@ -284,13 +439,23 @@ module Klime
284
439
  )
285
440
 
286
441
  if batch_response.failed > 0 && batch_response.errors
287
- warn "Klime: Batch partially failed. Accepted: #{batch_response.accepted}, Failed: #{batch_response.failed}"
442
+ logger.warn "Batch partially failed (accepted: #{batch_response.accepted}, failed: #{batch_response.failed})"
443
+ batch_response.errors.each do |err|
444
+ logger.warn " Event #{err.index}: #{err.message} (#{err.code})"
445
+ end
446
+ else
447
+ logger.debug "Batch sent successfully (accepted: #{batch_response.accepted})"
288
448
  end
449
+
450
+ # Invoke success callback
451
+ invoke_on_success(batch_response)
289
452
  return
290
453
 
291
454
  when Net::HTTPBadRequest, Net::HTTPUnauthorized
292
455
  data = JSON.parse(response.body) rescue {}
293
- warn "Klime: Permanent error (#{response.code}): #{data}"
456
+ error_msg = "Permanent error (#{response.code}): #{data}"
457
+ logger.error error_msg
458
+ invoke_on_error(SendError.new(error_msg, status_code: response.code.to_i, events: batch), batch)
294
459
  return
295
460
 
296
461
  when Net::HTTPTooManyRequests, Net::HTTPServiceUnavailable
@@ -299,6 +464,7 @@ module Klime
299
464
 
300
465
  attempt += 1
301
466
  if attempt < @retry_max_attempts
467
+ logger.warn "Rate limited (#{response.code}), retrying in #{delay}s (attempt #{attempt}/#{@retry_max_attempts})"
302
468
  sleep(delay)
303
469
  delay = [delay * 2, 16.0].min
304
470
  next
@@ -308,6 +474,7 @@ module Klime
308
474
  # Other errors - retry
309
475
  attempt += 1
310
476
  if attempt < @retry_max_attempts
477
+ logger.warn "Request failed (#{response.code}), retrying in #{delay}s (attempt #{attempt}/#{@retry_max_attempts})"
311
478
  sleep(delay)
312
479
  delay = [delay * 2, 16.0].min
313
480
  end
@@ -317,13 +484,58 @@ module Klime
317
484
  # Network errors - retry
318
485
  attempt += 1
319
486
  if attempt < @retry_max_attempts
487
+ logger.warn "Network error: #{e.message}, retrying in #{delay}s (attempt #{attempt}/#{@retry_max_attempts})"
320
488
  sleep(delay)
321
489
  delay = [delay * 2, 16.0].min
322
490
  else
323
- warn "Klime: Failed to send batch after retries: #{e.message}"
491
+ error_msg = "Failed to send batch after #{@retry_max_attempts} attempts: #{e.message}"
492
+ logger.error error_msg
493
+ invoke_on_error(SendError.new(error_msg, events: batch), batch)
324
494
  end
325
495
  end
326
496
  end
497
+
498
+ # If we exhausted retries without returning
499
+ error_msg = "Failed to send batch after #{@retry_max_attempts} attempts"
500
+ logger.error error_msg
501
+ invoke_on_error(SendError.new(error_msg, events: batch), batch)
502
+ end
503
+
504
+ # Synchronous send for bang methods
505
+ def send_sync(events)
506
+ request_body = { batch: events.map(&:to_h) }.to_json
507
+ uri = URI.parse("#{@endpoint}/v1/batch")
508
+
509
+ logger.debug "Sending sync (size: #{events.size})"
510
+
511
+ response = make_request(uri, request_body)
512
+
513
+ case response
514
+ when Net::HTTPSuccess
515
+ data = JSON.parse(response.body)
516
+ batch_response = BatchResponse.new(
517
+ status: data["status"] || "ok",
518
+ accepted: data["accepted"] || 0,
519
+ failed: data["failed"] || 0,
520
+ errors: parse_errors(data["errors"])
521
+ )
522
+
523
+ logger.debug "Sync send successful (accepted: #{batch_response.accepted})"
524
+ batch_response
525
+
526
+ when Net::HTTPBadRequest, Net::HTTPUnauthorized
527
+ data = JSON.parse(response.body) rescue {}
528
+ raise SendError.new("Request failed (#{response.code}): #{data}", status_code: response.code.to_i, events: events)
529
+
530
+ when Net::HTTPTooManyRequests
531
+ raise SendError.new("Rate limited (#{response.code})", status_code: response.code.to_i, events: events)
532
+
533
+ else
534
+ raise SendError.new("Request failed (#{response.code})", status_code: response.code.to_i, events: events)
535
+ end
536
+ rescue StandardError => e
537
+ raise e if e.is_a?(SendError)
538
+ raise SendError.new("Network error: #{e.message}", events: events)
327
539
  end
328
540
 
329
541
  def make_request(uri, body)
@@ -340,12 +552,10 @@ module Klime
340
552
  http.request(request)
341
553
  end
342
554
 
343
- def build_context(ip = nil)
344
- context = EventContext.new(
555
+ def build_context
556
+ EventContext.new(
345
557
  library: LibraryInfo.new(name: "ruby-sdk", version: VERSION)
346
558
  )
347
- context.ip = ip if ip
348
- context
349
559
  end
350
560
 
351
561
  def estimate_event_size(event)
@@ -365,6 +575,19 @@ module Klime
365
575
  )
366
576
  end
367
577
  end
578
+
579
+ def invoke_on_error(error, batch)
580
+ callback = @on_error || Klime.configuration.on_error
581
+ callback&.call(error, batch)
582
+ rescue StandardError => e
583
+ logger.error "on_error callback raised: #{e.message}"
584
+ end
585
+
586
+ def invoke_on_success(response)
587
+ callback = @on_success || Klime.configuration.on_success
588
+ callback&.call(response)
589
+ rescue StandardError => e
590
+ logger.error "on_success callback raised: #{e.message}"
591
+ end
368
592
  end
369
593
  end
370
-
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Klime
4
+ # Configuration options for the Klime SDK
5
+ #
6
+ # @example Configure via block
7
+ # Klime.configure do |config|
8
+ # config.logger = Rails.logger
9
+ # config.on_error = ->(error, batch) { Sentry.capture_exception(error) }
10
+ # end
11
+ #
12
+ # @example Direct assignment
13
+ # Klime.configuration.logger = Logger.new($stdout)
14
+ class Configuration
15
+ # Callback invoked when a batch fails to send after all retries.
16
+ # Receives the error and the batch of events that failed.
17
+ # @return [Proc, nil]
18
+ # @example
19
+ # config.on_error = ->(error, batch) { puts "Failed: #{error.message}" }
20
+ attr_accessor :on_error
21
+
22
+ # Callback invoked when a batch is successfully sent.
23
+ # Receives the batch response with accepted/failed counts.
24
+ # @return [Proc, nil]
25
+ # @example
26
+ # config.on_success = ->(response) { puts "Sent #{response.accepted} events" }
27
+ attr_accessor :on_success
28
+
29
+ # Logger instance setter
30
+ # @return [Logger]
31
+ attr_writer :logger
32
+
33
+ def initialize
34
+ @logger = nil
35
+ @on_error = nil
36
+ @on_success = nil
37
+ end
38
+
39
+ # Returns the configured logger, or creates a default one.
40
+ # Auto-detects Rails.logger if available.
41
+ # @return [Logger]
42
+ def logger
43
+ @logger ||= default_logger
44
+ end
45
+
46
+ private
47
+
48
+ def default_logger
49
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
50
+ Rails.logger
51
+ else
52
+ require "logger"
53
+ new_logger = Logger.new($stdout)
54
+ new_logger.progname = "Klime"
55
+ new_logger.level = Logger::INFO
56
+ new_logger
57
+ end
58
+ end
59
+ end
60
+ end
data/lib/klime/event.rb CHANGED
@@ -32,17 +32,14 @@ module Klime
32
32
  # Context information for events
33
33
  class EventContext
34
34
  attr_reader :library
35
- attr_accessor :ip
36
35
 
37
- def initialize(library: nil, ip: nil)
36
+ def initialize(library: nil)
38
37
  @library = library
39
- @ip = ip
40
38
  end
41
39
 
42
40
  def to_h
43
41
  result = {}
44
42
  result[:library] = @library.to_h if @library
45
- result[:ip] = @ip if @ip
46
43
  result
47
44
  end
48
45
  end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Klime
4
+ # Provides logging functionality to SDK classes.
5
+ # Wraps the configured logger with a [Klime] prefix.
6
+ #
7
+ # @example Include in a class
8
+ # class MyClass
9
+ # include Klime::Logging
10
+ #
11
+ # def do_something
12
+ # logger.debug "Doing something"
13
+ # end
14
+ # end
15
+ module Logging
16
+ # Wrapper that prefixes all log messages with [Klime]
17
+ class PrefixedLogger
18
+ PREFIX = "[Klime]"
19
+
20
+ def initialize(logger)
21
+ @logger = logger
22
+ end
23
+
24
+ def debug(msg)
25
+ @logger.debug("#{PREFIX} #{msg}")
26
+ end
27
+
28
+ def info(msg)
29
+ @logger.info("#{PREFIX} #{msg}")
30
+ end
31
+
32
+ def warn(msg)
33
+ @logger.warn("#{PREFIX} #{msg}")
34
+ end
35
+
36
+ def error(msg)
37
+ @logger.error("#{PREFIX} #{msg}")
38
+ end
39
+
40
+ # Allow setting log level on the underlying logger
41
+ def level=(level)
42
+ @logger.level = level if @logger.respond_to?(:level=)
43
+ end
44
+
45
+ def level
46
+ @logger.level if @logger.respond_to?(:level)
47
+ end
48
+ end
49
+
50
+ def self.included(base)
51
+ base.extend(ClassMethods)
52
+ end
53
+
54
+ module ClassMethods
55
+ def logger
56
+ Klime.logger
57
+ end
58
+ end
59
+
60
+ def logger
61
+ Klime.logger
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Klime
4
+ # Rack middleware that ensures events are flushed after each request.
5
+ #
6
+ # This is useful when you need guaranteed delivery of events within the
7
+ # request lifecycle, rather than relying on background flushing.
8
+ #
9
+ # @example Rails application.rb
10
+ # config.middleware.use Klime::Middleware, client: KLIME
11
+ #
12
+ # @example Rails with auto-detection
13
+ # # If you set Klime.client, it will be used automatically
14
+ # Klime.client = KLIME
15
+ # config.middleware.use Klime::Middleware
16
+ #
17
+ # @example Rack app
18
+ # use Klime::Middleware, client: klime_client
19
+ #
20
+ # @note This adds latency to every request as it waits for the flush.
21
+ # Only use this if you need guaranteed per-request delivery.
22
+ # For most use cases, the background worker is sufficient.
23
+ class Middleware
24
+ # @param app [#call] The Rack application
25
+ # @param client [Klime::Client, nil] The Klime client to use (optional if Klime.client is set)
26
+ def initialize(app, client: nil)
27
+ @app = app
28
+ @client = client
29
+ end
30
+
31
+ def call(env)
32
+ response = @app.call(env)
33
+ flush_client
34
+ response
35
+ rescue StandardError
36
+ flush_client
37
+ raise
38
+ end
39
+
40
+ private
41
+
42
+ def flush_client
43
+ client = @client || Klime.client
44
+ return unless client
45
+
46
+ client.flush
47
+ rescue StandardError => e
48
+ # Don't let flush errors break the request
49
+ Klime.logger.error "Middleware flush error: #{e.message}"
50
+ end
51
+ end
52
+ end
data/lib/klime/version.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Klime
4
- VERSION = "1.0.4"
4
+ VERSION = "1.1.0"
5
5
  end
6
6
 
data/lib/klime.rb CHANGED
@@ -1,11 +1,89 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "klime/version"
4
+ require_relative "klime/configuration"
5
+ require_relative "klime/logging"
4
6
  require_relative "klime/event"
5
7
  require_relative "klime/client"
8
+ require_relative "klime/middleware"
6
9
 
7
10
  module Klime
8
11
  class Error < StandardError; end
9
12
  class ConfigurationError < Error; end
10
- end
11
13
 
14
+ # Raised when a synchronous operation (bang method) fails
15
+ class SendError < Error
16
+ attr_reader :status_code, :events
17
+
18
+ def initialize(message, status_code: nil, events: nil)
19
+ super(message)
20
+ @status_code = status_code
21
+ @events = events
22
+ end
23
+ end
24
+
25
+ class << self
26
+ # Global client instance (optional, for use with middleware)
27
+ # @return [Klime::Client, nil]
28
+ attr_accessor :client
29
+
30
+ # Returns the global configuration instance
31
+ # @return [Configuration]
32
+ def configuration
33
+ @configuration ||= Configuration.new
34
+ end
35
+
36
+ # Configure the SDK with a block
37
+ #
38
+ # @example
39
+ # Klime.configure do |config|
40
+ # config.logger = Rails.logger
41
+ # config.on_error = Proc.new { |error, batch| Sentry.capture_exception(error) }
42
+ # end
43
+ #
44
+ # @yield [Configuration] the configuration instance
45
+ def configure
46
+ yield(configuration) if block_given?
47
+ configuration
48
+ end
49
+
50
+ # Reset configuration to defaults (useful for testing)
51
+ # @api private
52
+ def reset_configuration!
53
+ @configuration = Configuration.new
54
+ @logger = nil
55
+ @client = nil
56
+ end
57
+
58
+ # Returns the configured logger wrapped with a prefix
59
+ # @return [Logging::PrefixedLogger]
60
+ def logger
61
+ @logger ||= Logging::PrefixedLogger.new(configuration.logger)
62
+ end
63
+
64
+ # Allows setting a custom logger directly
65
+ # @param new_logger [Logger] the logger instance to use
66
+ def logger=(new_logger)
67
+ configuration.logger = new_logger
68
+ @logger = Logging::PrefixedLogger.new(new_logger)
69
+ end
70
+
71
+ # Restart the global client's worker thread after a fork.
72
+ # Call this in Puma's on_worker_boot or Unicorn's after_fork.
73
+ #
74
+ # @example config/puma.rb
75
+ # on_worker_boot do
76
+ # Klime.restart!
77
+ # end
78
+ #
79
+ # @example config/unicorn.rb
80
+ # after_fork do |server, worker|
81
+ # Klime.restart!
82
+ # end
83
+ #
84
+ # @return [void]
85
+ def restart!
86
+ @client&.restart!
87
+ end
88
+ end
89
+ end
metadata CHANGED
@@ -1,15 +1,28 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: klime
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.4
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Klime
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2025-12-08 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: logger
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
13
26
  - !ruby/object:Gem::Dependency
14
27
  name: minitest
15
28
  requirement: !ruby/object:Gem::Requirement
@@ -64,7 +77,10 @@ files:
64
77
  - README.md
65
78
  - lib/klime.rb
66
79
  - lib/klime/client.rb
80
+ - lib/klime/configuration.rb
67
81
  - lib/klime/event.rb
82
+ - lib/klime/logging.rb
83
+ - lib/klime/middleware.rb
68
84
  - lib/klime/version.rb
69
85
  homepage: https://github.com/klimeapp/klime-ruby
70
86
  licenses:
@@ -74,7 +90,6 @@ metadata:
74
90
  source_code_uri: https://github.com/klimeapp/klime-ruby
75
91
  documentation_uri: https://github.com/klimeapp/klime-ruby
76
92
  changelog_uri: https://github.com/klimeapp/klime-ruby/blob/main/CHANGELOG.md
77
- post_install_message:
78
93
  rdoc_options: []
79
94
  require_paths:
80
95
  - lib
@@ -89,8 +104,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
89
104
  - !ruby/object:Gem::Version
90
105
  version: '0'
91
106
  requirements: []
92
- rubygems_version: 3.0.3.1
93
- signing_key:
107
+ rubygems_version: 3.6.9
94
108
  specification_version: 4
95
109
  summary: Klime SDK for Ruby
96
110
  test_files: []