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 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
@@ -0,0 +1,2 @@
1
+ --require spec_helper
2
+ --format documentation
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,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
@@ -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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Risenexa
4
+ module Tracking
5
+ VERSION = "0.1.0"
6
+ end
7
+ 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: []