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 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,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "cekat_event_sdk"
@@ -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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CekatEventSdk
4
+ VERSION = "0.1.0"
5
+ 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: []