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 +4 -4
- data/README.md +160 -48
- data/lib/klime/client.rb +300 -77
- data/lib/klime/configuration.rb +60 -0
- data/lib/klime/event.rb +1 -4
- data/lib/klime/logging.rb +64 -0
- data/lib/klime/middleware.rb +52 -0
- data/lib/klime/version.rb +1 -1
- data/lib/klime.rb +79 -1
- metadata +20 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1b96d506df543d39a095e29ded2bd3a55060d49669b579da2bb14a19cc1ada4f
|
|
4
|
+
data.tar.gz: c8df6be4c7ec823f7e9a3576a56c007ed0317da16620a43a7f8ed5e8d9a542d1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
160
|
+
#### `track(event_name, properties = nil, user_id: nil, group_id: nil)`
|
|
79
161
|
|
|
80
|
-
Track
|
|
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
|
-
#
|
|
89
|
-
client.track('
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
},
|
|
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
|
-
> **
|
|
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
|
|
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
|
-
}
|
|
190
|
+
})
|
|
106
191
|
```
|
|
107
192
|
|
|
108
|
-
#### `group(group_id, traits = nil, user_id: 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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
319
|
+
Klime.configure do |config|
|
|
320
|
+
config.logger = Rails.logger
|
|
321
|
+
end
|
|
189
322
|
|
|
190
|
-
|
|
191
|
-
|
|
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
|
|
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']
|
|
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
|
-
@
|
|
71
|
+
@worker_mutex = Mutex.new
|
|
72
|
+
@worker_thread = nil
|
|
73
|
+
@pid = Process.pid
|
|
59
74
|
@shutdown = false
|
|
60
|
-
|
|
61
|
-
@
|
|
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
|
-
# @
|
|
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
|
|
80
|
-
# client.track('Button Clicked', { button_name: 'Sign up' }, user_id: 'user_123'
|
|
81
|
-
def track(event_name, properties = nil, user_id: nil, group_id: 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
|
|
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
|
-
# @
|
|
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
|
|
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
|
|
148
|
+
context: build_context
|
|
113
149
|
)
|
|
114
150
|
|
|
115
151
|
enqueue(event)
|
|
152
|
+
self
|
|
116
153
|
end
|
|
117
154
|
|
|
118
|
-
#
|
|
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
|
-
# @
|
|
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
|
|
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
|
|
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
|
|
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 [
|
|
222
|
+
# @return [self] Returns self for method chaining
|
|
148
223
|
def flush
|
|
149
|
-
return if @shutdown
|
|
224
|
+
return self if @shutdown
|
|
150
225
|
|
|
151
|
-
|
|
152
|
-
|
|
226
|
+
# Process all batches synchronously
|
|
227
|
+
loop do
|
|
228
|
+
batch = extract_batch
|
|
229
|
+
break if batch.empty?
|
|
153
230
|
|
|
154
|
-
|
|
231
|
+
send_batch(batch)
|
|
155
232
|
end
|
|
156
233
|
|
|
157
|
-
|
|
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
|
-
#
|
|
175
|
-
@
|
|
246
|
+
# Signal worker thread to exit
|
|
247
|
+
@worker_thread[:should_exit] = true if @worker_thread&.alive?
|
|
176
248
|
|
|
177
|
-
#
|
|
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
|
|
289
|
+
at_exit do
|
|
290
|
+
@worker_thread[:should_exit] = true if @worker_thread&.alive?
|
|
291
|
+
shutdown
|
|
292
|
+
end
|
|
185
293
|
end
|
|
186
294
|
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
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
|
-
|
|
193
|
-
|
|
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 "
|
|
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
|
-
|
|
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
|
-
#
|
|
211
|
-
|
|
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
|
-
#
|
|
219
|
-
|
|
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
|
-
|
|
222
|
-
schedule_flush unless @shutdown
|
|
372
|
+
true
|
|
223
373
|
end
|
|
224
374
|
|
|
225
|
-
# Flush all queued events without
|
|
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 "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
344
|
-
|
|
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
|
|
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
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
|
+
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:
|
|
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.
|
|
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: []
|