cekat-event-sdk 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/COMPATIBILITY.md +48 -0
- data/LICENSE +21 -0
- data/README.md +138 -0
- data/lib/cekat-event-sdk.rb +3 -0
- data/lib/cekat_event_sdk/acknowledgement.rb +7 -0
- data/lib/cekat_event_sdk/client.rb +146 -0
- data/lib/cekat_event_sdk/errors.rb +63 -0
- data/lib/cekat_event_sdk/event_input.rb +15 -0
- data/lib/cekat_event_sdk/payload_builder.rb +146 -0
- data/lib/cekat_event_sdk/rack/middleware.rb +32 -0
- data/lib/cekat_event_sdk/rails.rb +52 -0
- data/lib/cekat_event_sdk/response_decoder.rb +72 -0
- data/lib/cekat_event_sdk/retry_policy.rb +39 -0
- data/lib/cekat_event_sdk/transport.rb +90 -0
- data/lib/cekat_event_sdk/version.rb +5 -0
- data/lib/cekat_event_sdk/visitor_context.rb +29 -0
- data/lib/cekat_event_sdk/visitor_id_resolver.rb +40 -0
- data/lib/cekat_event_sdk.rb +25 -0
- metadata +60 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 2916e118d56407e2f21b5e4520936221b46bc3d46a5744e24c1612f8b755a2ca
|
|
4
|
+
data.tar.gz: 206f9d1eb0068e404d1f1b510c767be62889b8de15877dace4b73320f3b9d05b
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: f33ec335fe1a10dcd10272a112ce49f80e2253dc786c9d8aeb1b7c51fb7df260ec9195fee184a6e4fafa2b7870d9c04744d1856cf6f2badeb5c3c9aee4d21700
|
|
7
|
+
data.tar.gz: b305914695eaaf96983c1b2ced8f4f5c78ead1d9b27f52b58c9afc0b330c21499e1de0ed240349e3736f8853ad3a7b195e44e81a6a8bd74ee6eb77a3809962c5
|
data/COMPATIBILITY.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Ruby SDK compatibility evidence
|
|
2
|
+
|
|
3
|
+
Retrieved: 2026-09-13 (UTC).
|
|
4
|
+
|
|
5
|
+
## Official sources
|
|
6
|
+
|
|
7
|
+
- Ruby maintenance branches: <https://www.ruby-lang.org/en/downloads/branches/> and <https://endoflife.date/api/v1/products/ruby/>
|
|
8
|
+
- Rails maintenance policy: <https://rubyonrails.org/maintenance> and <https://endoflife.date/api/v1/products/rails/>
|
|
9
|
+
- RubyGems metadata: `https://rubygems.org/api/v1/gems/<gem>.json`, `https://rubygems.org/api/v1/versions/<gem>.json`, and `https://rubygems.org/api/v2/rubygems/<gem>/versions/<version>.json`
|
|
10
|
+
- Security advisories: `bundle-audit check --update` (ruby-advisory-db)
|
|
11
|
+
|
|
12
|
+
## Ruby
|
|
13
|
+
|
|
14
|
+
| Line | Status | End of life | Observed patch | `Fiber[]` / `Fiber[]=` |
|
|
15
|
+
| --- | --- | --- | --- | --- |
|
|
16
|
+
| 4.0 | normal maintenance | 2029-03-31 | 4.0.6 | available |
|
|
17
|
+
| 3.4 | normal maintenance | 2028-03-31 | 3.4.10 | available |
|
|
18
|
+
| 3.3 | security maintenance | 2027-03-31 | 3.3.12 | available |
|
|
19
|
+
| 3.2 | end of life | 2026-03-31 | 3.2.11 | — |
|
|
20
|
+
|
|
21
|
+
`required_ruby_version` is `>= 3.3.0`: the oldest maintained line. The visitor scope requires the fiber storage API (`Fiber[]`, Ruby 3.2+), verified on every tested interpreter.
|
|
22
|
+
|
|
23
|
+
## Frameworks
|
|
24
|
+
|
|
25
|
+
| Framework | Supported | Evidence |
|
|
26
|
+
| --- | --- | --- |
|
|
27
|
+
| Rails | 8.0, 8.1 | 8.1.3.1 (security support until 2027-10-10); 8.0.5.1 (security support until **2026-11-07**); both require Ruby >= 3.2.0. Rails 7.2 reached end of life on 2026-08-09. |
|
|
28
|
+
| Rack | 2.2, 3.1, 3.2 | 3.2.7, 3.1.22, and 2.2.24 were all released 2026-08-13. The middleware uses only the Rack env protocol, so the gem declares no Rack dependency. |
|
|
29
|
+
|
|
30
|
+
The gem has no runtime dependencies: it uses Ruby's default `json`, `net-http`, `securerandom`, `time`, and `uri` gems.
|
|
31
|
+
|
|
32
|
+
## Development dependencies (latest observed)
|
|
33
|
+
|
|
34
|
+
rspec 3.13.2, rubocop 1.91.0, json_schemer 2.5.0, bundler-audit 0.9.3, rake 13.4.2. Bundler 2.5.22 ships with Ruby 3.3.12 and 4.0.16 with Ruby 4.0.6.
|
|
35
|
+
|
|
36
|
+
## Execution matrix (2026-09-13)
|
|
37
|
+
|
|
38
|
+
Every row ran `rake spec` (core, Rack, transport, packaging, and Rails specs), RuboCop, and the shared conformance suite against `conformance/mock-ingest-server`: 54 cases passed and the 3 caller-cancellation cases were reported `not_applicable`.
|
|
39
|
+
|
|
40
|
+
| Ruby | Rails | Rack | json | Specs |
|
|
41
|
+
| --- | --- | --- | --- | --- |
|
|
42
|
+
| 3.3.12 | 8.0.5.1 | 2.2.24 | 2.21.2 | 65 core + 6 Rails passed |
|
|
43
|
+
| 3.4.10 | 8.1.3.1 | 3.1.22 | 3.0.2 | 65 core + 6 Rails passed |
|
|
44
|
+
| 4.0.6 | 8.1.3.1 | 3.2.7 | 3.0.2 | 65 core + 6 Rails passed |
|
|
45
|
+
|
|
46
|
+
The Rails 8.0 row pins `json` to 2.x (`JSON_VERSION="~> 2.7"`). Rails 8.0.5.1's `render json:` raises `ArgumentError: unknown keyword: quirks_mode` with json 3.0; this is a Rails 8.0/json 3 incompatibility, not an SDK constraint, and Rails 8.0 applications lock json 2.x. The SDK itself passes with json 2.21 and 3.0.
|
|
47
|
+
|
|
48
|
+
`scripts/package` passed on Ruby 4.0.6, including `bundle-audit check --update` with no vulnerabilities found.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Cekat AI
|
|
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,138 @@
|
|
|
1
|
+
# Cekat Ruby Event SDK
|
|
2
|
+
|
|
3
|
+
`cekat-event-sdk` submits identity-bearing Cekat events from Ruby backends and automatically attaches the browser visitor ID (from the `X-Cekat-Visitor-ID` header or `_cekat_visitor_id` cookie) of the request being handled.
|
|
4
|
+
|
|
5
|
+
Requires Ruby 3.3 or newer. No runtime gem dependencies. Integrations: Rack middleware (Rack 2.2 and 3) and Rails 8.0–8.1.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
gem "cekat-event-sdk"
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Construct a client and submit events
|
|
12
|
+
|
|
13
|
+
Construct one client and reuse it; it is thread-safe. Only the access token is required.
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
require "cekat_event_sdk"
|
|
17
|
+
|
|
18
|
+
CEKAT = CekatEventSdk::Client.new(access_token: ENV.fetch("CEKAT_ACCESS_TOKEN"))
|
|
19
|
+
|
|
20
|
+
CEKAT.user_registration(email: "person@example.com", contact_name: "Person")
|
|
21
|
+
CEKAT.user_login(phone_number: "+628123456789")
|
|
22
|
+
CEKAT.order_created(email: "person@example.com", properties: { order_id: "o-1" })
|
|
23
|
+
CEKAT.order_paid(email: "person@example.com", properties: { order_id: "o-1" }, amount: 125_000, currency: "IDR")
|
|
24
|
+
ack = CEKAT.custom_event("wishlist_updated", email: "person@example.com")
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Every event method also accepts a `CekatEventSdk::EventInput` or a Hash instead of keyword attributes. `order_paid` additionally requires a finite `amount:` (Integer, Float, BigDecimal, or Rational) and a nonblank `currency:`, sent as the `amount` and `currency` properties; do not also put those keys in `properties`.
|
|
28
|
+
|
|
29
|
+
An `Acknowledgement` means Cekat accepted the event for **asynchronous processing**. It does not confirm durable storage, identity resolution, delivery completion, or analytics availability.
|
|
30
|
+
|
|
31
|
+
At least one of `email` or `phone_number` must be nonblank. Identity and contact strings are sent unchanged. `properties` is `nil` (omitted) or a Hash with String or Symbol keys; values may be `nil`, `true`, `false`, UTF-8 Strings, Symbols (sent as strings), finite numbers, Arrays, and Hashes. Integers must be within ±9,007,199,254,740,991; BigDecimal and Rational are sent as floats. Other objects (including `Time` — use `time.iso8601`), cycles, non-String keys, and duplicate keys after Symbol conversion raise `CekatEventSdk::ValidationError` before any request.
|
|
32
|
+
|
|
33
|
+
## Event IDs and timestamps
|
|
34
|
+
|
|
35
|
+
Every event carries an `event_id` and an `occurred_at` timestamp. When `event_id` is blank the SDK generates a random UUID, and when `occurred_at` (a `Time`, `DateTime`, or `ActiveSupport::TimeWithZone`) is omitted it uses the time of the call. Both are fixed before the first attempt and reused by every retry, so Cekat can recognize retried deliveries. Supply your own `event_id` when the same business event may be sent more than once:
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
CEKAT.order_paid(email: order.email, event_id: "order-paid-#{order.id}", occurred_at: order.paid_at,
|
|
39
|
+
amount: order.total, currency: order.currency)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Request visitor context
|
|
43
|
+
|
|
44
|
+
Install the integration at the request boundary. While the request is handled, event calls attach its visitor ID automatically. A nonblank explicit `visitor_id:` takes precedence; a blank one falls back to the request. Visitor IDs are untrusted correlation data: never use them for authentication or authorization.
|
|
45
|
+
|
|
46
|
+
### Rails
|
|
47
|
+
|
|
48
|
+
The Railtie inserts `CekatEventSdk::Rails::Middleware` automatically. The request visitor is available to the client and as `CekatEventSdk::Rails::Current.visitor_id`. To insert the middleware yourself, set `config.cekat_event_sdk.insert_middleware = false` and add it where you want in the stack.
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
# config/initializers/cekat.rb
|
|
52
|
+
CEKAT = CekatEventSdk::Client.new(access_token: Rails.application.credentials.cekat_access_token)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Rack (Sinatra, Hanami, Roda, and others)
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
use CekatEventSdk::Rack::Middleware
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The middleware also exposes the value as `env["cekat_event_sdk.visitor_id"]`. It reads the raw `Cookie` header and never modifies the request, response, or cookies.
|
|
62
|
+
|
|
63
|
+
### Without middleware
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
CEKAT.with_visitor_id(visitor_id) do
|
|
67
|
+
CEKAT.user_login(email: "person@example.com")
|
|
68
|
+
end
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Scope and concurrency
|
|
72
|
+
|
|
73
|
+
The visitor scope lives in fiber storage (`Fiber[]`), so it is isolated per request under threaded servers (Puma) and fiber-based servers (Falcon). Every scope restores the previous visitor when it ends, including when it raises, so long-running workers never leak a visitor ID between requests. Threads and fibers started inside a request begin with a copy of its scope. The scope ends when the middleware returns, so events emitted while a streaming response body is iterated must pass `visitor_id:` explicitly.
|
|
74
|
+
|
|
75
|
+
## Keep tracking off the request's critical path
|
|
76
|
+
|
|
77
|
+
Calls are synchronous, so each event adds its round-trip to the request. To send in the background, capture the visitor ID while the request scope is active and pass it explicitly:
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
visitor_id = CekatEventSdk::VisitorContext.current_visitor_id
|
|
81
|
+
TrackLoginJob.perform_later(user.email, visitor_id)
|
|
82
|
+
|
|
83
|
+
class TrackLoginJob < ApplicationJob
|
|
84
|
+
def perform(email, visitor_id)
|
|
85
|
+
CEKAT.user_login(email: email, visitor_id: visitor_id)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Jobs run outside the request, so they never see its scope; later events that carry the contact's email or phone number do not need a visitor ID. Rescue and log errors in background work.
|
|
91
|
+
|
|
92
|
+
## Errors and retries
|
|
93
|
+
|
|
94
|
+
All errors inherit from `CekatEventSdk::Error`, which exposes `attempts` and `delivery_outcome_unknown?`:
|
|
95
|
+
|
|
96
|
+
| Error | Meaning |
|
|
97
|
+
| --- | --- |
|
|
98
|
+
| `ValidationError` | Invalid configuration or event input; nothing was sent. |
|
|
99
|
+
| `AuthenticationError` | HTTP 401. Inherits from `ApiError`. |
|
|
100
|
+
| `EventDefinitionNotFoundError` | HTTP 404. Inherits from `ApiError`. |
|
|
101
|
+
| `ApiError` | Any other non-200 response: `status`, `code`, bounded `raw_body`. |
|
|
102
|
+
| `ResponseDecodeError` | HTTP 200 whose body was invalid, over 65,536 bytes, or unreadable. The event was received. |
|
|
103
|
+
| `TransportError` | No response after all attempts. `delivery_outcome_unknown?` is `true`: Cekat may have received it. |
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
begin
|
|
107
|
+
CEKAT.order_paid(email: email, amount: 125_000, currency: "IDR")
|
|
108
|
+
rescue CekatEventSdk::TransportError => e
|
|
109
|
+
Rails.logger.warn("Cekat unreachable after #{e.attempts} attempts; delivery outcome unknown")
|
|
110
|
+
rescue CekatEventSdk::ApiError => e
|
|
111
|
+
Rails.logger.error("Cekat rejected the event (#{e.status}): #{e.message}")
|
|
112
|
+
end
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Response bodies retained in errors are capped at 65,536 bytes. Error messages never include request headers, and `Client#inspect` omits the access token.
|
|
116
|
+
|
|
117
|
+
Transport failures, timeouts, and HTTP 429, 500, 502, 503, and 504 are retried, up to `retry_count` retries (default 2). Delays use capped exponential full jitter (up to 100ms, 200ms, 400ms, 800ms, then 1s). A valid `Retry-After` header raises the delay to the server's value; above 5 seconds the SDK raises immediately instead of blocking. Other statuses, including 400, 401, and 404, are not retried, and a received 200 is never retried. A retry after an unknown outcome can create a **duplicate** event; retries reuse the same `event_id`, but the SDK does not guarantee server-side deduplication.
|
|
118
|
+
|
|
119
|
+
## Configuration
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
CekatEventSdk::Client.new(access_token: token, base_url: "https://server.cekat.ai", timeout: 3, retry_count: 2)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
`base_url` must be an absolute HTTP(S) origin without credentials, path, query, or fragment; the SDK always posts to `/api/events/ingest`. Requests send `User-Agent: cekat-event-sdk-ruby/<version>`.
|
|
126
|
+
|
|
127
|
+
The Net::HTTP transport opens a fresh connection per attempt, never follows redirects, reads at most 65,537 response bytes, and applies `timeout` to connecting, writing, waiting for the response, and reading the body. Ruby has no portable caller cancellation, so bound each call with `timeout` and `retry_count`.
|
|
128
|
+
|
|
129
|
+
## Development
|
|
130
|
+
|
|
131
|
+
```sh
|
|
132
|
+
bundle install
|
|
133
|
+
bundle exec rake # core, Rack, and Rails specs plus RuboCop
|
|
134
|
+
RAILS_VERSION="~> 8.0.0" RACK_VERSION="~> 2.2" bundle update && bundle exec rake spec
|
|
135
|
+
./scripts/package --version 0.1.0 --output /absolute/empty-directory
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
`scripts/package` runs the specs, RuboCop, and `bundle-audit`, then builds the gem and a SHA-256 `manifest.json`. It never pushes, signs, or tags; releases to RubyGems run from the repository's `release-ruby.yml` workflow when a `ruby/vX.Y.Z` tag is pushed. `scripts/conformance` runs the shared conformance fixtures (see `conformance/README.md`); the three caller-cancellation cases are reported as `not_applicable` for Ruby.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CekatEventSdk
|
|
4
|
+
# Cekat accepted the event for asynchronous processing. This does not confirm durable
|
|
5
|
+
# storage, identity resolution, delivery completion, or analytics availability.
|
|
6
|
+
Acknowledgement = Data.define(:success, :message, :event_key, :validated_properties, :raw_body)
|
|
7
|
+
end
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module CekatEventSdk
|
|
6
|
+
# Submits Cekat events synchronously. Construct one client and reuse it; it is thread-safe.
|
|
7
|
+
#
|
|
8
|
+
# Each method returns an Acknowledgement (accepted for asynchronous processing) or raises:
|
|
9
|
+
# ValidationError before any request; AuthenticationError (401), EventDefinitionNotFoundError
|
|
10
|
+
# (404), or ApiError for other non-200 responses; ResponseDecodeError for an invalid or
|
|
11
|
+
# unreadable 200; TransportError when no response was received. Retries can create duplicate
|
|
12
|
+
# events; every retry reuses the call's event_id.
|
|
13
|
+
class Client
|
|
14
|
+
attr_reader :base_url, :timeout, :retry_count
|
|
15
|
+
|
|
16
|
+
# sleeper, random, and payload_builder are test seams.
|
|
17
|
+
def initialize(access_token:, base_url: DEFAULT_BASE_URL, timeout: 3, retry_count: 2, transport: Transport.new,
|
|
18
|
+
visitor_context: VisitorContext, sleeper: ->(seconds) { sleep(seconds) }, random: Random.new,
|
|
19
|
+
payload_builder: PayloadBuilder.new)
|
|
20
|
+
raise ValidationError, "access token must not be blank" unless access_token.is_a?(String) && !access_token.strip.empty?
|
|
21
|
+
unless timeout.is_a?(Numeric) && timeout.real? && timeout.to_f.finite? && timeout.positive?
|
|
22
|
+
raise ValidationError, "timeout must be a finite number of seconds greater than zero"
|
|
23
|
+
end
|
|
24
|
+
raise ValidationError, "retry count must be a nonnegative Integer" unless retry_count.is_a?(Integer) && !retry_count.negative?
|
|
25
|
+
|
|
26
|
+
@access_token = access_token
|
|
27
|
+
@base_url = normalize_origin(base_url)
|
|
28
|
+
@timeout = timeout
|
|
29
|
+
@retry_count = retry_count
|
|
30
|
+
@transport = transport
|
|
31
|
+
@visitor_context = visitor_context
|
|
32
|
+
@sleeper = sleeper
|
|
33
|
+
@random = random
|
|
34
|
+
@payload_builder = payload_builder
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Each event method accepts an EventInput, a Hash, or keyword attributes:
|
|
38
|
+
# client.user_login(email: "person@example.com", properties: { plan: "pro" })
|
|
39
|
+
def user_registration(event = nil, **attributes)
|
|
40
|
+
track("user_registration", true, event_from(event, attributes))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def user_login(event = nil, **attributes)
|
|
44
|
+
track("user_login", true, event_from(event, attributes))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def order_created(event = nil, **attributes)
|
|
48
|
+
track("order_created", true, event_from(event, attributes))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The required finite amount and nonblank currency are sent as the "amount" and "currency"
|
|
52
|
+
# properties; the event's properties must not already contain either key.
|
|
53
|
+
def order_paid(event = nil, amount:, currency:, **attributes)
|
|
54
|
+
track("order_paid", true, PayloadBuilder.with_order_paid_properties(amount, currency, event_from(event, attributes)))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def custom_event(event_key, event = nil, **attributes)
|
|
58
|
+
track(event_key, false, event_from(event, attributes))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Runs the block with visitor_id as the current visitor (see VisitorContext.with).
|
|
62
|
+
def with_visitor_id(visitor_id, &)
|
|
63
|
+
VisitorContext.with(visitor_id, &)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def inspect
|
|
67
|
+
"#<#{self.class.name} base_url=#{@base_url.inspect} timeout=#{@timeout} retry_count=#{@retry_count}>"
|
|
68
|
+
end
|
|
69
|
+
alias to_s inspect
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def event_from(event, attributes)
|
|
74
|
+
raise ValidationError, "pass an event or keyword attributes, not both" if event && !attributes.empty?
|
|
75
|
+
|
|
76
|
+
case event
|
|
77
|
+
when EventInput then event
|
|
78
|
+
when nil then EventInput.new(**attributes)
|
|
79
|
+
when Hash then EventInput.new(**event.transform_keys(&:to_sym))
|
|
80
|
+
else raise ValidationError, "event must be a CekatEventSdk::EventInput or Hash"
|
|
81
|
+
end
|
|
82
|
+
rescue ArgumentError => e
|
|
83
|
+
raise ValidationError, "invalid event attributes: #{e.message}"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def track(event_key, is_common, event)
|
|
87
|
+
# Encoded once so every retry sends the same event ID and timestamp.
|
|
88
|
+
body = @payload_builder.build(event_key, is_common, event, @visitor_context.current_visitor_id)
|
|
89
|
+
headers = {
|
|
90
|
+
"Authorization" => "Bearer #{@access_token}",
|
|
91
|
+
"Content-Type" => "application/json",
|
|
92
|
+
"User-Agent" => "cekat-event-sdk-ruby/#{VERSION} ruby/#{RUBY_VERSION}"
|
|
93
|
+
}
|
|
94
|
+
deliver("#{@base_url}#{INGEST_PATH}", headers, body)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def deliver(url, headers, body)
|
|
98
|
+
maximum_attempts = @retry_count + 1
|
|
99
|
+
attempt = 0
|
|
100
|
+
loop do
|
|
101
|
+
attempt += 1
|
|
102
|
+
begin
|
|
103
|
+
response = @transport.post(uri: url, headers: headers, body: body, timeout: @timeout)
|
|
104
|
+
rescue Transport::Failure
|
|
105
|
+
raise TransportError.new("Cekat API request failed before a response was received", attempts: attempt) if attempt >= maximum_attempts
|
|
106
|
+
|
|
107
|
+
backoff(attempt, nil)
|
|
108
|
+
next
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Retry is decided by status alone, even when the body read failed.
|
|
112
|
+
if attempt < maximum_attempts && RetryPolicy.retryable_status?(response.status)
|
|
113
|
+
retry_after = RetryPolicy.parse_retry_after_ms(response.header("Retry-After"), (Time.now.to_r * 1_000).to_i)
|
|
114
|
+
if retry_after.nil? || retry_after <= RetryPolicy::MAXIMUM_RETRY_AFTER_MS
|
|
115
|
+
backoff(attempt, retry_after)
|
|
116
|
+
next
|
|
117
|
+
end
|
|
118
|
+
# The server asked for a longer pause than a caller should wait: report it now.
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
return ResponseDecoder.decode(response, attempt)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def backoff(retry_number, retry_after_ms)
|
|
126
|
+
jitter = @random.rand(RetryPolicy.delay_bound_ms(retry_number) + 1)
|
|
127
|
+
@sleeper.call([jitter, retry_after_ms || 0].max / 1000.0)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def normalize_origin(base_url)
|
|
131
|
+
invalid = ValidationError.new("base URL must be an absolute HTTP(S) origin without credentials, path, query, or fragment")
|
|
132
|
+
raise invalid unless base_url.is_a?(String) && !base_url.match?(/[\s?#]/)
|
|
133
|
+
|
|
134
|
+
uri = URI.parse(base_url)
|
|
135
|
+
unless %w[http https].include?(uri.scheme&.downcase) && uri.host && !uri.host.empty? && uri.userinfo.nil? &&
|
|
136
|
+
["", "/"].include?(uri.path)
|
|
137
|
+
raise invalid
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
default_port = uri.scheme.casecmp?("https") ? 443 : 80
|
|
141
|
+
"#{uri.scheme.downcase}://#{uri.host.downcase}#{":#{uri.port}" if uri.port != default_port}"
|
|
142
|
+
rescue URI::InvalidURIError
|
|
143
|
+
raise invalid
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CekatEventSdk
|
|
4
|
+
# Base class for every error raised by the SDK.
|
|
5
|
+
class Error < StandardError
|
|
6
|
+
# Number of HTTP attempts made; zero when nothing was sent.
|
|
7
|
+
attr_reader :attempts
|
|
8
|
+
|
|
9
|
+
def initialize(message = nil, attempts: 0)
|
|
10
|
+
super(message)
|
|
11
|
+
@attempts = attempts
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Whether Cekat may have received the event even though no response was seen.
|
|
15
|
+
def delivery_outcome_unknown?
|
|
16
|
+
false
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Invalid configuration or event input, detected before any request.
|
|
21
|
+
class ValidationError < Error; end
|
|
22
|
+
|
|
23
|
+
# Cekat returned a non-200 response. The delivery outcome is known.
|
|
24
|
+
class ApiError < Error
|
|
25
|
+
attr_reader :status, :code, :raw_body
|
|
26
|
+
|
|
27
|
+
# raw_body holds at most the first 65,536 response bytes.
|
|
28
|
+
def initialize(message, status:, code:, raw_body:, attempts:)
|
|
29
|
+
super(message, attempts: attempts)
|
|
30
|
+
@status = status
|
|
31
|
+
@code = code
|
|
32
|
+
@raw_body = raw_body
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# HTTP 401: Cekat rejected the access token.
|
|
37
|
+
class AuthenticationError < ApiError; end
|
|
38
|
+
|
|
39
|
+
# HTTP 404: the tenant has no definition for the event key.
|
|
40
|
+
class EventDefinitionNotFoundError < ApiError; end
|
|
41
|
+
|
|
42
|
+
# No response was received after all attempts. Cekat may have received the event,
|
|
43
|
+
# so resending it can create a duplicate.
|
|
44
|
+
class TransportError < Error
|
|
45
|
+
def delivery_outcome_unknown?
|
|
46
|
+
true
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# HTTP 200 whose body was invalid, over 65,536 bytes, or unreadable. The event was received.
|
|
51
|
+
class ResponseDecodeError < Error
|
|
52
|
+
attr_reader :raw_body
|
|
53
|
+
|
|
54
|
+
def initialize(message, raw_body:, attempts:)
|
|
55
|
+
super(message, attempts: attempts)
|
|
56
|
+
@raw_body = raw_body
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def status
|
|
60
|
+
200
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CekatEventSdk
|
|
4
|
+
# An identity-bearing event. At least one of email or phone_number must be nonblank.
|
|
5
|
+
#
|
|
6
|
+
# properties is nil (omitted) or a Hash with String or Symbol keys. Values may be nil, true,
|
|
7
|
+
# false, Strings, Symbols, finite numbers (Integer, Float, BigDecimal, Rational), Arrays, and
|
|
8
|
+
# Hashes of those. event_id deduplicates retried deliveries and defaults to a random UUID;
|
|
9
|
+
# occurred_at (a Time or DateTime) defaults to the time of the call.
|
|
10
|
+
EventInput = Data.define(:email, :phone_number, :contact_name, :visitor_id, :properties, :event_id, :occurred_at) do
|
|
11
|
+
def initialize(email: nil, phone_number: nil, contact_name: nil, visitor_id: nil, properties: nil, event_id: nil, occurred_at: nil)
|
|
12
|
+
super
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module CekatEventSdk
|
|
7
|
+
# Validates event input and builds the JSON request body.
|
|
8
|
+
# @api private
|
|
9
|
+
class PayloadBuilder
|
|
10
|
+
MAX_SAFE_INTEGER = 9_007_199_254_740_991
|
|
11
|
+
MAX_DEPTH = 256
|
|
12
|
+
STRING_FIELDS = %i[email phone_number contact_name visitor_id event_id].freeze
|
|
13
|
+
|
|
14
|
+
def initialize(clock: -> { Time.now }, id_generator: -> { SecureRandom.uuid })
|
|
15
|
+
@clock = clock
|
|
16
|
+
@id_generator = id_generator
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Returns a copy of event whose properties include the required order_paid arguments.
|
|
20
|
+
def self.with_order_paid_properties(amount, currency, event)
|
|
21
|
+
raise ValidationError, "amount must be a finite number" unless amount.is_a?(Numeric) && amount.real? && (amount.is_a?(Integer) || amount.to_f.finite?)
|
|
22
|
+
raise ValidationError, "currency must not be blank" unless currency.is_a?(String) && !currency.strip.empty?
|
|
23
|
+
return event unless event.is_a?(EventInput) && (event.properties.nil? || event.properties.is_a?(Hash))
|
|
24
|
+
|
|
25
|
+
properties = event.properties || {}
|
|
26
|
+
%w[amount currency].each do |reserved|
|
|
27
|
+
if properties.key?(reserved) || properties.key?(reserved.to_sym)
|
|
28
|
+
raise ValidationError, "properties must not contain #{reserved.inspect}; pass it as the order_paid argument"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
event.with(properties: properties.merge("amount" => amount, "currency" => currency))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def build(event_key, is_common, event, ambient_visitor_id)
|
|
35
|
+
raise ValidationError, "event key must not be blank" unless event_key.is_a?(String) && !event_key.strip.empty?
|
|
36
|
+
raise ValidationError, "event must be a CekatEventSdk::EventInput" unless event.is_a?(EventInput)
|
|
37
|
+
|
|
38
|
+
STRING_FIELDS.each do |field|
|
|
39
|
+
value = event.public_send(field)
|
|
40
|
+
raise ValidationError, "#{field} must be a String" unless value.nil? || value.is_a?(String)
|
|
41
|
+
end
|
|
42
|
+
raise ValidationError, "event must include a nonblank email or phone number" if blank?(event.email) && blank?(event.phone_number)
|
|
43
|
+
|
|
44
|
+
payload = {
|
|
45
|
+
"event_key" => utf8(event_key, "event_key"),
|
|
46
|
+
"event_id" => VisitorIdResolver.normalize(event.event_id) || @id_generator.call,
|
|
47
|
+
"occurred_at" => occurred_at(event.occurred_at),
|
|
48
|
+
"is_common" => is_common
|
|
49
|
+
}
|
|
50
|
+
payload["email"] = utf8(event.email, "email") unless event.email.nil?
|
|
51
|
+
payload["phone_number"] = utf8(event.phone_number, "phone_number") unless event.phone_number.nil?
|
|
52
|
+
payload["contact_name"] = utf8(event.contact_name, "contact_name") unless event.contact_name.nil?
|
|
53
|
+
visitor_id = VisitorIdResolver.normalize(event.visitor_id) || VisitorIdResolver.normalize(ambient_visitor_id)
|
|
54
|
+
payload["visitor_id"] = utf8(visitor_id, "visitor_id") if visitor_id
|
|
55
|
+
unless event.properties.nil?
|
|
56
|
+
raise ValidationError, "properties must be a Hash" unless event.properties.is_a?(Hash)
|
|
57
|
+
|
|
58
|
+
payload["properties"] = normalize(event.properties, "properties", {}.compare_by_identity, 0)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
JSON.generate(payload)
|
|
62
|
+
rescue JSON::GeneratorError
|
|
63
|
+
raise ValidationError, "event is not JSON-compatible"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def blank?(value)
|
|
69
|
+
value.nil? || value.strip.empty?
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def occurred_at(value)
|
|
73
|
+
time = value.nil? ? @clock.call : value
|
|
74
|
+
raise ValidationError, "occurred_at must be a Time or DateTime" unless time.is_a?(Time) || (defined?(::DateTime) && time.is_a?(::DateTime))
|
|
75
|
+
|
|
76
|
+
utc = time.is_a?(Time) ? time.getutc : time.to_time.utc
|
|
77
|
+
raise ValidationError, "occurred_at must be between years 0001 and 9999" unless (1..9999).cover?(utc.year)
|
|
78
|
+
|
|
79
|
+
utc.strftime("%Y-%m-%dT%H:%M:%S.%LZ")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def utf8(value, path)
|
|
83
|
+
string = value.encoding == Encoding::UTF_8 ? value : value.dup.force_encoding(Encoding::UTF_8)
|
|
84
|
+
raise ValidationError, "#{path} must be valid UTF-8" unless string.valid_encoding?
|
|
85
|
+
|
|
86
|
+
string
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def normalize(value, path, ancestors, depth)
|
|
90
|
+
raise ValidationError, "#{path} is nested too deeply" if depth > MAX_DEPTH
|
|
91
|
+
|
|
92
|
+
case value
|
|
93
|
+
when nil, true, false then value
|
|
94
|
+
when String then utf8(value, path)
|
|
95
|
+
when Symbol then utf8(value.to_s, path)
|
|
96
|
+
when Integer
|
|
97
|
+
raise invalid(path) unless value.between?(-MAX_SAFE_INTEGER, MAX_SAFE_INTEGER)
|
|
98
|
+
|
|
99
|
+
value
|
|
100
|
+
when Float then portable_float(value, path)
|
|
101
|
+
when Numeric
|
|
102
|
+
raise invalid(path) unless value.real?
|
|
103
|
+
|
|
104
|
+
portable_float(value.to_f, path)
|
|
105
|
+
when Array
|
|
106
|
+
within(value, path, ancestors) do
|
|
107
|
+
value.each_with_index.map { |item, index| normalize(item, "#{path}[#{index}]", ancestors, depth + 1) }
|
|
108
|
+
end
|
|
109
|
+
when Hash
|
|
110
|
+
within(value, path, ancestors) do
|
|
111
|
+
value.each_with_object({}) do |(key, item), object|
|
|
112
|
+
raise ValidationError, "#{path} has a #{key.class} key; property keys must be Strings or Symbols" unless key.is_a?(String) || key.is_a?(Symbol)
|
|
113
|
+
|
|
114
|
+
name = utf8(key.to_s, path)
|
|
115
|
+
raise ValidationError, "#{path} has duplicate key #{name.inspect}" if object.key?(name)
|
|
116
|
+
|
|
117
|
+
object[name] = normalize(item, name.empty? ? "#{path}[\"\"]" : "#{path}.#{name}", ancestors, depth + 1)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
else
|
|
121
|
+
raise invalid(path)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def portable_float(value, path)
|
|
126
|
+
raise invalid(path) unless value.finite?
|
|
127
|
+
raise invalid(path) if value == value.truncate && value.abs > MAX_SAFE_INTEGER
|
|
128
|
+
|
|
129
|
+
value
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def within(container, path, ancestors)
|
|
133
|
+
raise ValidationError, "#{path} contains a cycle" if ancestors.key?(container)
|
|
134
|
+
|
|
135
|
+
# Only containers on the current path are tracked, so repeated references are allowed.
|
|
136
|
+
ancestors[container] = true
|
|
137
|
+
result = yield
|
|
138
|
+
ancestors.delete(container)
|
|
139
|
+
result
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def invalid(path)
|
|
143
|
+
ValidationError.new("#{path} is not a JSON-compatible value")
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CekatEventSdk
|
|
4
|
+
module Rack
|
|
5
|
+
# Rack middleware (Sinatra, Hanami, Roda, and other Rack apps) that scopes the request's
|
|
6
|
+
# visitor ID while the downstream app runs. It exposes the value as
|
|
7
|
+
# env["cekat_event_sdk.visitor_id"] and never changes headers, cookies, or the response.
|
|
8
|
+
#
|
|
9
|
+
# The scope ends when call returns, so events emitted while a streaming body is iterated
|
|
10
|
+
# must pass visitor_id explicitly.
|
|
11
|
+
class Middleware
|
|
12
|
+
ENV_KEY = "cekat_event_sdk.visitor_id"
|
|
13
|
+
|
|
14
|
+
def initialize(app, visitor_context: VisitorContext)
|
|
15
|
+
@app = app
|
|
16
|
+
@visitor_context = visitor_context
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call(env)
|
|
20
|
+
had_previous = env.key?(ENV_KEY)
|
|
21
|
+
previous = env[ENV_KEY]
|
|
22
|
+
visitor_id = VisitorIdResolver.from_rack_env(env)
|
|
23
|
+
visitor_id ? env[ENV_KEY] = visitor_id : env.delete(ENV_KEY)
|
|
24
|
+
begin
|
|
25
|
+
@visitor_context.with(visitor_id) { @app.call(env) }
|
|
26
|
+
ensure
|
|
27
|
+
had_previous ? env[ENV_KEY] = previous : env.delete(ENV_KEY)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support"
|
|
4
|
+
require "active_support/current_attributes"
|
|
5
|
+
require "rails/railtie"
|
|
6
|
+
|
|
7
|
+
module CekatEventSdk
|
|
8
|
+
# Rails integration. The Railtie inserts CekatEventSdk::Rails::Middleware, which exposes the
|
|
9
|
+
# request visitor as CekatEventSdk::Rails::Current.visitor_id and scopes it for the client.
|
|
10
|
+
module Rails
|
|
11
|
+
# Request-scoped attributes; visitor_id is set by Middleware for each request.
|
|
12
|
+
class Current < ActiveSupport::CurrentAttributes
|
|
13
|
+
attribute :visitor_id
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Reads the visitor from CurrentAttributes; pass as a client's visitor_context when code
|
|
17
|
+
# sets Current.visitor_id itself (for example in a job).
|
|
18
|
+
module VisitorContext
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def current_visitor_id
|
|
22
|
+
Current.visitor_id || CekatEventSdk::VisitorContext.current_visitor_id
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Scopes the request visitor for Current and for CekatEventSdk::VisitorContext.
|
|
27
|
+
class Middleware
|
|
28
|
+
def initialize(app)
|
|
29
|
+
@app = app
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Current.set restores the previous attributes when the block exits, and Rails resets
|
|
33
|
+
# CurrentAttributes after each request, so no state crosses requests.
|
|
34
|
+
def call(env)
|
|
35
|
+
visitor_id = VisitorIdResolver.from_rack_env(env)
|
|
36
|
+
Current.set(visitor_id: visitor_id) do
|
|
37
|
+
CekatEventSdk::VisitorContext.with(visitor_id) { @app.call(env) }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Inserts Middleware unless config.cekat_event_sdk.insert_middleware is false.
|
|
43
|
+
class Railtie < ::Rails::Railtie
|
|
44
|
+
config.cekat_event_sdk = ActiveSupport::OrderedOptions.new
|
|
45
|
+
config.cekat_event_sdk.insert_middleware = true
|
|
46
|
+
|
|
47
|
+
initializer "cekat_event_sdk.middleware" do |app|
|
|
48
|
+
app.middleware.use CekatEventSdk::Rails::Middleware if app.config.cekat_event_sdk.insert_middleware
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module CekatEventSdk
|
|
6
|
+
# Maps a received response to an Acknowledgement or a typed error.
|
|
7
|
+
# @api private
|
|
8
|
+
module ResponseDecoder
|
|
9
|
+
STATUS_TEXT = {
|
|
10
|
+
400 => "Bad Request", 401 => "Unauthorized", 403 => "Forbidden", 404 => "Not Found",
|
|
11
|
+
405 => "Method Not Allowed", 408 => "Request Timeout", 409 => "Conflict", 413 => "Content Too Large",
|
|
12
|
+
415 => "Unsupported Media Type", 418 => "I'm a teapot", 422 => "Unprocessable Content", 429 => "Too Many Requests",
|
|
13
|
+
500 => "Internal Server Error", 501 => "Not Implemented", 502 => "Bad Gateway",
|
|
14
|
+
503 => "Service Unavailable", 504 => "Gateway Timeout"
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
def decode(response, attempts)
|
|
20
|
+
return acknowledgement(response, attempts) if response.status == 200
|
|
21
|
+
|
|
22
|
+
message, code = error_envelope(response.body) || [fallback_message(response), nil]
|
|
23
|
+
error_class = { 401 => AuthenticationError, 404 => EventDefinitionNotFoundError }.fetch(response.status, ApiError)
|
|
24
|
+
raise error_class.new(message, status: response.status, code: code, raw_body: response.body, attempts: attempts)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def acknowledgement(response, attempts)
|
|
28
|
+
body = response.body
|
|
29
|
+
if response.body_read_failure
|
|
30
|
+
raise ResponseDecodeError.new("response body could not be read", raw_body: body, attempts: attempts),
|
|
31
|
+
cause: response.body_read_failure
|
|
32
|
+
end
|
|
33
|
+
raise ResponseDecodeError.new("response body exceeds #{MAXIMUM_RESPONSE_BODY_BYTES} bytes", raw_body: body, attempts: attempts) if response.body_truncated
|
|
34
|
+
|
|
35
|
+
envelope = parse(body)
|
|
36
|
+
data = envelope.is_a?(Hash) ? envelope["data"] : nil
|
|
37
|
+
properties = data.is_a?(Hash) ? data["validated_properties"] : nil
|
|
38
|
+
valid = envelope.is_a?(Hash) && envelope["success"] == true && data.is_a?(Hash) && data["success"] == true &&
|
|
39
|
+
nonempty_string?(data["message"]) && nonempty_string?(data["event_key"]) &&
|
|
40
|
+
properties.is_a?(Array) && properties.all?(String)
|
|
41
|
+
raise ResponseDecodeError.new("response body is not a valid success envelope", raw_body: body, attempts: attempts) unless valid
|
|
42
|
+
|
|
43
|
+
Acknowledgement.new(success: true, message: data["message"], event_key: data["event_key"],
|
|
44
|
+
validated_properties: properties.freeze, raw_body: body)
|
|
45
|
+
rescue JSON::ParserError, EncodingError
|
|
46
|
+
raise ResponseDecodeError.new("response body is not valid JSON", raw_body: body, attempts: attempts)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def error_envelope(body)
|
|
50
|
+
envelope = parse(body)
|
|
51
|
+
return nil unless envelope.is_a?(Hash) && envelope["success"] == false && nonempty_string?(envelope["error"])
|
|
52
|
+
return [envelope["error"], nil] unless envelope.key?("code")
|
|
53
|
+
|
|
54
|
+
envelope["code"].is_a?(String) ? [envelope["error"], envelope["code"]] : nil
|
|
55
|
+
rescue JSON::ParserError, EncodingError
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def fallback_message(response)
|
|
60
|
+
reason = response.reason.to_s.strip
|
|
61
|
+
reason.empty? ? STATUS_TEXT.fetch(response.status, "HTTP #{response.status}") : reason
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def parse(body)
|
|
65
|
+
JSON.parse(body)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def nonempty_string?(value)
|
|
69
|
+
value.is_a?(String) && !value.empty?
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module CekatEventSdk
|
|
6
|
+
# @api private
|
|
7
|
+
module RetryPolicy
|
|
8
|
+
MAXIMUM_RETRY_AFTER_MS = 5_000
|
|
9
|
+
RETRYABLE_STATUSES = [429, 500, 502, 503, 504].freeze
|
|
10
|
+
HTTP_DATE = /\A(?:
|
|
11
|
+
[A-Za-z]{3},\ \d{2}\ [A-Za-z]{3}\ \d{4}\ \d{2}:\d{2}:\d{2}\ GMT | # IMF-fixdate
|
|
12
|
+
[A-Za-z]{6,9},\ \d{2}-[A-Za-z]{3}-\d{2}\ \d{2}:\d{2}:\d{2}\ GMT | # RFC 850
|
|
13
|
+
[A-Za-z]{3}\ [A-Za-z]{3}\ [\ \d]\d\ \d{2}:\d{2}:\d{2}\ \d{4} # asctime
|
|
14
|
+
)\z/x
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def retryable_status?(status)
|
|
19
|
+
RETRYABLE_STATUSES.include?(status)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Full-jitter upper bound before one-indexed retry: 100ms doubling to a 1s cap.
|
|
23
|
+
def delay_bound_ms(retry_number)
|
|
24
|
+
[100 * (2**(retry_number.clamp(1, 5) - 1)), 1_000].min
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Parses Retry-After delta-seconds or an HTTP-date; nil when absent or invalid.
|
|
28
|
+
def parse_retry_after_ms(value, now_ms)
|
|
29
|
+
trimmed = value.to_s.strip
|
|
30
|
+
return nil if trimmed.empty?
|
|
31
|
+
return trimmed.length > 9 ? Float::INFINITY : Integer(trimmed, 10) * 1_000 if trimmed.match?(/\A\d+\z/)
|
|
32
|
+
return nil unless trimmed.match?(HTTP_DATE)
|
|
33
|
+
|
|
34
|
+
[(Time.parse("#{trimmed} UTC").to_r * 1_000).to_i - now_ms, 0].max
|
|
35
|
+
rescue ArgumentError
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module CekatEventSdk
|
|
8
|
+
# Default Net::HTTP transport. Each attempt opens a fresh connection (so nothing is silently
|
|
9
|
+
# resent on a stale keep-alive connection), never follows redirects, bounds connect, write,
|
|
10
|
+
# header, and body time by the timeout, and reads at most 65,537 body bytes.
|
|
11
|
+
class Transport
|
|
12
|
+
# Raised when no response status was received. The message never contains request headers.
|
|
13
|
+
class Failure < StandardError
|
|
14
|
+
def self.from(error)
|
|
15
|
+
new("#{error.class}: #{error.message}")
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# A received response. body holds at most 65,536 bytes; body_read_failure is set when
|
|
20
|
+
# the status arrived but the body could not be read.
|
|
21
|
+
Response = Data.define(:status, :reason, :headers, :body, :body_truncated, :observed_body_bytes, :body_read_failure) do
|
|
22
|
+
def header(name)
|
|
23
|
+
headers.each { |key, values| return values.first if key.casecmp?(name) }
|
|
24
|
+
nil
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
class BodyDeadlineExceeded < StandardError; end
|
|
29
|
+
private_constant :BodyDeadlineExceeded
|
|
30
|
+
|
|
31
|
+
def post(uri:, headers:, body:, timeout:)
|
|
32
|
+
uri = URI(uri)
|
|
33
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
34
|
+
http.use_ssl = uri.scheme == "https"
|
|
35
|
+
http.open_timeout = timeout
|
|
36
|
+
http.read_timeout = timeout
|
|
37
|
+
http.write_timeout = timeout
|
|
38
|
+
http.ssl_timeout = timeout if http.use_ssl?
|
|
39
|
+
http.max_retries = 0
|
|
40
|
+
# Net::HTTP defaults to treating a connection closed before Content-Length as a complete
|
|
41
|
+
# body; a truncated response must be reported as a body read failure instead.
|
|
42
|
+
http.ignore_eof = false
|
|
43
|
+
|
|
44
|
+
request = Net::HTTP::Post.new(uri.request_uri, headers)
|
|
45
|
+
request.body = body
|
|
46
|
+
deadline = monotonic + timeout
|
|
47
|
+
state = { head: nil, buffer: +"", failure: nil }
|
|
48
|
+
|
|
49
|
+
catch(:body_limit) do
|
|
50
|
+
http.start do |connection|
|
|
51
|
+
connection.request(request) do |response|
|
|
52
|
+
state[:head] = response
|
|
53
|
+
response.read_body do |chunk|
|
|
54
|
+
room = MAXIMUM_RESPONSE_BODY_BYTES + 1 - state[:buffer].bytesize
|
|
55
|
+
state[:buffer] << chunk.byteslice(0, room) if room.positive?
|
|
56
|
+
throw :body_limit if state[:buffer].bytesize > MAXIMUM_RESPONSE_BODY_BYTES
|
|
57
|
+
raise BodyDeadlineExceeded, "response body exceeded the #{timeout}s timeout" if monotonic > deadline
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
response_from(state)
|
|
63
|
+
rescue StandardError => e
|
|
64
|
+
raise Failure.from(e) if state.nil? || state[:head].nil?
|
|
65
|
+
|
|
66
|
+
state[:failure] = Failure.from(e)
|
|
67
|
+
response_from(state)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def response_from(state)
|
|
73
|
+
head = state[:head]
|
|
74
|
+
buffer = state[:buffer]
|
|
75
|
+
Response.new(
|
|
76
|
+
status: head.code.to_i,
|
|
77
|
+
reason: head.message.to_s,
|
|
78
|
+
headers: head.to_hash,
|
|
79
|
+
body: buffer.byteslice(0, MAXIMUM_RESPONSE_BODY_BYTES).force_encoding(Encoding::UTF_8),
|
|
80
|
+
body_truncated: buffer.bytesize > MAXIMUM_RESPONSE_BODY_BYTES,
|
|
81
|
+
observed_body_bytes: buffer.bytesize,
|
|
82
|
+
body_read_failure: buffer.bytesize > MAXIMUM_RESPONSE_BODY_BYTES ? nil : state[:failure]
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def monotonic
|
|
87
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CekatEventSdk
|
|
4
|
+
# Request-local visitor scope stored in fiber storage (Fiber[]), so it is isolated per
|
|
5
|
+
# request fiber under threaded (Puma) and fiber-based (Falcon) servers. Fibers and threads
|
|
6
|
+
# created inside a scope start with a copy of it. Visitor IDs are untrusted correlation data:
|
|
7
|
+
# never use them for authentication or authorization.
|
|
8
|
+
module VisitorContext
|
|
9
|
+
STORAGE_KEY = :cekat_event_sdk_visitor_id
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def current_visitor_id
|
|
14
|
+
Fiber[STORAGE_KEY]
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Runs the block with visitor_id (trimmed; blank means none) as the current visitor and
|
|
18
|
+
# restores the previous value afterwards, including when the block raises.
|
|
19
|
+
def with(visitor_id)
|
|
20
|
+
previous = Fiber[STORAGE_KEY]
|
|
21
|
+
Fiber[STORAGE_KEY] = VisitorIdResolver.normalize(visitor_id)
|
|
22
|
+
begin
|
|
23
|
+
yield
|
|
24
|
+
ensure
|
|
25
|
+
Fiber[STORAGE_KEY] = previous
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CekatEventSdk
|
|
4
|
+
# Resolves the browser visitor ID: a nonblank X-Cekat-Visitor-ID header wins over a nonblank
|
|
5
|
+
# _cekat_visitor_id cookie. Values are trimmed and otherwise not decoded.
|
|
6
|
+
module VisitorIdResolver
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# Trims a visitor ID; blank or non-String values become nil.
|
|
10
|
+
def normalize(visitor_id)
|
|
11
|
+
return nil unless visitor_id.is_a?(String)
|
|
12
|
+
|
|
13
|
+
trimmed = visitor_id.strip
|
|
14
|
+
trimmed.empty? ? nil : trimmed
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def from_header_and_cookie(header:, cookie:)
|
|
18
|
+
normalize(header) || normalize(cookie)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Returns the first nonblank _cekat_visitor_id value in a raw Cookie header.
|
|
22
|
+
def from_cookie_header(cookie_header)
|
|
23
|
+
return nil unless cookie_header.is_a?(String)
|
|
24
|
+
|
|
25
|
+
cookie_header.split(";").each do |pair|
|
|
26
|
+
name, separator, value = pair.partition("=")
|
|
27
|
+
next if separator.empty? || name.strip != VISITOR_COOKIE
|
|
28
|
+
|
|
29
|
+
visitor_id = normalize(value)
|
|
30
|
+
return visitor_id if visitor_id
|
|
31
|
+
end
|
|
32
|
+
nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Resolves from a Rack env. The raw Cookie header is used so values are never unescaped.
|
|
36
|
+
def from_rack_env(env)
|
|
37
|
+
from_header_and_cookie(header: env["HTTP_X_CEKAT_VISITOR_ID"], cookie: from_cookie_header(env["HTTP_COOKIE"]))
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "cekat_event_sdk/version"
|
|
4
|
+
|
|
5
|
+
# Cekat event SDK: submit identity-bearing events and correlate them with the browser visitor.
|
|
6
|
+
module CekatEventSdk
|
|
7
|
+
DEFAULT_BASE_URL = "https://server.cekat.ai"
|
|
8
|
+
INGEST_PATH = "/api/events/ingest"
|
|
9
|
+
VISITOR_HEADER = "X-Cekat-Visitor-ID"
|
|
10
|
+
VISITOR_COOKIE = "_cekat_visitor_id"
|
|
11
|
+
MAXIMUM_RESPONSE_BODY_BYTES = 65_536
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
require_relative "cekat_event_sdk/errors"
|
|
15
|
+
require_relative "cekat_event_sdk/event_input"
|
|
16
|
+
require_relative "cekat_event_sdk/acknowledgement"
|
|
17
|
+
require_relative "cekat_event_sdk/visitor_id_resolver"
|
|
18
|
+
require_relative "cekat_event_sdk/visitor_context"
|
|
19
|
+
require_relative "cekat_event_sdk/payload_builder"
|
|
20
|
+
require_relative "cekat_event_sdk/retry_policy"
|
|
21
|
+
require_relative "cekat_event_sdk/transport"
|
|
22
|
+
require_relative "cekat_event_sdk/response_decoder"
|
|
23
|
+
require_relative "cekat_event_sdk/client"
|
|
24
|
+
require_relative "cekat_event_sdk/rack/middleware"
|
|
25
|
+
require_relative "cekat_event_sdk/rails" if defined?(Rails::Railtie)
|
metadata
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: cekat-event-sdk
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Cekat
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: Synchronous Cekat event client with Rack middleware and a Rails integration
|
|
13
|
+
that attach the browser visitor ID of the current request automatically.
|
|
14
|
+
executables: []
|
|
15
|
+
extensions: []
|
|
16
|
+
extra_rdoc_files: []
|
|
17
|
+
files:
|
|
18
|
+
- COMPATIBILITY.md
|
|
19
|
+
- LICENSE
|
|
20
|
+
- README.md
|
|
21
|
+
- lib/cekat-event-sdk.rb
|
|
22
|
+
- lib/cekat_event_sdk.rb
|
|
23
|
+
- lib/cekat_event_sdk/acknowledgement.rb
|
|
24
|
+
- lib/cekat_event_sdk/client.rb
|
|
25
|
+
- lib/cekat_event_sdk/errors.rb
|
|
26
|
+
- lib/cekat_event_sdk/event_input.rb
|
|
27
|
+
- lib/cekat_event_sdk/payload_builder.rb
|
|
28
|
+
- lib/cekat_event_sdk/rack/middleware.rb
|
|
29
|
+
- lib/cekat_event_sdk/rails.rb
|
|
30
|
+
- lib/cekat_event_sdk/response_decoder.rb
|
|
31
|
+
- lib/cekat_event_sdk/retry_policy.rb
|
|
32
|
+
- lib/cekat_event_sdk/transport.rb
|
|
33
|
+
- lib/cekat_event_sdk/version.rb
|
|
34
|
+
- lib/cekat_event_sdk/visitor_context.rb
|
|
35
|
+
- lib/cekat_event_sdk/visitor_id_resolver.rb
|
|
36
|
+
homepage: https://github.com/cekataiofficial/cekat-event-sdk
|
|
37
|
+
licenses:
|
|
38
|
+
- MIT
|
|
39
|
+
metadata:
|
|
40
|
+
source_code_uri: https://github.com/cekataiofficial/cekat-event-sdk/tree/main/ruby
|
|
41
|
+
rubygems_mfa_required: 'true'
|
|
42
|
+
rdoc_options: []
|
|
43
|
+
require_paths:
|
|
44
|
+
- lib
|
|
45
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
46
|
+
requirements:
|
|
47
|
+
- - ">="
|
|
48
|
+
- !ruby/object:Gem::Version
|
|
49
|
+
version: 3.3.0
|
|
50
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - ">="
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '0'
|
|
55
|
+
requirements: []
|
|
56
|
+
rubygems_version: 4.0.20
|
|
57
|
+
specification_version: 4
|
|
58
|
+
summary: Submit identity-bearing Cekat events from Ruby backends with browser visitor
|
|
59
|
+
correlation.
|
|
60
|
+
test_files: []
|