risenexa-tracking 0.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 +7 -0
- data/.rspec +2 -0
- data/CHANGELOG.md +28 -0
- data/LICENSE.txt +21 -0
- data/README.md +234 -0
- data/Rakefile +8 -0
- data/lib/risenexa/tracking/client.rb +100 -0
- data/lib/risenexa/tracking/configuration.rb +35 -0
- data/lib/risenexa/tracking/errors.rb +69 -0
- data/lib/risenexa/tracking/http_client.rb +200 -0
- data/lib/risenexa/tracking/result.rb +35 -0
- data/lib/risenexa/tracking/version.rb +7 -0
- data/lib/risenexa/tracking.rb +93 -0
- metadata +99 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 9ab27b33f1e1dc89d57f6d1e8801636789b1a5b42ec7310ca06543275699d448
|
|
4
|
+
data.tar.gz: 236824c91e9e7c55a7df530cb6ae07d09861e0dca28785a7c541c7a963d41723
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 8bb883ccd3d6ee1faba616728a2ce246af0849f6c3a342f4cf024909559fdaacd0e3c29e2a20ec6fb43dca00858cc87d3a000e564f76c0c1ae77a8f1f15d62f2
|
|
7
|
+
data.tar.gz: 940a0660c24b12564f150f1926a6308d153c7db2200eb06b8683df81b3090e8f031f37e593bc215d8aa0d786a0d37871eea4b6c439c3395472c103c716dcd1d0
|
data/.rspec
ADDED
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0] - Unreleased
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Initial implementation of the `risenexa-tracking` gem
|
|
15
|
+
- Global configuration via `Risenexa::Tracking.configure` block
|
|
16
|
+
- Per-instance `Risenexa::Tracking::Client` for multi-startup use cases
|
|
17
|
+
- `track_registration(user_id:)` convenience method (sends `event_type: "user_registered"`)
|
|
18
|
+
- `track_conversion(user_id:)` convenience method (sends `event_type: "user_converted"`)
|
|
19
|
+
- Low-level `track` method accepting all HTTP contract fields
|
|
20
|
+
- UUID v4 idempotency anchor generated before first attempt, reused across retries
|
|
21
|
+
- Exponential backoff retry with jitter (1s base, 2x multiplier, 30s max, ±20% jitter)
|
|
22
|
+
- `Retry-After` header honoring for 429 rate-limit responses
|
|
23
|
+
- Typed error hierarchy: `AuthenticationError`, `AuthorizationError`, `ValidationError`,
|
|
24
|
+
`StartupNotFoundError`, `RateLimitError`, `MaxRetriesExceededError`, `ConnectionError`,
|
|
25
|
+
`ConfigurationError`
|
|
26
|
+
- Zero runtime dependencies (Net::HTTP from Ruby stdlib)
|
|
27
|
+
- 35 compliance tests conforming to SDK-SPEC.md Section 10
|
|
28
|
+
- GitHub Actions CI (Ruby 3.1, 3.2, 3.3, 3.4) and automated gem publishing workflow
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Patrick Espake
|
|
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.
|
data/README.md
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
# risenexa-tracking
|
|
2
|
+
|
|
3
|
+
Ruby SDK for tracking user events with Risenexa. Report user registrations and conversions to your Risenexa dashboard with a single method call.
|
|
4
|
+
|
|
5
|
+
- **Zero runtime dependencies** — uses Ruby's built-in `Net::HTTP`
|
|
6
|
+
- **Idempotent retries** — UUID v4 anchor prevents duplicate counts on retry
|
|
7
|
+
- **Typed errors** — distinct error classes for auth, validation, rate limiting, and network failures
|
|
8
|
+
- **Two configuration modes** — global (Rails initializer) or per-instance (multi-startup)
|
|
9
|
+
|
|
10
|
+
For the full behavioral specification, see [SDK-SPEC.md](https://github.com/envixo/risenexa-tracking-rb/blob/main/SDK-SPEC.md).
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
Add this line to your application's Gemfile:
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
gem "risenexa-tracking"
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Then run:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
bundle install
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Or install directly:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
gem install risenexa-tracking
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
**Requirements:** Ruby >= 3.1.0
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## Quick Start
|
|
39
|
+
|
|
40
|
+
### Global Configuration (recommended for Rails)
|
|
41
|
+
|
|
42
|
+
Create an initializer at `config/initializers/risenexa_tracking.rb`:
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
Risenexa::Tracking.configure do |config|
|
|
46
|
+
config.api_key = ENV["RISENEXA_API_KEY"] # Bearer token with tracking:write scope
|
|
47
|
+
config.startup_slug = ENV["RISENEXA_STARTUP_SLUG"] # Your startup's slug
|
|
48
|
+
end
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Then call the module-level convenience methods anywhere in your app:
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
# After a user signs up
|
|
55
|
+
Risenexa::Tracking.track_registration(user_id: current_user.id.to_s)
|
|
56
|
+
|
|
57
|
+
# After a user starts paying
|
|
58
|
+
Risenexa::Tracking.track_conversion(user_id: current_user.id.to_s)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Per-Instance Configuration
|
|
62
|
+
|
|
63
|
+
Useful when managing multiple startups from a single Rails app:
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
startup_a = Risenexa::Tracking::Client.new(
|
|
67
|
+
api_key: "rxt_live_abc123",
|
|
68
|
+
startup_slug: "startup-alpha"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
startup_b = Risenexa::Tracking::Client.new(
|
|
72
|
+
api_key: "rxt_live_xyz789",
|
|
73
|
+
startup_slug: "startup-beta"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
startup_a.track_registration(user_id: "usr_1")
|
|
77
|
+
startup_b.track_conversion(user_id: "usr_2")
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Per-instance clients are fully independent — no shared state with global configuration or other instances.
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## API Reference
|
|
85
|
+
|
|
86
|
+
### Convenience Methods
|
|
87
|
+
|
|
88
|
+
#### `track_registration(user_id:, **opts)`
|
|
89
|
+
|
|
90
|
+
Sends `event_type: "user_registered"`, `action: "add"`.
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
result = client.track_registration(user_id: "usr_123")
|
|
94
|
+
result.success? # => true
|
|
95
|
+
result.status_code # => 202
|
|
96
|
+
result.event_id # => "550e8400-e29b-41d4-a716-446655440000"
|
|
97
|
+
result.body # => {"status" => "accepted"}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
#### `track_conversion(user_id:, **opts)`
|
|
101
|
+
|
|
102
|
+
Sends `event_type: "user_converted"`, `action: "add"`.
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
result = client.track_conversion(user_id: "usr_123")
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Both methods accept optional keyword arguments:
|
|
109
|
+
|
|
110
|
+
| Option | Type | Default | Description |
|
|
111
|
+
|--------|------|---------|-------------|
|
|
112
|
+
| `event_id:` | String | auto UUID v4 | Idempotency anchor |
|
|
113
|
+
| `occurred_at:` | String | server time | ISO 8601 UTC timestamp |
|
|
114
|
+
| `metadata:` | Hash | `{}` | Arbitrary JSONB payload |
|
|
115
|
+
| `action:` | String | `"add"` | `"add"` or `"remove"` |
|
|
116
|
+
|
|
117
|
+
### Low-Level `track` Method
|
|
118
|
+
|
|
119
|
+
Use when you need full control over all HTTP contract fields:
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
client.track(
|
|
123
|
+
event_type: "user_registered",
|
|
124
|
+
user_id: "usr_123",
|
|
125
|
+
event_id: "550e8400-e29b-41d4-a716-446655440000", # optional
|
|
126
|
+
occurred_at: "2026-04-01T12:00:00Z", # optional
|
|
127
|
+
metadata: { plan: "pro", source: "google" }, # optional
|
|
128
|
+
action: "add" # optional
|
|
129
|
+
)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
## Configuration Options
|
|
135
|
+
|
|
136
|
+
| Option | Required | Default | Type | Description |
|
|
137
|
+
|--------|----------|---------|------|-------------|
|
|
138
|
+
| `api_key` | Yes | — | String | Bearer token with `tracking:write` scope |
|
|
139
|
+
| `startup_slug` | Yes | — | String | Slug identifying the startup |
|
|
140
|
+
| `base_url` | No | `"https://app.risenexa.com"` | String | API base URL (override for staging) |
|
|
141
|
+
| `timeout` | No | `2000` | Integer | Per-request timeout in milliseconds |
|
|
142
|
+
| `max_retries` | No | `3` | Integer | Maximum retry attempts (0 disables retries) |
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
## Error Handling
|
|
147
|
+
|
|
148
|
+
All error classes inherit from `Risenexa::Tracking::Error < StandardError`.
|
|
149
|
+
|
|
150
|
+
```ruby
|
|
151
|
+
begin
|
|
152
|
+
client.track_registration(user_id: "usr_123")
|
|
153
|
+
rescue Risenexa::Tracking::AuthenticationError => e
|
|
154
|
+
# HTTP 401 — missing or invalid API key; check your api_key
|
|
155
|
+
puts e.message
|
|
156
|
+
rescue Risenexa::Tracking::AuthorizationError => e
|
|
157
|
+
# HTTP 403 — token lacks tracking:write scope
|
|
158
|
+
puts e.message
|
|
159
|
+
rescue Risenexa::Tracking::StartupNotFoundError => e
|
|
160
|
+
# HTTP 404 — wrong startup_slug; check your configuration
|
|
161
|
+
puts e.message
|
|
162
|
+
rescue Risenexa::Tracking::ValidationError => e
|
|
163
|
+
# HTTP 422 — invalid payload
|
|
164
|
+
puts e.errors.inspect # Array of error strings from response body
|
|
165
|
+
rescue Risenexa::Tracking::RateLimitError => e
|
|
166
|
+
# All retries exhausted on 429
|
|
167
|
+
puts "Retry after #{e.retry_after} seconds"
|
|
168
|
+
rescue Risenexa::Tracking::MaxRetriesExceededError => e
|
|
169
|
+
# All retries exhausted on 500/502/503
|
|
170
|
+
puts "Last response: #{e.last_response.code}"
|
|
171
|
+
rescue Risenexa::Tracking::ConnectionError => e
|
|
172
|
+
# All retries exhausted due to network/timeout failures
|
|
173
|
+
puts "Transport error: #{e.cause.class}"
|
|
174
|
+
rescue Risenexa::Tracking::ConfigurationError => e
|
|
175
|
+
# Missing api_key or startup_slug — caught before any HTTP request
|
|
176
|
+
puts e.message
|
|
177
|
+
end
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### Error Classes
|
|
181
|
+
|
|
182
|
+
| Class | HTTP Status | Notes |
|
|
183
|
+
|-------|-------------|-------|
|
|
184
|
+
| `AuthenticationError` | 401 | Never retried |
|
|
185
|
+
| `AuthorizationError` | 403 | Never retried |
|
|
186
|
+
| `StartupNotFoundError` | 404 | Never retried |
|
|
187
|
+
| `ValidationError` | 422 | Never retried; `.errors` contains array from response |
|
|
188
|
+
| `RateLimitError` | 429 (exhausted) | `.retry_after` has seconds from `Retry-After` header |
|
|
189
|
+
| `MaxRetriesExceededError` | 5xx (exhausted) | `.last_response` has the last `Net::HTTPResponse` |
|
|
190
|
+
| `ConnectionError` | timeout/refused | `.cause` has the underlying transport exception |
|
|
191
|
+
| `ConfigurationError` | — | Raised before HTTP; missing `api_key` or `startup_slug` |
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
## Retry Behavior
|
|
196
|
+
|
|
197
|
+
The SDK retries on transient errors (429, 500, 502, 503, timeouts, connection failures) with exponential backoff and ±20% jitter:
|
|
198
|
+
|
|
199
|
+
| Before Retry | Base Delay | Jitter Range | Actual Range |
|
|
200
|
+
|--------------|------------|--------------|--------------|
|
|
201
|
+
| Retry 1 | 1.0s | ±0.2s | [0.8s, 1.2s] |
|
|
202
|
+
| Retry 2 | 2.0s | ±0.4s | [1.6s, 2.4s] |
|
|
203
|
+
| Retry 3 | 4.0s | ±0.8s | [3.2s, 4.8s] |
|
|
204
|
+
|
|
205
|
+
**Idempotency:** The SDK generates a UUID v4 `event_id` before the first attempt and reuses it across all retries. This ensures the server counts the event exactly once even if the SDK retries.
|
|
206
|
+
|
|
207
|
+
**Disabling retries:**
|
|
208
|
+
|
|
209
|
+
```ruby
|
|
210
|
+
client = Risenexa::Tracking::Client.new(
|
|
211
|
+
api_key: "rxt_live_abc123",
|
|
212
|
+
startup_slug: "my-startup",
|
|
213
|
+
max_retries: 0 # raise immediately on any retryable error
|
|
214
|
+
)
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
---
|
|
218
|
+
|
|
219
|
+
## Development
|
|
220
|
+
|
|
221
|
+
```bash
|
|
222
|
+
git clone https://github.com/envixo/risenexa-tracking-rb.git
|
|
223
|
+
cd risenexa-tracking-rb
|
|
224
|
+
bundle install
|
|
225
|
+
bundle exec rspec
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
The test suite includes all 35 compliance tests from the SDK specification.
|
|
229
|
+
|
|
230
|
+
---
|
|
231
|
+
|
|
232
|
+
## License
|
|
233
|
+
|
|
234
|
+
MIT License. See [LICENSE.txt](LICENSE.txt).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Risenexa
|
|
4
|
+
module Tracking
|
|
5
|
+
# Per-instance client for the Risenexa Tracking SDK.
|
|
6
|
+
#
|
|
7
|
+
# Fully independent of the global configuration — each instance has its own
|
|
8
|
+
# api_key, startup_slug, and other options. Multiple instances can coexist
|
|
9
|
+
# without interference.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# client = Risenexa::Tracking::Client.new(
|
|
13
|
+
# api_key: "rxt_live_abc123",
|
|
14
|
+
# startup_slug: "my-startup"
|
|
15
|
+
# )
|
|
16
|
+
# result = client.track_registration(user_id: "usr_456")
|
|
17
|
+
class Client
|
|
18
|
+
# @param api_key [String, nil] Bearer token with tracking:write scope
|
|
19
|
+
# @param startup_slug [String, nil] Slug identifying the startup
|
|
20
|
+
# @param base_url [String] Base URL for the Risenexa API
|
|
21
|
+
# @param timeout [Integer] Per-request timeout in milliseconds
|
|
22
|
+
# @param max_retries [Integer] Maximum retry attempts (0 disables retries)
|
|
23
|
+
def initialize(api_key: nil, startup_slug: nil, base_url: "https://app.risenexa.com",
|
|
24
|
+
timeout: 2000, max_retries: 3)
|
|
25
|
+
@api_key = api_key
|
|
26
|
+
@startup_slug = startup_slug
|
|
27
|
+
@base_url = base_url
|
|
28
|
+
@timeout = timeout
|
|
29
|
+
@max_retries = max_retries
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Track a user registration event (event_type: "user_registered", action: "add").
|
|
33
|
+
#
|
|
34
|
+
# @param user_id [String] Opaque identifier for the user
|
|
35
|
+
# @param opts [Hash] Optional fields: event_id, occurred_at, metadata, action
|
|
36
|
+
# @return [Result] on success
|
|
37
|
+
# @raise [ConfigurationError] if api_key or startup_slug is missing
|
|
38
|
+
def track_registration(user_id:, **opts)
|
|
39
|
+
track(event_type: "user_registered", user_id: user_id, action: "add", **opts)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Track a user conversion event (event_type: "user_converted", action: "add").
|
|
43
|
+
#
|
|
44
|
+
# @param user_id [String] Opaque identifier for the user
|
|
45
|
+
# @param opts [Hash] Optional fields: event_id, occurred_at, metadata, action
|
|
46
|
+
# @return [Result] on success
|
|
47
|
+
# @raise [ConfigurationError] if api_key or startup_slug is missing
|
|
48
|
+
def track_conversion(user_id:, **opts)
|
|
49
|
+
track(event_type: "user_converted", user_id: user_id, action: "add", **opts)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Low-level track method accepting all HTTP contract fields.
|
|
53
|
+
#
|
|
54
|
+
# @param event_type [String] "user_registered" or "user_converted"
|
|
55
|
+
# @param user_id [String] Opaque identifier for the user
|
|
56
|
+
# @param opts [Hash] Optional: event_id, occurred_at, metadata, action
|
|
57
|
+
# @return [Result] on success
|
|
58
|
+
# @raise [ConfigurationError] if api_key or startup_slug is missing
|
|
59
|
+
def track(event_type:, user_id:, **opts)
|
|
60
|
+
validate_configuration!
|
|
61
|
+
|
|
62
|
+
payload = build_payload(event_type: event_type, user_id: user_id, **opts)
|
|
63
|
+
http_client.post(payload)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
attr_reader :api_key, :startup_slug, :base_url, :timeout, :max_retries
|
|
69
|
+
|
|
70
|
+
# Validate that required configuration is present before making HTTP requests.
|
|
71
|
+
def validate_configuration!
|
|
72
|
+
raise ConfigurationError, "api_key is required" if @api_key.nil? || @api_key.empty?
|
|
73
|
+
raise ConfigurationError, "startup_slug is required" if @startup_slug.nil? || @startup_slug.empty?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Build the event payload hash from the provided arguments.
|
|
77
|
+
def build_payload(event_type:, user_id:, **opts)
|
|
78
|
+
payload = { event_type: event_type, user_id: user_id }
|
|
79
|
+
|
|
80
|
+
payload[:action] = opts[:action] if opts.key?(:action)
|
|
81
|
+
payload[:event_id] = opts[:event_id] if opts.key?(:event_id)
|
|
82
|
+
payload[:occurred_at] = opts[:occurred_at] if opts.key?(:occurred_at)
|
|
83
|
+
payload[:metadata] = opts[:metadata] if opts.key?(:metadata)
|
|
84
|
+
|
|
85
|
+
payload
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Lazily create the HttpClient for this instance.
|
|
89
|
+
def http_client
|
|
90
|
+
@http_client ||= HttpClient.new(
|
|
91
|
+
api_key: @api_key,
|
|
92
|
+
base_url: @base_url,
|
|
93
|
+
startup_slug: @startup_slug,
|
|
94
|
+
timeout: @timeout,
|
|
95
|
+
max_retries: @max_retries
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Risenexa
|
|
4
|
+
module Tracking
|
|
5
|
+
# Configuration options for the Risenexa Tracking SDK.
|
|
6
|
+
#
|
|
7
|
+
# @example Global configuration
|
|
8
|
+
# Risenexa::Tracking.configure do |config|
|
|
9
|
+
# config.api_key = "rxt_live_abc123"
|
|
10
|
+
# config.startup_slug = "my-startup"
|
|
11
|
+
# end
|
|
12
|
+
class Configuration
|
|
13
|
+
# @return [String, nil] Bearer token with tracking:write scope (required)
|
|
14
|
+
attr_accessor :api_key
|
|
15
|
+
|
|
16
|
+
# @return [String, nil] Slug identifying the startup (required)
|
|
17
|
+
attr_accessor :startup_slug
|
|
18
|
+
|
|
19
|
+
# @return [String] Base URL for the Risenexa API
|
|
20
|
+
attr_accessor :base_url
|
|
21
|
+
|
|
22
|
+
# @return [Integer] Per-request timeout in milliseconds (default: 2000)
|
|
23
|
+
attr_accessor :timeout
|
|
24
|
+
|
|
25
|
+
# @return [Integer] Maximum retry attempts, 0 disables retries (default: 3)
|
|
26
|
+
attr_accessor :max_retries
|
|
27
|
+
|
|
28
|
+
def initialize
|
|
29
|
+
@base_url = "https://app.risenexa.com"
|
|
30
|
+
@timeout = 2000
|
|
31
|
+
@max_retries = 3
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Risenexa
|
|
4
|
+
module Tracking
|
|
5
|
+
# Base error class for all Risenexa::Tracking errors.
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
|
|
8
|
+
# Raised when the API key is missing or invalid (HTTP 401).
|
|
9
|
+
# Non-retryable — auth failures are permanent with the same token.
|
|
10
|
+
class AuthenticationError < Error; end
|
|
11
|
+
|
|
12
|
+
# Raised when the token lacks the tracking:write scope (HTTP 403).
|
|
13
|
+
# Non-retryable — scope errors are permanent.
|
|
14
|
+
class AuthorizationError < Error; end
|
|
15
|
+
|
|
16
|
+
# Raised when the request payload is invalid (HTTP 422).
|
|
17
|
+
# Non-retryable — same payload will always fail.
|
|
18
|
+
class ValidationError < Error
|
|
19
|
+
# @return [Array<String>] validation error messages from the response body
|
|
20
|
+
attr_reader :errors
|
|
21
|
+
|
|
22
|
+
def initialize(errors: [])
|
|
23
|
+
@errors = errors
|
|
24
|
+
super(errors.first || "Validation failed")
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Raised when the startup is not found for this account (HTTP 404).
|
|
29
|
+
# Non-retryable — wrong slug/id is a configuration error.
|
|
30
|
+
class StartupNotFoundError < Error; end
|
|
31
|
+
|
|
32
|
+
# Raised when the SDK exhausts retries on 429 responses.
|
|
33
|
+
class RateLimitError < Error
|
|
34
|
+
# @return [Integer, nil] seconds from the Retry-After header
|
|
35
|
+
attr_reader :retry_after
|
|
36
|
+
|
|
37
|
+
def initialize(message = "Rate limit exceeded", retry_after: nil)
|
|
38
|
+
@retry_after = retry_after
|
|
39
|
+
super(message)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Raised when all retry attempts are exhausted on retryable HTTP errors.
|
|
44
|
+
class MaxRetriesExceededError < Error
|
|
45
|
+
# @return [Net::HTTPResponse] the last HTTP response received
|
|
46
|
+
attr_reader :last_response
|
|
47
|
+
|
|
48
|
+
def initialize(message = "Max retries exceeded", last_response: nil)
|
|
49
|
+
@last_response = last_response
|
|
50
|
+
super(message)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Raised when all retry attempts are exhausted due to transport errors.
|
|
55
|
+
class ConnectionError < Error
|
|
56
|
+
# @return [Exception] the underlying transport exception
|
|
57
|
+
attr_reader :cause
|
|
58
|
+
|
|
59
|
+
def initialize(message = "Connection error", cause: nil)
|
|
60
|
+
@cause = cause
|
|
61
|
+
super(message)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Raised when required configuration (api_key, startup_slug) is missing.
|
|
66
|
+
# Raised before any HTTP request is made.
|
|
67
|
+
class ConfigurationError < Error; end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
|
|
8
|
+
module Risenexa
|
|
9
|
+
module Tracking
|
|
10
|
+
# HTTP layer for the Risenexa Tracking SDK.
|
|
11
|
+
#
|
|
12
|
+
# Implements the retry algorithm from SDK-SPEC.md Section 4:
|
|
13
|
+
# - Exponential backoff: BASE_DELAY * (MULTIPLIER ^ attempt), capped at MAX_DELAY
|
|
14
|
+
# - Jitter: ±JITTER_FACTOR of the capped delay
|
|
15
|
+
# - Retryable: 429, 500, 502, 503, timeout, connection errors
|
|
16
|
+
# - Non-retryable: 401, 403, 404, 422 — raise immediately
|
|
17
|
+
# - Idempotency: UUID v4 generated ONCE before first attempt, reused on all retries
|
|
18
|
+
class HttpClient
|
|
19
|
+
BASE_DELAY = 1.0 # seconds
|
|
20
|
+
MULTIPLIER = 2.0
|
|
21
|
+
MAX_DELAY = 30.0 # seconds
|
|
22
|
+
JITTER_FACTOR = 0.20 # ±20% of the capped delay
|
|
23
|
+
|
|
24
|
+
RETRYABLE_STATUSES = [429, 500, 502, 503].freeze
|
|
25
|
+
NON_RETRYABLE_STATUSES = [401, 403, 404, 422].freeze
|
|
26
|
+
|
|
27
|
+
TRACK_PATH = "/api/v1/track"
|
|
28
|
+
|
|
29
|
+
def initialize(api_key:, base_url:, startup_slug:, timeout:, max_retries:)
|
|
30
|
+
@api_key = api_key
|
|
31
|
+
@base_url = base_url
|
|
32
|
+
@startup_slug = startup_slug
|
|
33
|
+
@timeout = timeout
|
|
34
|
+
@max_retries = max_retries
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# POST the event payload to the Risenexa tracking endpoint.
|
|
38
|
+
#
|
|
39
|
+
# Generates a UUID v4 idempotency anchor before the first attempt and
|
|
40
|
+
# reuses it across all retry attempts.
|
|
41
|
+
#
|
|
42
|
+
# @param payload [Hash] Event fields (event_type, user_id, and optional fields)
|
|
43
|
+
# @return [Result] on success (HTTP 202)
|
|
44
|
+
# @raise [AuthenticationError] on HTTP 401
|
|
45
|
+
# @raise [AuthorizationError] on HTTP 403
|
|
46
|
+
# @raise [StartupNotFoundError] on HTTP 404
|
|
47
|
+
# @raise [ValidationError] on HTTP 422
|
|
48
|
+
# @raise [RateLimitError] when 429 exhausts all retries
|
|
49
|
+
# @raise [MaxRetriesExceededError] when 500/502/503 exhausts all retries
|
|
50
|
+
# @raise [ConnectionError] when transport errors exhaust all retries
|
|
51
|
+
def post(payload)
|
|
52
|
+
# Use caller-provided event_id if present; otherwise generate a fresh UUID v4.
|
|
53
|
+
# The event_id is generated ONCE before the first attempt and reused on all retries.
|
|
54
|
+
event_id = payload[:event_id] || SecureRandom.uuid
|
|
55
|
+
full_payload = payload.merge(event_id: event_id)
|
|
56
|
+
|
|
57
|
+
attempt = 0
|
|
58
|
+
|
|
59
|
+
loop do
|
|
60
|
+
begin
|
|
61
|
+
response = perform_request(full_payload)
|
|
62
|
+
rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED,
|
|
63
|
+
SocketError, Errno::EHOSTUNREACH => e
|
|
64
|
+
if attempt >= @max_retries
|
|
65
|
+
raise ConnectionError.new("Connection failed after #{attempt + 1} attempt(s): #{e.message}", cause: e)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
sleep(calculate_backoff(attempt))
|
|
69
|
+
attempt += 1
|
|
70
|
+
next
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
case response.code.to_i
|
|
74
|
+
when 202
|
|
75
|
+
body = parse_json(response.body)
|
|
76
|
+
return Result.new(status_code: 202, event_id: event_id, body: body)
|
|
77
|
+
when *NON_RETRYABLE_STATUSES
|
|
78
|
+
raise_non_retryable!(response)
|
|
79
|
+
when *RETRYABLE_STATUSES
|
|
80
|
+
if attempt >= @max_retries
|
|
81
|
+
status = response.code.to_i
|
|
82
|
+
if status == 429
|
|
83
|
+
raise RateLimitError.new(
|
|
84
|
+
"Rate limit exceeded after #{attempt + 1} attempt(s)",
|
|
85
|
+
retry_after: parse_retry_after(response)
|
|
86
|
+
)
|
|
87
|
+
else
|
|
88
|
+
raise MaxRetriesExceededError.new(
|
|
89
|
+
"Max retries exceeded after #{attempt + 1} attempt(s)",
|
|
90
|
+
last_response: response
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
delay = if response.code.to_i == 429
|
|
96
|
+
parse_retry_after(response) || calculate_backoff(attempt)
|
|
97
|
+
else
|
|
98
|
+
calculate_backoff(attempt)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
sleep(delay)
|
|
102
|
+
attempt += 1
|
|
103
|
+
else
|
|
104
|
+
raise Error, "Unexpected response status: #{response.code}"
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
# Calculate exponential backoff delay for the given attempt index.
|
|
112
|
+
#
|
|
113
|
+
# Formula (from SDK-SPEC.md Section 4):
|
|
114
|
+
# raw = min(BASE_DELAY * (MULTIPLIER ^ attempt), MAX_DELAY)
|
|
115
|
+
# jitter = raw * JITTER_FACTOR * rand(-1.0..1.0)
|
|
116
|
+
# delay = max(raw + jitter, 0.0)
|
|
117
|
+
#
|
|
118
|
+
# @param attempt [Integer] zero-based attempt index (0 = before first retry)
|
|
119
|
+
# @return [Float] delay in seconds
|
|
120
|
+
def calculate_backoff(attempt)
|
|
121
|
+
raw = [BASE_DELAY * (MULTIPLIER**attempt), MAX_DELAY].min
|
|
122
|
+
jitter = raw * JITTER_FACTOR * rand(-1.0..1.0)
|
|
123
|
+
[raw + jitter, 0.0].max
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Parse the Retry-After header from a 429 response.
|
|
127
|
+
#
|
|
128
|
+
# @param response [Net::HTTPResponse]
|
|
129
|
+
# @return [Integer, nil] seconds to wait, or nil if header absent/unparseable
|
|
130
|
+
def parse_retry_after(response)
|
|
131
|
+
value = response["Retry-After"]
|
|
132
|
+
return nil if value.nil? || value.empty?
|
|
133
|
+
|
|
134
|
+
integer_value = Integer(value, 10)
|
|
135
|
+
integer_value
|
|
136
|
+
rescue ArgumentError
|
|
137
|
+
nil
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Raise the appropriate non-retryable error for a given HTTP response.
|
|
141
|
+
#
|
|
142
|
+
# @param response [Net::HTTPResponse]
|
|
143
|
+
# @raise [AuthenticationError, AuthorizationError, StartupNotFoundError, ValidationError]
|
|
144
|
+
def raise_non_retryable!(response)
|
|
145
|
+
body = parse_json(response.body)
|
|
146
|
+
|
|
147
|
+
case response.code.to_i
|
|
148
|
+
when 401
|
|
149
|
+
raise AuthenticationError, body["error"] || "Unauthorized"
|
|
150
|
+
when 403
|
|
151
|
+
raise AuthorizationError, body["error"] || "Forbidden"
|
|
152
|
+
when 404
|
|
153
|
+
raise StartupNotFoundError, body["error"] || "Startup not found"
|
|
154
|
+
when 422
|
|
155
|
+
raise ValidationError.new(errors: body["errors"] || [])
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Perform a single HTTP POST request.
|
|
160
|
+
#
|
|
161
|
+
# @param payload [Hash] Full event payload including event_id
|
|
162
|
+
# @return [Net::HTTPResponse]
|
|
163
|
+
def perform_request(payload)
|
|
164
|
+
uri = URI.parse("#{@base_url}#{TRACK_PATH}")
|
|
165
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
166
|
+
|
|
167
|
+
if uri.scheme == "https"
|
|
168
|
+
require "openssl"
|
|
169
|
+
http.use_ssl = true
|
|
170
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# CRITICAL: Set BOTH timeouts — default is 60s which violates the spec
|
|
174
|
+
timeout_seconds = @timeout / 1000.0
|
|
175
|
+
http.open_timeout = timeout_seconds
|
|
176
|
+
http.read_timeout = timeout_seconds
|
|
177
|
+
|
|
178
|
+
request = Net::HTTP::Post.new(uri.path)
|
|
179
|
+
request["Authorization"] = "Bearer #{@api_key}"
|
|
180
|
+
request["Content-Type"] = "application/json"
|
|
181
|
+
request["Accept"] = "application/json"
|
|
182
|
+
request.body = { event: { startup_slug: @startup_slug, **payload } }.to_json
|
|
183
|
+
|
|
184
|
+
http.request(request)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Parse a JSON string safely, returning empty hash on failure.
|
|
188
|
+
#
|
|
189
|
+
# @param body [String, nil]
|
|
190
|
+
# @return [Hash]
|
|
191
|
+
def parse_json(body)
|
|
192
|
+
return {} if body.nil? || body.empty?
|
|
193
|
+
|
|
194
|
+
JSON.parse(body)
|
|
195
|
+
rescue JSON::ParserError
|
|
196
|
+
{}
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Risenexa
|
|
4
|
+
module Tracking
|
|
5
|
+
# Value object returned on successful event tracking (HTTP 202).
|
|
6
|
+
#
|
|
7
|
+
# @example
|
|
8
|
+
# result = client.track_registration(user_id: "usr_123")
|
|
9
|
+
# result.success? # => true
|
|
10
|
+
# result.status_code # => 202
|
|
11
|
+
# result.event_id # => "550e8400-e29b-41d4-a716-446655440000"
|
|
12
|
+
# result.body # => {"status" => "accepted"}
|
|
13
|
+
class Result
|
|
14
|
+
# @return [Integer] HTTP status code (202 on success)
|
|
15
|
+
attr_reader :status_code
|
|
16
|
+
|
|
17
|
+
# @return [String] UUID v4 sent in the request (idempotency anchor)
|
|
18
|
+
attr_reader :event_id
|
|
19
|
+
|
|
20
|
+
# @return [Hash] Parsed JSON response body
|
|
21
|
+
attr_reader :body
|
|
22
|
+
|
|
23
|
+
def initialize(status_code:, event_id:, body:)
|
|
24
|
+
@status_code = status_code
|
|
25
|
+
@event_id = event_id
|
|
26
|
+
@body = body
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @return [Boolean] true when status_code is 202
|
|
30
|
+
def success?
|
|
31
|
+
@status_code == 202
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
|
|
8
|
+
require_relative "tracking/version"
|
|
9
|
+
require_relative "tracking/errors"
|
|
10
|
+
require_relative "tracking/configuration"
|
|
11
|
+
require_relative "tracking/result"
|
|
12
|
+
require_relative "tracking/http_client"
|
|
13
|
+
require_relative "tracking/client"
|
|
14
|
+
|
|
15
|
+
module Risenexa
|
|
16
|
+
# The Risenexa Tracking module provides global configuration and module-level
|
|
17
|
+
# convenience methods that delegate to a lazily-created Client instance.
|
|
18
|
+
#
|
|
19
|
+
# @example Global configuration and usage
|
|
20
|
+
# Risenexa::Tracking.configure do |config|
|
|
21
|
+
# config.api_key = "rxt_live_abc123"
|
|
22
|
+
# config.startup_slug = "my-startup"
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
# Risenexa::Tracking.track_registration(user_id: "usr_456")
|
|
26
|
+
module Tracking
|
|
27
|
+
class << self
|
|
28
|
+
# @return [Configuration, nil] the current global configuration
|
|
29
|
+
attr_accessor :configuration
|
|
30
|
+
|
|
31
|
+
# Configure the SDK globally.
|
|
32
|
+
#
|
|
33
|
+
# @yieldparam config [Configuration] the configuration object
|
|
34
|
+
def configure
|
|
35
|
+
self.configuration ||= Configuration.new
|
|
36
|
+
yield(configuration)
|
|
37
|
+
self.configuration
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Reset global configuration and the cached client instance.
|
|
41
|
+
# Useful for test isolation.
|
|
42
|
+
def reset_configuration!
|
|
43
|
+
self.configuration = nil
|
|
44
|
+
@client = nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Track a user registration event using the global configuration.
|
|
48
|
+
#
|
|
49
|
+
# @param user_id [String] Opaque identifier for the user
|
|
50
|
+
# @param opts [Hash] Optional fields: event_id, occurred_at, metadata, action
|
|
51
|
+
# @return [Result] on success
|
|
52
|
+
# @raise [ConfigurationError] if global api_key or startup_slug is missing
|
|
53
|
+
def track_registration(user_id:, **opts)
|
|
54
|
+
client.track_registration(user_id: user_id, **opts)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Track a user conversion event using the global configuration.
|
|
58
|
+
#
|
|
59
|
+
# @param user_id [String] Opaque identifier for the user
|
|
60
|
+
# @param opts [Hash] Optional fields: event_id, occurred_at, metadata, action
|
|
61
|
+
# @return [Result] on success
|
|
62
|
+
# @raise [ConfigurationError] if global api_key or startup_slug is missing
|
|
63
|
+
def track_conversion(user_id:, **opts)
|
|
64
|
+
client.track_conversion(user_id: user_id, **opts)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Low-level track method using the global configuration.
|
|
68
|
+
#
|
|
69
|
+
# @param params [Hash] All event fields including event_type, user_id, and optionals
|
|
70
|
+
# @return [Result] on success
|
|
71
|
+
# @raise [ConfigurationError] if global api_key or startup_slug is missing
|
|
72
|
+
def track(**params)
|
|
73
|
+
client.track(**params)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
# Lazily create a Client from the current global configuration.
|
|
79
|
+
def client
|
|
80
|
+
@client ||= begin
|
|
81
|
+
cfg = configuration || Configuration.new
|
|
82
|
+
Client.new(
|
|
83
|
+
api_key: cfg.api_key,
|
|
84
|
+
startup_slug: cfg.startup_slug,
|
|
85
|
+
base_url: cfg.base_url,
|
|
86
|
+
timeout: cfg.timeout,
|
|
87
|
+
max_retries: cfg.max_retries
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: risenexa-tracking
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Patrick Espake
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: rspec
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '3.13'
|
|
19
|
+
type: :development
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '3.13'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: webmock
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '3.23'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '3.23'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: rake
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '13.0'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '13.0'
|
|
54
|
+
description: A Ruby gem to track user registrations and conversions, sending events
|
|
55
|
+
to the Risenexa dashboard with zero runtime dependencies.
|
|
56
|
+
email:
|
|
57
|
+
- patrickespake@gmail.com
|
|
58
|
+
executables: []
|
|
59
|
+
extensions: []
|
|
60
|
+
extra_rdoc_files: []
|
|
61
|
+
files:
|
|
62
|
+
- ".rspec"
|
|
63
|
+
- CHANGELOG.md
|
|
64
|
+
- LICENSE.txt
|
|
65
|
+
- README.md
|
|
66
|
+
- Rakefile
|
|
67
|
+
- lib/risenexa/tracking.rb
|
|
68
|
+
- lib/risenexa/tracking/client.rb
|
|
69
|
+
- lib/risenexa/tracking/configuration.rb
|
|
70
|
+
- lib/risenexa/tracking/errors.rb
|
|
71
|
+
- lib/risenexa/tracking/http_client.rb
|
|
72
|
+
- lib/risenexa/tracking/result.rb
|
|
73
|
+
- lib/risenexa/tracking/version.rb
|
|
74
|
+
homepage: https://github.com/envixo/risenexa-tracking-rb
|
|
75
|
+
licenses:
|
|
76
|
+
- MIT
|
|
77
|
+
metadata:
|
|
78
|
+
allowed_push_host: https://rubygems.org
|
|
79
|
+
homepage_uri: https://github.com/envixo/risenexa-tracking-rb
|
|
80
|
+
source_code_uri: https://github.com/envixo/risenexa-tracking-rb
|
|
81
|
+
changelog_uri: https://github.com/envixo/risenexa-tracking-rb/blob/main/CHANGELOG.md
|
|
82
|
+
rdoc_options: []
|
|
83
|
+
require_paths:
|
|
84
|
+
- lib
|
|
85
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
86
|
+
requirements:
|
|
87
|
+
- - ">="
|
|
88
|
+
- !ruby/object:Gem::Version
|
|
89
|
+
version: 3.1.0
|
|
90
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
91
|
+
requirements:
|
|
92
|
+
- - ">="
|
|
93
|
+
- !ruby/object:Gem::Version
|
|
94
|
+
version: '0'
|
|
95
|
+
requirements: []
|
|
96
|
+
rubygems_version: 3.6.9
|
|
97
|
+
specification_version: 4
|
|
98
|
+
summary: Ruby SDK for tracking user events with Risenexa
|
|
99
|
+
test_files: []
|