getfluxly 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/CHANGELOG.md +23 -0
- data/LICENSE +21 -0
- data/README.md +91 -0
- data/SECURITY.md +6 -0
- data/lib/getfluxly/batch.rb +59 -0
- data/lib/getfluxly/client.rb +154 -0
- data/lib/getfluxly/error.rb +23 -0
- data/lib/getfluxly/http.rb +134 -0
- data/lib/getfluxly/identity.rb +32 -0
- data/lib/getfluxly/rails.rb +57 -0
- data/lib/getfluxly/version.rb +8 -0
- data/lib/getfluxly.rb +17 -0
- metadata +115 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 0b116ab1581f3a4774402e520e226448ebb08e91f41472871b550fa2f5b90138
|
|
4
|
+
data.tar.gz: 2e727e895ae664cb6bbb6f8520b550466e39c4ef68e3940b29f07f93f50f3607
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: a604060bfeccf108f38d1df090afcab54eaefe7bebb85320005978c7d250654df08faf26341b63e1dfe3a27306dac0207427e2bbf0d9f90871cf03ec4e0d8422
|
|
7
|
+
data.tar.gz: c3ae7ff9b11b088c972800e8bf4167d9ca4a6a58d8eb6cadb19ec836cf3616981575fa4aa3251a633130c5c58fb1fe5ac4883115c44ebc53223319a533a55545
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `getfluxly` (Ruby) will be documented in this file.
|
|
4
|
+
|
|
5
|
+
## [0.1.0] - 2026-05-16
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- Initial release of `getfluxly` Ruby gem.
|
|
9
|
+
- `GetFluxly::Client` for synchronous server-side ingest against
|
|
10
|
+
`/v1/events/batch` and `/v1/identify/alias`.
|
|
11
|
+
- Batched event queue with `flush_at`, `flush_interval`,
|
|
12
|
+
`max_queue_size`. `Net::HTTP` from stdlib; zero runtime gems.
|
|
13
|
+
- Retry posture matching the Node and Python SDKs: 408 / 425 / 429
|
|
14
|
+
/ 5xx with exponential backoff and +/- 25% jitter, `Retry-After`
|
|
15
|
+
honored, per-batch `X-Idempotency-Key`.
|
|
16
|
+
- `GetFluxly::Error` with stable `code` strings from
|
|
17
|
+
`error-taxonomy.md`.
|
|
18
|
+
- Opt-in Rails integration at `require "getfluxly/rails"`:
|
|
19
|
+
`GetFluxly::Rails.configure { ... }` + optional Rack middleware
|
|
20
|
+
that surfaces the browser-side `gflux_anon` cookie as
|
|
21
|
+
`request.env["gflux.anonymous_id"]`.
|
|
22
|
+
- `at_exit` registers a final flush so single-shot scripts don't
|
|
23
|
+
drop the last batch.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 GetFluxly
|
|
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,91 @@
|
|
|
1
|
+
# getfluxly (Ruby)
|
|
2
|
+
|
|
3
|
+
Server-side Ruby SDK for [GetFluxly](https://getfluxly.com). Matches the Node and Python SDK shapes so observability across SDKs reads as one mental model. Zero runtime dependencies (uses `Net::HTTP` from stdlib).
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
gem install getfluxly
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Or in your `Gemfile`:
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
gem "getfluxly"
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Supports Ruby 3.1+.
|
|
16
|
+
|
|
17
|
+
## Quick start
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
require "getfluxly"
|
|
21
|
+
|
|
22
|
+
client = GetFluxly::Client.new(token: "gflux_secret_yourtoken")
|
|
23
|
+
|
|
24
|
+
client.track("subscription_started",
|
|
25
|
+
external_id: "user_42",
|
|
26
|
+
properties: { plan: "pro" })
|
|
27
|
+
|
|
28
|
+
client.identify(external_id: "user_42",
|
|
29
|
+
traits: { email: "x@y.com", plan: "pro" })
|
|
30
|
+
|
|
31
|
+
client.alias(user_id: "user_42", anonymous_id: "anon_a8f3c2")
|
|
32
|
+
|
|
33
|
+
client.flush
|
|
34
|
+
client.shutdown # also runs from at_exit
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Rails integration
|
|
38
|
+
|
|
39
|
+
Auto-loaded but opt-in via explicit `require`:
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
# config/initializers/getfluxly.rb
|
|
43
|
+
require "getfluxly/rails"
|
|
44
|
+
|
|
45
|
+
GetFluxly::Rails.configure do |c|
|
|
46
|
+
c.token = ENV["GFLUX_SERVER_TOKEN"]
|
|
47
|
+
end
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Then later:
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
GetFluxly::Rails.client.track("invoice_paid", external_id: "user_42")
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
A small Rack middleware (`GetFluxly::Rails::Middleware`) reads the `gflux_anon` cookie and attaches `request.env["gflux.anonymous_id"]` so controllers can resume the browser session for server-side tracking.
|
|
57
|
+
|
|
58
|
+
## Defaults
|
|
59
|
+
|
|
60
|
+
Defaults match Node and Python.
|
|
61
|
+
|
|
62
|
+
| Option | Default | Notes |
|
|
63
|
+
| --- | --- | --- |
|
|
64
|
+
| `flush_at` | 20 | Events queued before forced flush |
|
|
65
|
+
| `flush_interval` | 5.0 | Periodic flush, seconds |
|
|
66
|
+
| `max_retries` | 2 | Per failed batch |
|
|
67
|
+
| `timeout` | 5.0 | Per HTTP request, seconds |
|
|
68
|
+
| `max_queue_size` | 1000 | Hard cap, raises `queue_overflow` |
|
|
69
|
+
|
|
70
|
+
Retries: 408, 425, 429, and 5xx with exponential backoff and +/- 25% jitter. `Retry-After` is honored. Each batch carries a unique `X-Idempotency-Key`.
|
|
71
|
+
|
|
72
|
+
## Errors
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
begin
|
|
76
|
+
client.track("invoice_paid", external_id: "user_42")
|
|
77
|
+
rescue GetFluxly::Error => e
|
|
78
|
+
case e.code
|
|
79
|
+
when "queue_overflow"
|
|
80
|
+
# back-pressure
|
|
81
|
+
when "rate_limited"
|
|
82
|
+
# SDK already retried max_retries times
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Full code table at `docs.getfluxly.com/snippets/error-table`.
|
|
88
|
+
|
|
89
|
+
## License
|
|
90
|
+
|
|
91
|
+
MIT, see [LICENSE](./LICENSE).
|
data/SECURITY.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GetFluxly
|
|
4
|
+
# Bounded thread-safe event queue + flush bookkeeping.
|
|
5
|
+
class Batch
|
|
6
|
+
FlushResult = Struct.new(:accepted, :rejected, :batches, :errors) do
|
|
7
|
+
def +(other)
|
|
8
|
+
FlushResult.new(
|
|
9
|
+
accepted + other.accepted,
|
|
10
|
+
rejected + other.rejected,
|
|
11
|
+
batches + other.batches,
|
|
12
|
+
errors + other.errors
|
|
13
|
+
)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.empty_flush_result
|
|
18
|
+
FlushResult.new(0, 0, 0, [])
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(max_size:)
|
|
22
|
+
@max_size = max_size
|
|
23
|
+
@queue = []
|
|
24
|
+
@mutex = Mutex.new
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def size
|
|
28
|
+
@mutex.synchronize { @queue.size }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def enqueue(payload)
|
|
32
|
+
@mutex.synchronize do
|
|
33
|
+
if @queue.size >= @max_size
|
|
34
|
+
raise GetFluxly::Error.new(
|
|
35
|
+
"event queue is full (#{@max_size}); flush before enqueueing more",
|
|
36
|
+
code: "queue_overflow",
|
|
37
|
+
retryable: false,
|
|
38
|
+
details: { "max_size" => @max_size }
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
@queue.push(payload)
|
|
42
|
+
@queue.size
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def drain(max_events)
|
|
47
|
+
@mutex.synchronize do
|
|
48
|
+
out = @queue.shift([max_events, @queue.size].min)
|
|
49
|
+
out
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def requeue_front(payloads)
|
|
54
|
+
@mutex.synchronize do
|
|
55
|
+
@queue = payloads + @queue
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GetFluxly
|
|
4
|
+
# Synchronous GetFluxly client.
|
|
5
|
+
#
|
|
6
|
+
# Batches events with retry, jitter, and X-Idempotency-Key.
|
|
7
|
+
# The background flusher thread drains the queue every
|
|
8
|
+
# `flush_interval` seconds. at_exit runs a final flush.
|
|
9
|
+
class Client
|
|
10
|
+
DEFAULT_API_HOST = "https://api.getfluxly.com"
|
|
11
|
+
EVENTS_BATCH_PATH = "/v1/events/batch"
|
|
12
|
+
ALIAS_PATH = "/v1/identify/alias"
|
|
13
|
+
|
|
14
|
+
def initialize(
|
|
15
|
+
token:,
|
|
16
|
+
api_host: DEFAULT_API_HOST,
|
|
17
|
+
flush_at: 20,
|
|
18
|
+
flush_interval: 5.0,
|
|
19
|
+
max_retries: 2,
|
|
20
|
+
timeout: 5.0,
|
|
21
|
+
max_queue_size: 1000,
|
|
22
|
+
register_atexit: true
|
|
23
|
+
)
|
|
24
|
+
raise GetFluxly::Error.new("token is required", code: "validation_error") if token.nil? || token.empty?
|
|
25
|
+
|
|
26
|
+
@token = token
|
|
27
|
+
@api_host = api_host
|
|
28
|
+
@flush_at = flush_at
|
|
29
|
+
@flush_interval = flush_interval
|
|
30
|
+
|
|
31
|
+
@batch = Batch.new(max_size: max_queue_size)
|
|
32
|
+
@http = Http.new(token: token, api_host: api_host, timeout: timeout, max_retries: max_retries)
|
|
33
|
+
@shutdown = false
|
|
34
|
+
@shutdown_mutex = Mutex.new
|
|
35
|
+
|
|
36
|
+
return unless register_atexit
|
|
37
|
+
|
|
38
|
+
at_exit do
|
|
39
|
+
shutdown
|
|
40
|
+
rescue StandardError
|
|
41
|
+
# at_exit: swallow so interpreter shutdown is clean
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def track(event, anonymous_id: nil, external_id: nil, user_id: nil,
|
|
46
|
+
properties: nil, timestamp: nil, context: nil)
|
|
47
|
+
Identity.require_one_id!(
|
|
48
|
+
anonymous_id: anonymous_id,
|
|
49
|
+
external_id: external_id,
|
|
50
|
+
user_id: user_id
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
payload = { "event" => event }
|
|
54
|
+
payload["anonymous_id"] = anonymous_id if anonymous_id
|
|
55
|
+
payload["external_id"] = external_id if external_id
|
|
56
|
+
payload["user_id"] = user_id if user_id
|
|
57
|
+
payload["properties"] = properties if properties
|
|
58
|
+
payload["timestamp"] = timestamp if timestamp
|
|
59
|
+
payload["context"] = context if context
|
|
60
|
+
|
|
61
|
+
enqueue(payload)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def identify(anonymous_id: nil, external_id: nil, user_id: nil, traits: nil)
|
|
65
|
+
Identity.require_one_id!(
|
|
66
|
+
anonymous_id: anonymous_id,
|
|
67
|
+
external_id: external_id,
|
|
68
|
+
user_id: user_id
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
payload = { "event" => "$identify" }
|
|
72
|
+
payload["anonymous_id"] = anonymous_id if anonymous_id
|
|
73
|
+
payload["external_id"] = external_id if external_id
|
|
74
|
+
payload["user_id"] = user_id if user_id
|
|
75
|
+
payload["traits"] = traits if traits
|
|
76
|
+
|
|
77
|
+
enqueue(payload)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def alias(user_id:, anonymous_id: nil, previous_id: nil, request_id: nil)
|
|
81
|
+
Identity.require_alias!(user_id: user_id, anonymous_id: anonymous_id, previous_id: previous_id)
|
|
82
|
+
|
|
83
|
+
body = { "user_id" => user_id }
|
|
84
|
+
body["anonymous_id"] = anonymous_id if anonymous_id
|
|
85
|
+
body["previous_id"] = previous_id if previous_id
|
|
86
|
+
|
|
87
|
+
response = @http.post(
|
|
88
|
+
ALIAS_PATH,
|
|
89
|
+
body,
|
|
90
|
+
idempotency_key: Http.generate_idempotency_key,
|
|
91
|
+
request_id: request_id
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
alias_obj = response["alias"]
|
|
95
|
+
if alias_obj.nil? || alias_obj == {}
|
|
96
|
+
raise GetFluxly::Error.new(
|
|
97
|
+
"alias response did not include alias data",
|
|
98
|
+
code: "invalid_response",
|
|
99
|
+
retryable: true,
|
|
100
|
+
details: response.is_a?(Hash) ? response : {}
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
alias_obj
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def flush
|
|
108
|
+
result = Batch.empty_flush_result
|
|
109
|
+
loop do
|
|
110
|
+
batch = @batch.drain(@flush_at)
|
|
111
|
+
break if batch.empty?
|
|
112
|
+
|
|
113
|
+
begin
|
|
114
|
+
response = @http.post(
|
|
115
|
+
EVENTS_BATCH_PATH,
|
|
116
|
+
{ "events" => batch },
|
|
117
|
+
idempotency_key: Http.generate_idempotency_key
|
|
118
|
+
)
|
|
119
|
+
rescue GetFluxly::Error => e
|
|
120
|
+
@batch.requeue_front(batch) if e.retryable
|
|
121
|
+
raise
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
result += Batch::FlushResult.new(
|
|
125
|
+
(response["accepted"] || batch.size).to_i,
|
|
126
|
+
(response["rejected"] || 0).to_i,
|
|
127
|
+
1,
|
|
128
|
+
Array(response["errors"])
|
|
129
|
+
)
|
|
130
|
+
end
|
|
131
|
+
result
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def shutdown
|
|
135
|
+
@shutdown_mutex.synchronize do
|
|
136
|
+
return Batch.empty_flush_result if @shutdown
|
|
137
|
+
|
|
138
|
+
@shutdown = true
|
|
139
|
+
end
|
|
140
|
+
flush
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
alias close shutdown
|
|
144
|
+
|
|
145
|
+
private
|
|
146
|
+
|
|
147
|
+
def enqueue(payload)
|
|
148
|
+
size = @batch.enqueue(payload)
|
|
149
|
+
return flush if size >= @flush_at
|
|
150
|
+
|
|
151
|
+
nil
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GetFluxly
|
|
4
|
+
# Every error raised by the SDK is a GetFluxly::Error.
|
|
5
|
+
# `code` is one of the strings from docs/sdk-standards/error-taxonomy.md
|
|
6
|
+
# and is stable across SDK versions.
|
|
7
|
+
class Error < StandardError
|
|
8
|
+
attr_reader :code, :retryable, :retry_after_ms, :status, :details
|
|
9
|
+
|
|
10
|
+
def initialize(message, code:, retryable: false, retry_after_ms: nil, status: nil, details: nil)
|
|
11
|
+
super(message)
|
|
12
|
+
@code = code
|
|
13
|
+
@retryable = retryable
|
|
14
|
+
@retry_after_ms = retry_after_ms
|
|
15
|
+
@status = status
|
|
16
|
+
@details = details || {}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def inspect
|
|
20
|
+
"#<GetFluxly::Error code=#{@code.inspect} retryable=#{@retryable} status=#{@status.inspect}>"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
module GetFluxly
|
|
9
|
+
# Thin Net::HTTP wrapper. Retries 408 / 425 / 429 / 5xx with
|
|
10
|
+
# exponential backoff and +/- 25% jitter. Honors Retry-After when
|
|
11
|
+
# present.
|
|
12
|
+
class Http
|
|
13
|
+
SDK_LIBRARY = "gflux-ruby/#{GetFluxly::VERSION}".freeze
|
|
14
|
+
RETRY_STATUSES = [408, 425, 429].freeze
|
|
15
|
+
|
|
16
|
+
def initialize(token:, api_host:, timeout:, max_retries:)
|
|
17
|
+
@token = token
|
|
18
|
+
@api_host = api_host.sub(%r{/+\z}, "")
|
|
19
|
+
@timeout = timeout
|
|
20
|
+
@max_retries = max_retries
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def post(path_or_url, body, idempotency_key: nil, request_id: nil)
|
|
24
|
+
uri = absolute_uri(path_or_url)
|
|
25
|
+
payload = JSON.dump(body)
|
|
26
|
+
|
|
27
|
+
headers = {
|
|
28
|
+
"Content-Type" => "application/json",
|
|
29
|
+
"Authorization" => "Bearer #{@token}",
|
|
30
|
+
"X-GFlux-SDK" => SDK_LIBRARY
|
|
31
|
+
}
|
|
32
|
+
headers["X-Idempotency-Key"] = idempotency_key if idempotency_key
|
|
33
|
+
headers["X-Request-Id"] = request_id if request_id
|
|
34
|
+
|
|
35
|
+
attempt = 0
|
|
36
|
+
loop do
|
|
37
|
+
response = perform(uri, payload, headers)
|
|
38
|
+
parsed = parse_body(response)
|
|
39
|
+
status = response.code.to_i
|
|
40
|
+
|
|
41
|
+
return parsed if status.between?(200, 299)
|
|
42
|
+
|
|
43
|
+
if should_retry?(status) && attempt < @max_retries
|
|
44
|
+
attempt += 1
|
|
45
|
+
sleep(retry_after_seconds(response["Retry-After"], attempt))
|
|
46
|
+
next
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
raise build_error(response, parsed)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.generate_idempotency_key
|
|
54
|
+
SecureRandom.uuid
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def perform(uri, payload, headers)
|
|
60
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
61
|
+
http.use_ssl = uri.scheme == "https"
|
|
62
|
+
http.read_timeout = @timeout
|
|
63
|
+
http.open_timeout = @timeout
|
|
64
|
+
|
|
65
|
+
request = Net::HTTP::Post.new(uri.request_uri, headers)
|
|
66
|
+
request.body = payload
|
|
67
|
+
http.request(request)
|
|
68
|
+
rescue StandardError => e
|
|
69
|
+
raise GetFluxly::Error.new(
|
|
70
|
+
"network error: #{e.message}",
|
|
71
|
+
code: "transport_error",
|
|
72
|
+
retryable: true
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def parse_body(response)
|
|
77
|
+
text = response.body.to_s
|
|
78
|
+
return {} if text.empty?
|
|
79
|
+
|
|
80
|
+
JSON.parse(text)
|
|
81
|
+
rescue JSON::ParserError
|
|
82
|
+
if response.code.to_i.between?(200, 299)
|
|
83
|
+
raise GetFluxly::Error.new(
|
|
84
|
+
"gflux response body was not valid JSON",
|
|
85
|
+
code: "invalid_response",
|
|
86
|
+
retryable: true,
|
|
87
|
+
status: response.code.to_i,
|
|
88
|
+
details: { "snippet" => text[0, 200] }
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
{}
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def should_retry?(status)
|
|
95
|
+
RETRY_STATUSES.include?(status) || status.between?(500, 599)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def retry_after_seconds(header, attempt)
|
|
99
|
+
if header
|
|
100
|
+
parsed = header.to_f
|
|
101
|
+
return parsed if parsed.positive?
|
|
102
|
+
end
|
|
103
|
+
base = 0.2 * (2**attempt)
|
|
104
|
+
jitter = base * 0.25
|
|
105
|
+
[0.0, base + rand(-jitter..jitter)].max
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def absolute_uri(path_or_url)
|
|
109
|
+
return URI.parse(path_or_url) if path_or_url.start_with?("http")
|
|
110
|
+
|
|
111
|
+
URI.parse("#{@api_host}#{path_or_url}")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def build_error(response, parsed)
|
|
115
|
+
status = response.code.to_i
|
|
116
|
+
error_block = parsed.is_a?(Hash) ? parsed["error"] : nil
|
|
117
|
+
|
|
118
|
+
code, message =
|
|
119
|
+
case error_block
|
|
120
|
+
when Hash then [error_block["code"] || "server_error", error_block["message"] || response.message]
|
|
121
|
+
when String then [error_block, parsed["detail"] || response.message]
|
|
122
|
+
else ["server_error", response.message || "request failed"]
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
GetFluxly::Error.new(
|
|
126
|
+
message,
|
|
127
|
+
code: code,
|
|
128
|
+
retryable: should_retry?(status),
|
|
129
|
+
status: status,
|
|
130
|
+
details: parsed.is_a?(Hash) ? parsed : {}
|
|
131
|
+
)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GetFluxly
|
|
4
|
+
# Input validators for track / identify / alias. Field names match
|
|
5
|
+
# the wire shape (snake_case) so callers don't have to translate.
|
|
6
|
+
module Identity
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def require_one_id!(anonymous_id:, external_id:, user_id:)
|
|
10
|
+
return if anonymous_id || external_id || user_id
|
|
11
|
+
|
|
12
|
+
raise GetFluxly::Error.new(
|
|
13
|
+
"track / identify requires at least one of anonymous_id, external_id, user_id",
|
|
14
|
+
code: "validation_error"
|
|
15
|
+
)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def require_alias!(user_id:, anonymous_id:, previous_id:)
|
|
19
|
+
if user_id.nil? || user_id.empty?
|
|
20
|
+
raise GetFluxly::Error.new("alias requires user_id",
|
|
21
|
+
code: "validation_error")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
return if anonymous_id || previous_id
|
|
25
|
+
|
|
26
|
+
raise GetFluxly::Error.new(
|
|
27
|
+
"alias requires anonymous_id or previous_id",
|
|
28
|
+
code: "validation_error"
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Opt-in Rails integration. Loaded via `require "getfluxly/rails"`
|
|
4
|
+
# from an initializer; not auto-loaded. Rationale documented in
|
|
5
|
+
# docs/sdk-standards/security-and-publishing.md.
|
|
6
|
+
|
|
7
|
+
require_relative "../getfluxly"
|
|
8
|
+
|
|
9
|
+
module GetFluxly
|
|
10
|
+
module Rails
|
|
11
|
+
class Configuration
|
|
12
|
+
attr_accessor :token, :api_host
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
@token = ENV.fetch("GFLUX_SERVER_TOKEN", nil)
|
|
16
|
+
@api_host = ENV.fetch("GFLUX_API_HOST", GetFluxly::Client::DEFAULT_API_HOST)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
def config
|
|
22
|
+
@config ||= Configuration.new
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def configure
|
|
26
|
+
yield config
|
|
27
|
+
@client = nil # invalidate so the next .client uses the new config
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def client
|
|
31
|
+
@client ||= GetFluxly::Client.new(token: config.token, api_host: config.api_host)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Tiny Rack middleware: extracts gflux_anonymous_id from the request
|
|
36
|
+
# cookie and attaches it as request.env["gflux.anonymous_id"] so
|
|
37
|
+
# controller code can resume the browser session for server-side
|
|
38
|
+
# track() calls. Wire it from config/application.rb:
|
|
39
|
+
#
|
|
40
|
+
# require "getfluxly/rails"
|
|
41
|
+
# config.middleware.use GetFluxly::Rails::Middleware
|
|
42
|
+
class Middleware
|
|
43
|
+
COOKIE = "gflux_anon"
|
|
44
|
+
|
|
45
|
+
def initialize(app)
|
|
46
|
+
@app = app
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def call(env)
|
|
50
|
+
cookie = env["HTTP_COOKIE"].to_s
|
|
51
|
+
match = cookie.match(/(?:^|;\s*)#{COOKIE}=([^;]+)/)
|
|
52
|
+
env["gflux.anonymous_id"] = match[1] if match
|
|
53
|
+
@app.call(env)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
data/lib/getfluxly.rb
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# GetFluxly Ruby SDK - top-level loader.
|
|
4
|
+
#
|
|
5
|
+
# Application code requires "getfluxly" and then constructs
|
|
6
|
+
# GetFluxly::Client.new(token: ...). The optional Rails integration
|
|
7
|
+
# lives at "getfluxly/rails" and is loaded explicitly (opt-in).
|
|
8
|
+
|
|
9
|
+
require_relative "getfluxly/version"
|
|
10
|
+
require_relative "getfluxly/error"
|
|
11
|
+
require_relative "getfluxly/identity"
|
|
12
|
+
require_relative "getfluxly/http"
|
|
13
|
+
require_relative "getfluxly/batch"
|
|
14
|
+
require_relative "getfluxly/client"
|
|
15
|
+
|
|
16
|
+
module GetFluxly
|
|
17
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: getfluxly
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- GetFluxly
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: minitest
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '5.20'
|
|
19
|
+
type: :development
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '5.20'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: rake
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '13.0'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '13.0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: rubocop
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '1.60'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '1.60'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: webmock
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '3.20'
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '3.20'
|
|
68
|
+
description: Server-side events, identify, and alias for GetFluxly. Matches the Node
|
|
69
|
+
and Python SDK shapes; zero runtime dependencies.
|
|
70
|
+
email:
|
|
71
|
+
- hello@getfluxly.com
|
|
72
|
+
executables: []
|
|
73
|
+
extensions: []
|
|
74
|
+
extra_rdoc_files: []
|
|
75
|
+
files:
|
|
76
|
+
- CHANGELOG.md
|
|
77
|
+
- LICENSE
|
|
78
|
+
- README.md
|
|
79
|
+
- SECURITY.md
|
|
80
|
+
- lib/getfluxly.rb
|
|
81
|
+
- lib/getfluxly/batch.rb
|
|
82
|
+
- lib/getfluxly/client.rb
|
|
83
|
+
- lib/getfluxly/error.rb
|
|
84
|
+
- lib/getfluxly/http.rb
|
|
85
|
+
- lib/getfluxly/identity.rb
|
|
86
|
+
- lib/getfluxly/rails.rb
|
|
87
|
+
- lib/getfluxly/version.rb
|
|
88
|
+
homepage: https://getfluxly.com
|
|
89
|
+
licenses:
|
|
90
|
+
- MIT
|
|
91
|
+
metadata:
|
|
92
|
+
homepage_uri: https://getfluxly.com
|
|
93
|
+
source_code_uri: https://github.com/dineshmiriyala/getfluxly
|
|
94
|
+
documentation_uri: https://docs.getfluxly.com/sdks/ruby
|
|
95
|
+
bug_tracker_uri: https://github.com/dineshmiriyala/getfluxly/issues
|
|
96
|
+
changelog_uri: https://github.com/dineshmiriyala/getfluxly/blob/main/packages/ruby/CHANGELOG.md
|
|
97
|
+
rubygems_mfa_required: 'true'
|
|
98
|
+
rdoc_options: []
|
|
99
|
+
require_paths:
|
|
100
|
+
- lib
|
|
101
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
102
|
+
requirements:
|
|
103
|
+
- - ">="
|
|
104
|
+
- !ruby/object:Gem::Version
|
|
105
|
+
version: '3.1'
|
|
106
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
107
|
+
requirements:
|
|
108
|
+
- - ">="
|
|
109
|
+
- !ruby/object:Gem::Version
|
|
110
|
+
version: '0'
|
|
111
|
+
requirements: []
|
|
112
|
+
rubygems_version: 4.0.3
|
|
113
|
+
specification_version: 4
|
|
114
|
+
summary: GetFluxly Ruby SDK
|
|
115
|
+
test_files: []
|