klime 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE.md +22 -0
- data/README.md +265 -0
- data/lib/klime/client.rb +370 -0
- data/lib/klime/event.rb +122 -0
- data/lib/klime/version.rb +6 -0
- data/lib/klime.rb +11 -0
- metadata +96 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 330590153e5a8620ea195eeac0af41c0eacbc5d4619f3cf22c05192fcaa071ee
|
|
4
|
+
data.tar.gz: 54cf4a7e4afb4680dcd7386c056058184ac5fb0f46132f980074a115c1f6ac47
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: b89311ba42ebcc5cf409fbd331e2f77dfcf72455dc2c832a34c9392aaabe216949adedb90f2999cb4a88ae3ede702452db82074694db79a87b33dbe4c5c01a9d
|
|
7
|
+
data.tar.gz: 448ef16a67fc2ce3cf81766bce565cffc60a2ca5cb91dab198cd8a81ca825b643ec5b9c044605e436f56cdbb98ff88a4f9763b507bda1f929b3b05ca101a198a
|
data/LICENSE.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Klime
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
data/README.md
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
# klime
|
|
2
|
+
|
|
3
|
+
Klime SDK for Ruby.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Add this line to your application's Gemfile:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
gem 'klime'
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
And then execute:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
bundle install
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Or install it yourself as:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
gem install klime
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Quick Start
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
require 'klime'
|
|
29
|
+
|
|
30
|
+
client = Klime::Client.new(
|
|
31
|
+
write_key: 'your-write-key'
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# Identify a user
|
|
35
|
+
client.identify('user_123', {
|
|
36
|
+
email: 'user@example.com',
|
|
37
|
+
name: 'Stefan'
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
# Track an event
|
|
41
|
+
client.track('Button Clicked', {
|
|
42
|
+
button_name: 'Sign up',
|
|
43
|
+
plan: 'pro'
|
|
44
|
+
}, user_id: 'user_123')
|
|
45
|
+
|
|
46
|
+
# Associate user with a group and set group traits
|
|
47
|
+
client.group('org_456', {
|
|
48
|
+
name: 'Acme Inc',
|
|
49
|
+
plan: 'enterprise'
|
|
50
|
+
}, user_id: 'user_123')
|
|
51
|
+
|
|
52
|
+
# Or just link the user to a group (if traits are already set)
|
|
53
|
+
client.group('org_456', user_id: 'user_123')
|
|
54
|
+
|
|
55
|
+
# Shutdown gracefully
|
|
56
|
+
client.shutdown
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## API Reference
|
|
60
|
+
|
|
61
|
+
### Constructor
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
Klime::Client.new(
|
|
65
|
+
write_key:, # Required: Your Klime write key
|
|
66
|
+
endpoint: nil, # Optional: API endpoint (default: https://i.klime.com)
|
|
67
|
+
flush_interval: nil, # Optional: Milliseconds between flushes (default: 2000)
|
|
68
|
+
max_batch_size: nil, # Optional: Max events per batch (default: 20, max: 100)
|
|
69
|
+
max_queue_size: nil, # Optional: Max queued events (default: 1000)
|
|
70
|
+
retry_max_attempts: nil, # Optional: Max retry attempts (default: 5)
|
|
71
|
+
retry_initial_delay: nil, # Optional: Initial retry delay in ms (default: 1000)
|
|
72
|
+
flush_on_shutdown: nil # Optional: Auto-flush on exit (default: true)
|
|
73
|
+
)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Methods
|
|
77
|
+
|
|
78
|
+
#### `track(event_name, properties = nil, user_id: nil, group_id: nil, ip: nil)`
|
|
79
|
+
|
|
80
|
+
Track a user event. A `user_id` is required for events to be useful in Klime.
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
client.track('Button Clicked', {
|
|
84
|
+
button_name: 'Sign up',
|
|
85
|
+
plan: 'pro'
|
|
86
|
+
}, user_id: 'user_123')
|
|
87
|
+
|
|
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')
|
|
93
|
+
```
|
|
94
|
+
|
|
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.
|
|
96
|
+
|
|
97
|
+
#### `identify(user_id, traits = nil, ip: nil)`
|
|
98
|
+
|
|
99
|
+
Identify a user with traits.
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
client.identify('user_123', {
|
|
103
|
+
email: 'user@example.com',
|
|
104
|
+
name: 'Stefan'
|
|
105
|
+
}, ip: '192.168.1.1')
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
#### `group(group_id, traits = nil, user_id: nil, ip: nil)`
|
|
109
|
+
|
|
110
|
+
Associate a user with a group and/or set group traits.
|
|
111
|
+
|
|
112
|
+
```ruby
|
|
113
|
+
# Associate user with a group and set group traits (most common)
|
|
114
|
+
client.group('org_456', {
|
|
115
|
+
name: 'Acme Inc',
|
|
116
|
+
plan: 'enterprise'
|
|
117
|
+
}, user_id: 'user_123')
|
|
118
|
+
|
|
119
|
+
# Just link a user to a group (traits already set or not needed)
|
|
120
|
+
client.group('org_456', user_id: 'user_123')
|
|
121
|
+
|
|
122
|
+
# Just update group traits (e.g., from a webhook or background job)
|
|
123
|
+
client.group('org_456', {
|
|
124
|
+
plan: 'enterprise',
|
|
125
|
+
employee_count: 50
|
|
126
|
+
})
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
#### `flush`
|
|
130
|
+
|
|
131
|
+
Manually flush queued events immediately.
|
|
132
|
+
|
|
133
|
+
```ruby
|
|
134
|
+
client.flush
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
#### `shutdown`
|
|
138
|
+
|
|
139
|
+
Gracefully shutdown the client, flushing remaining events.
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
client.shutdown
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Features
|
|
146
|
+
|
|
147
|
+
- **Automatic Batching**: Events are automatically batched and sent every 2 seconds or when the batch size reaches 20 events
|
|
148
|
+
- **Automatic Retries**: Failed requests are automatically retried with exponential backoff
|
|
149
|
+
- **Thread-Safe**: Safe to use from multiple threads
|
|
150
|
+
- **Process Exit Handling**: Automatically flushes events on process exit (via `at_exit`)
|
|
151
|
+
- **Zero Dependencies**: Uses only Ruby standard library
|
|
152
|
+
|
|
153
|
+
## Configuration
|
|
154
|
+
|
|
155
|
+
### Default Values
|
|
156
|
+
|
|
157
|
+
- `flush_interval`: 2000ms
|
|
158
|
+
- `max_batch_size`: 20 events
|
|
159
|
+
- `max_queue_size`: 1000 events
|
|
160
|
+
- `retry_max_attempts`: 5 attempts
|
|
161
|
+
- `retry_initial_delay`: 1000ms
|
|
162
|
+
- `flush_on_shutdown`: true
|
|
163
|
+
|
|
164
|
+
## Error Handling
|
|
165
|
+
|
|
166
|
+
The SDK automatically handles:
|
|
167
|
+
|
|
168
|
+
- **Transient errors** (429, 503, network failures): Retries with exponential backoff
|
|
169
|
+
- **Permanent errors** (400, 401): Logs error and drops event
|
|
170
|
+
- **Rate limiting**: Respects `Retry-After` header
|
|
171
|
+
|
|
172
|
+
## Size Limits
|
|
173
|
+
|
|
174
|
+
- Maximum event size: 200KB
|
|
175
|
+
- Maximum batch size: 10MB
|
|
176
|
+
- Maximum events per batch: 100
|
|
177
|
+
|
|
178
|
+
Events exceeding these limits are rejected and logged.
|
|
179
|
+
|
|
180
|
+
## Rails Example
|
|
181
|
+
|
|
182
|
+
```ruby
|
|
183
|
+
# config/initializers/klime.rb
|
|
184
|
+
require 'klime'
|
|
185
|
+
|
|
186
|
+
KLIME = Klime::Client.new(
|
|
187
|
+
write_key: ENV['KLIME_WRITE_KEY']
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
# Ensure graceful shutdown
|
|
191
|
+
at_exit { KLIME.shutdown }
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
```ruby
|
|
195
|
+
# app/controllers/buttons_controller.rb
|
|
196
|
+
class ButtonsController < ApplicationController
|
|
197
|
+
def click
|
|
198
|
+
KLIME.track('Button Clicked', {
|
|
199
|
+
button_name: params[:button_name]
|
|
200
|
+
}, user_id: current_user&.id&.to_s, ip: request.remote_ip)
|
|
201
|
+
|
|
202
|
+
render json: { success: true }
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
## Sinatra Example
|
|
208
|
+
|
|
209
|
+
```ruby
|
|
210
|
+
require 'sinatra'
|
|
211
|
+
require 'klime'
|
|
212
|
+
|
|
213
|
+
client = Klime::Client.new(write_key: ENV['KLIME_WRITE_KEY'])
|
|
214
|
+
|
|
215
|
+
post '/api/button-clicked' do
|
|
216
|
+
data = JSON.parse(request.body.read)
|
|
217
|
+
|
|
218
|
+
client.track('Button Clicked', {
|
|
219
|
+
button_name: data['buttonName']
|
|
220
|
+
}, user_id: data['userId'], ip: request.ip)
|
|
221
|
+
|
|
222
|
+
{ success: true }.to_json
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Graceful shutdown
|
|
226
|
+
at_exit { client.shutdown }
|
|
227
|
+
```
|
|
228
|
+
|
|
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
|
+
## Requirements
|
|
259
|
+
|
|
260
|
+
- Ruby 2.6 or higher
|
|
261
|
+
- No external dependencies (uses only standard library)
|
|
262
|
+
|
|
263
|
+
## License
|
|
264
|
+
|
|
265
|
+
MIT
|
data/lib/klime/client.rb
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
require "thread"
|
|
7
|
+
|
|
8
|
+
module Klime
|
|
9
|
+
# Main client for the Klime analytics SDK
|
|
10
|
+
#
|
|
11
|
+
# @example Basic usage
|
|
12
|
+
# client = Klime::Client.new(write_key: 'your-write-key')
|
|
13
|
+
# client.track('Button Clicked', { button_name: 'Sign up' })
|
|
14
|
+
# client.shutdown
|
|
15
|
+
class Client
|
|
16
|
+
DEFAULT_ENDPOINT = "https://i.klime.com"
|
|
17
|
+
DEFAULT_FLUSH_INTERVAL = 2000 # milliseconds
|
|
18
|
+
DEFAULT_MAX_BATCH_SIZE = 20
|
|
19
|
+
DEFAULT_MAX_QUEUE_SIZE = 1000
|
|
20
|
+
DEFAULT_RETRY_MAX_ATTEMPTS = 5
|
|
21
|
+
DEFAULT_RETRY_INITIAL_DELAY = 1000 # milliseconds
|
|
22
|
+
MAX_BATCH_SIZE = 100
|
|
23
|
+
MAX_EVENT_SIZE_BYTES = 200 * 1024 # 200KB
|
|
24
|
+
MAX_BATCH_SIZE_BYTES = 10 * 1024 * 1024 # 10MB
|
|
25
|
+
|
|
26
|
+
# Create a new Klime client
|
|
27
|
+
#
|
|
28
|
+
# @param write_key [String] Your Klime write key (required)
|
|
29
|
+
# @param endpoint [String] API endpoint URL (default: https://i.klime.com)
|
|
30
|
+
# @param flush_interval [Integer] Milliseconds between automatic flushes (default: 2000)
|
|
31
|
+
# @param max_batch_size [Integer] Maximum events per batch (default: 20, max: 100)
|
|
32
|
+
# @param max_queue_size [Integer] Maximum queued events (default: 1000)
|
|
33
|
+
# @param retry_max_attempts [Integer] Maximum retry attempts (default: 5)
|
|
34
|
+
# @param retry_initial_delay [Integer] Initial retry delay in milliseconds (default: 1000)
|
|
35
|
+
# @param flush_on_shutdown [Boolean] Auto-flush on exit (default: true)
|
|
36
|
+
def initialize(
|
|
37
|
+
write_key:,
|
|
38
|
+
endpoint: nil,
|
|
39
|
+
flush_interval: nil,
|
|
40
|
+
max_batch_size: nil,
|
|
41
|
+
max_queue_size: nil,
|
|
42
|
+
retry_max_attempts: nil,
|
|
43
|
+
retry_initial_delay: nil,
|
|
44
|
+
flush_on_shutdown: nil
|
|
45
|
+
)
|
|
46
|
+
raise ConfigurationError, "write_key is required" if write_key.nil? || write_key.empty?
|
|
47
|
+
|
|
48
|
+
@write_key = write_key
|
|
49
|
+
@endpoint = endpoint || DEFAULT_ENDPOINT
|
|
50
|
+
@flush_interval = flush_interval || DEFAULT_FLUSH_INTERVAL
|
|
51
|
+
@max_batch_size = [max_batch_size || DEFAULT_MAX_BATCH_SIZE, MAX_BATCH_SIZE].min
|
|
52
|
+
@max_queue_size = max_queue_size || DEFAULT_MAX_QUEUE_SIZE
|
|
53
|
+
@retry_max_attempts = retry_max_attempts || DEFAULT_RETRY_MAX_ATTEMPTS
|
|
54
|
+
@retry_initial_delay = retry_initial_delay || DEFAULT_RETRY_INITIAL_DELAY
|
|
55
|
+
@flush_on_shutdown = flush_on_shutdown.nil? ? true : flush_on_shutdown
|
|
56
|
+
|
|
57
|
+
@queue = Queue.new
|
|
58
|
+
@mutex = Mutex.new
|
|
59
|
+
@shutdown = false
|
|
60
|
+
@flush_in_progress = false
|
|
61
|
+
@flush_thread = nil
|
|
62
|
+
|
|
63
|
+
setup_shutdown_hook if @flush_on_shutdown
|
|
64
|
+
schedule_flush
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Track a user event
|
|
68
|
+
#
|
|
69
|
+
# @param event_name [String] Name of the event
|
|
70
|
+
# @param properties [Hash] Event properties (optional)
|
|
71
|
+
# @param user_id [String] User ID (optional)
|
|
72
|
+
# @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' })
|
|
78
|
+
#
|
|
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
|
|
83
|
+
|
|
84
|
+
event = Event.new(
|
|
85
|
+
type: EventType::TRACK,
|
|
86
|
+
event: event_name,
|
|
87
|
+
properties: properties || {},
|
|
88
|
+
user_id: user_id,
|
|
89
|
+
group_id: group_id,
|
|
90
|
+
context: build_context(ip)
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
enqueue(event)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Identify a user with traits
|
|
97
|
+
#
|
|
98
|
+
# @param user_id [String] User ID (required)
|
|
99
|
+
# @param traits [Hash] User traits (optional)
|
|
100
|
+
# @param ip [String] IP address (optional)
|
|
101
|
+
# @return [void]
|
|
102
|
+
#
|
|
103
|
+
# @example
|
|
104
|
+
# client.identify('user_123', { email: 'user@example.com', name: 'Stefan' })
|
|
105
|
+
def identify(user_id, traits = nil, ip: nil)
|
|
106
|
+
return if @shutdown
|
|
107
|
+
|
|
108
|
+
event = Event.new(
|
|
109
|
+
type: EventType::IDENTIFY,
|
|
110
|
+
user_id: user_id,
|
|
111
|
+
traits: traits || {},
|
|
112
|
+
context: build_context(ip)
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
enqueue(event)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Associate a user with a group and set group traits
|
|
119
|
+
#
|
|
120
|
+
# @param group_id [String] Group ID (required)
|
|
121
|
+
# @param traits [Hash] Group traits (optional)
|
|
122
|
+
# @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' })
|
|
128
|
+
#
|
|
129
|
+
# @example With user ID
|
|
130
|
+
# 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
|
|
133
|
+
|
|
134
|
+
event = Event.new(
|
|
135
|
+
type: EventType::GROUP,
|
|
136
|
+
group_id: group_id,
|
|
137
|
+
user_id: user_id,
|
|
138
|
+
traits: traits || {},
|
|
139
|
+
context: build_context(ip)
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
enqueue(event)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Manually flush queued events immediately
|
|
146
|
+
#
|
|
147
|
+
# @return [void]
|
|
148
|
+
def flush
|
|
149
|
+
return if @shutdown
|
|
150
|
+
|
|
151
|
+
@mutex.synchronize do
|
|
152
|
+
return if @flush_in_progress
|
|
153
|
+
|
|
154
|
+
@flush_in_progress = true
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
begin
|
|
158
|
+
do_flush
|
|
159
|
+
ensure
|
|
160
|
+
@mutex.synchronize do
|
|
161
|
+
@flush_in_progress = false
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Gracefully shutdown the client, flushing remaining events
|
|
167
|
+
#
|
|
168
|
+
# @return [void]
|
|
169
|
+
def shutdown
|
|
170
|
+
return if @shutdown
|
|
171
|
+
|
|
172
|
+
@shutdown = true
|
|
173
|
+
|
|
174
|
+
# Cancel scheduled flush
|
|
175
|
+
@flush_thread&.kill
|
|
176
|
+
|
|
177
|
+
# Force flush remaining events (bypass normal flush which checks @shutdown)
|
|
178
|
+
flush_remaining
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
private
|
|
182
|
+
|
|
183
|
+
def setup_shutdown_hook
|
|
184
|
+
at_exit { shutdown }
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def schedule_flush
|
|
188
|
+
return if @shutdown
|
|
189
|
+
|
|
190
|
+
@flush_thread = Thread.new do
|
|
191
|
+
sleep(@flush_interval / 1000.0)
|
|
192
|
+
flush unless @shutdown
|
|
193
|
+
schedule_flush unless @shutdown
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def enqueue(event)
|
|
198
|
+
# Check event size
|
|
199
|
+
event_size = estimate_event_size(event)
|
|
200
|
+
if event_size > MAX_EVENT_SIZE_BYTES
|
|
201
|
+
warn "Klime: Event size (#{event_size} bytes) exceeds #{MAX_EVENT_SIZE_BYTES} bytes limit"
|
|
202
|
+
return
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Drop oldest if queue is full
|
|
206
|
+
@queue.pop(true) if @queue.size >= @max_queue_size rescue nil
|
|
207
|
+
|
|
208
|
+
@queue.push(event)
|
|
209
|
+
|
|
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
|
|
217
|
+
|
|
218
|
+
# Process batches
|
|
219
|
+
flush_remaining
|
|
220
|
+
|
|
221
|
+
# Schedule next flush
|
|
222
|
+
schedule_flush unless @shutdown
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Flush all queued events without scheduling (used by shutdown)
|
|
226
|
+
def flush_remaining
|
|
227
|
+
loop do
|
|
228
|
+
batch = extract_batch
|
|
229
|
+
break if batch.empty?
|
|
230
|
+
|
|
231
|
+
send_batch(batch)
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def extract_batch
|
|
236
|
+
batch = []
|
|
237
|
+
batch_size = 0
|
|
238
|
+
|
|
239
|
+
while batch.length < MAX_BATCH_SIZE && batch.length < @max_batch_size
|
|
240
|
+
begin
|
|
241
|
+
event = @queue.pop(true) # non-blocking
|
|
242
|
+
rescue ThreadError
|
|
243
|
+
break # Queue is empty
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
event_size = estimate_event_size(event)
|
|
247
|
+
|
|
248
|
+
# Check if adding this event would exceed batch size limit
|
|
249
|
+
if batch_size + event_size > MAX_BATCH_SIZE_BYTES
|
|
250
|
+
# Put event back (at the front would be ideal, but Queue doesn't support that)
|
|
251
|
+
# We'll lose some ordering but it's acceptable for analytics
|
|
252
|
+
@queue.push(event)
|
|
253
|
+
break
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
batch << event
|
|
257
|
+
batch_size += event_size
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
batch
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def send_batch(batch)
|
|
264
|
+
return if batch.empty?
|
|
265
|
+
|
|
266
|
+
request_body = { batch: batch.map(&:to_h) }.to_json
|
|
267
|
+
uri = URI.parse("#{@endpoint}/v1/batch")
|
|
268
|
+
|
|
269
|
+
attempt = 0
|
|
270
|
+
delay = @retry_initial_delay / 1000.0 # Convert to seconds
|
|
271
|
+
|
|
272
|
+
while attempt < @retry_max_attempts
|
|
273
|
+
begin
|
|
274
|
+
response = make_request(uri, request_body)
|
|
275
|
+
|
|
276
|
+
case response
|
|
277
|
+
when Net::HTTPSuccess
|
|
278
|
+
data = JSON.parse(response.body)
|
|
279
|
+
batch_response = BatchResponse.new(
|
|
280
|
+
status: data["status"] || "ok",
|
|
281
|
+
accepted: data["accepted"] || 0,
|
|
282
|
+
failed: data["failed"] || 0,
|
|
283
|
+
errors: parse_errors(data["errors"])
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
if batch_response.failed > 0 && batch_response.errors
|
|
287
|
+
warn "Klime: Batch partially failed. Accepted: #{batch_response.accepted}, Failed: #{batch_response.failed}"
|
|
288
|
+
end
|
|
289
|
+
return
|
|
290
|
+
|
|
291
|
+
when Net::HTTPBadRequest, Net::HTTPUnauthorized
|
|
292
|
+
data = JSON.parse(response.body) rescue {}
|
|
293
|
+
warn "Klime: Permanent error (#{response.code}): #{data}"
|
|
294
|
+
return
|
|
295
|
+
|
|
296
|
+
when Net::HTTPTooManyRequests, Net::HTTPServiceUnavailable
|
|
297
|
+
retry_after = response["Retry-After"]
|
|
298
|
+
delay = retry_after.to_i if retry_after && retry_after.to_i > 0
|
|
299
|
+
|
|
300
|
+
attempt += 1
|
|
301
|
+
if attempt < @retry_max_attempts
|
|
302
|
+
sleep(delay)
|
|
303
|
+
delay = [delay * 2, 16.0].min
|
|
304
|
+
next
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
else
|
|
308
|
+
# Other errors - retry
|
|
309
|
+
attempt += 1
|
|
310
|
+
if attempt < @retry_max_attempts
|
|
311
|
+
sleep(delay)
|
|
312
|
+
delay = [delay * 2, 16.0].min
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
rescue StandardError => e
|
|
317
|
+
# Network errors - retry
|
|
318
|
+
attempt += 1
|
|
319
|
+
if attempt < @retry_max_attempts
|
|
320
|
+
sleep(delay)
|
|
321
|
+
delay = [delay * 2, 16.0].min
|
|
322
|
+
else
|
|
323
|
+
warn "Klime: Failed to send batch after retries: #{e.message}"
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def make_request(uri, body)
|
|
330
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
331
|
+
http.use_ssl = uri.scheme == "https"
|
|
332
|
+
http.open_timeout = 10
|
|
333
|
+
http.read_timeout = 10
|
|
334
|
+
|
|
335
|
+
request = Net::HTTP::Post.new(uri.request_uri)
|
|
336
|
+
request["Content-Type"] = "application/json"
|
|
337
|
+
request["Authorization"] = "Bearer #{@write_key}"
|
|
338
|
+
request.body = body
|
|
339
|
+
|
|
340
|
+
http.request(request)
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def build_context(ip = nil)
|
|
344
|
+
context = EventContext.new(
|
|
345
|
+
library: LibraryInfo.new(name: "ruby-sdk", version: VERSION)
|
|
346
|
+
)
|
|
347
|
+
context.ip = ip if ip
|
|
348
|
+
context
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def estimate_event_size(event)
|
|
352
|
+
event.to_json.bytesize
|
|
353
|
+
rescue StandardError
|
|
354
|
+
500
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def parse_errors(errors)
|
|
358
|
+
return nil unless errors.is_a?(Array)
|
|
359
|
+
|
|
360
|
+
errors.map do |err|
|
|
361
|
+
ValidationError.new(
|
|
362
|
+
index: err["index"] || -1,
|
|
363
|
+
message: err["message"] || "",
|
|
364
|
+
code: err["code"] || ""
|
|
365
|
+
)
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
|
data/lib/klime/event.rb
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "time"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module Klime
|
|
8
|
+
# Event types supported by the API
|
|
9
|
+
module EventType
|
|
10
|
+
TRACK = "track"
|
|
11
|
+
IDENTIFY = "identify"
|
|
12
|
+
GROUP = "group"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Library information included in event context
|
|
16
|
+
class LibraryInfo
|
|
17
|
+
attr_reader :name, :version
|
|
18
|
+
|
|
19
|
+
def initialize(name:, version:)
|
|
20
|
+
@name = name
|
|
21
|
+
@version = version
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def to_h
|
|
25
|
+
{
|
|
26
|
+
name: @name,
|
|
27
|
+
version: @version
|
|
28
|
+
}
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Context information for events
|
|
33
|
+
class EventContext
|
|
34
|
+
attr_reader :library
|
|
35
|
+
attr_accessor :ip
|
|
36
|
+
|
|
37
|
+
def initialize(library: nil, ip: nil)
|
|
38
|
+
@library = library
|
|
39
|
+
@ip = ip
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def to_h
|
|
43
|
+
result = {}
|
|
44
|
+
result[:library] = @library.to_h if @library
|
|
45
|
+
result[:ip] = @ip if @ip
|
|
46
|
+
result
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Base event class
|
|
51
|
+
class Event
|
|
52
|
+
attr_reader :type, :message_id, :timestamp, :context
|
|
53
|
+
attr_accessor :event, :user_id, :group_id, :properties, :traits
|
|
54
|
+
|
|
55
|
+
def initialize(
|
|
56
|
+
type:,
|
|
57
|
+
message_id: nil,
|
|
58
|
+
timestamp: nil,
|
|
59
|
+
event: nil,
|
|
60
|
+
user_id: nil,
|
|
61
|
+
group_id: nil,
|
|
62
|
+
properties: nil,
|
|
63
|
+
traits: nil,
|
|
64
|
+
context: nil
|
|
65
|
+
)
|
|
66
|
+
@type = type
|
|
67
|
+
@message_id = message_id || SecureRandom.uuid
|
|
68
|
+
@timestamp = timestamp || Time.now.utc.iso8601(3)
|
|
69
|
+
@event = event
|
|
70
|
+
@user_id = user_id
|
|
71
|
+
@group_id = group_id
|
|
72
|
+
@properties = properties
|
|
73
|
+
@traits = traits
|
|
74
|
+
@context = context
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def to_h
|
|
78
|
+
result = {
|
|
79
|
+
type: @type,
|
|
80
|
+
messageId: @message_id,
|
|
81
|
+
timestamp: @timestamp
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
result[:event] = @event if @event
|
|
85
|
+
result[:userId] = @user_id if @user_id
|
|
86
|
+
result[:groupId] = @group_id if @group_id
|
|
87
|
+
result[:properties] = @properties if @properties
|
|
88
|
+
result[:traits] = @traits if @traits
|
|
89
|
+
result[:context] = @context.to_h if @context && !@context.to_h.empty?
|
|
90
|
+
|
|
91
|
+
result
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def to_json(*args)
|
|
95
|
+
to_h.to_json(*args)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Validation error from batch response
|
|
100
|
+
class ValidationError
|
|
101
|
+
attr_reader :index, :message, :code
|
|
102
|
+
|
|
103
|
+
def initialize(index:, message:, code:)
|
|
104
|
+
@index = index
|
|
105
|
+
@message = message
|
|
106
|
+
@code = code
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Batch response from the API
|
|
111
|
+
class BatchResponse
|
|
112
|
+
attr_reader :status, :accepted, :failed, :errors
|
|
113
|
+
|
|
114
|
+
def initialize(status:, accepted:, failed:, errors: nil)
|
|
115
|
+
@status = status
|
|
116
|
+
@accepted = accepted
|
|
117
|
+
@failed = failed
|
|
118
|
+
@errors = errors
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
data/lib/klime.rb
ADDED
metadata
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: klime
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.4
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Klime
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2025-12-08 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: minitest
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '5.0'
|
|
20
|
+
type: :development
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '5.0'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: rake
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '13.0'
|
|
34
|
+
type: :development
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '13.0'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: webmock
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '3.0'
|
|
48
|
+
type: :development
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '3.0'
|
|
55
|
+
description: Track events, identify users, and group them with organizations using
|
|
56
|
+
Klime analytics.
|
|
57
|
+
email:
|
|
58
|
+
- support@klime.com
|
|
59
|
+
executables: []
|
|
60
|
+
extensions: []
|
|
61
|
+
extra_rdoc_files: []
|
|
62
|
+
files:
|
|
63
|
+
- LICENSE.md
|
|
64
|
+
- README.md
|
|
65
|
+
- lib/klime.rb
|
|
66
|
+
- lib/klime/client.rb
|
|
67
|
+
- lib/klime/event.rb
|
|
68
|
+
- lib/klime/version.rb
|
|
69
|
+
homepage: https://github.com/klimeapp/klime-ruby
|
|
70
|
+
licenses:
|
|
71
|
+
- MIT
|
|
72
|
+
metadata:
|
|
73
|
+
homepage_uri: https://github.com/klimeapp/klime-ruby
|
|
74
|
+
source_code_uri: https://github.com/klimeapp/klime-ruby
|
|
75
|
+
documentation_uri: https://github.com/klimeapp/klime-ruby
|
|
76
|
+
changelog_uri: https://github.com/klimeapp/klime-ruby/blob/main/CHANGELOG.md
|
|
77
|
+
post_install_message:
|
|
78
|
+
rdoc_options: []
|
|
79
|
+
require_paths:
|
|
80
|
+
- lib
|
|
81
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
82
|
+
requirements:
|
|
83
|
+
- - ">="
|
|
84
|
+
- !ruby/object:Gem::Version
|
|
85
|
+
version: 2.6.0
|
|
86
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
87
|
+
requirements:
|
|
88
|
+
- - ">="
|
|
89
|
+
- !ruby/object:Gem::Version
|
|
90
|
+
version: '0'
|
|
91
|
+
requirements: []
|
|
92
|
+
rubygems_version: 3.0.3.1
|
|
93
|
+
signing_key:
|
|
94
|
+
specification_version: 4
|
|
95
|
+
summary: Klime SDK for Ruby
|
|
96
|
+
test_files: []
|