lucerna 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.
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "../errors"
6
+ require_relative "../http"
7
+ require_relative "../reporting"
8
+ require_relative "../version"
9
+
10
+ module Lucerna
11
+ class Identity
12
+ # Fire-and-forget delivery of identify calls to People
13
+ # (POST /sdk/v1/people/identify). Mirrors the wire behavior of
14
+ # @lucerna/identity's sync layer, adapted to a server process: a
15
+ # bounded queue drained by one lazy background worker replaces the JS
16
+ # microtask, and flush/close exist because Ruby processes don't drain
17
+ # a microtask queue on exit.
18
+ #
19
+ # identify validates strictly (bad input is a programming error and
20
+ # fails loud); delivery never raises into the caller — failures report
21
+ # through on_error, and the server's idempotent upsert makes the next
22
+ # identify the retry.
23
+ class Client
24
+ DEFAULT_BASE_URL = "https://api.uselucerna.app"
25
+ DEFAULT_REQUEST_TIMEOUT = 5.0
26
+ DEFAULT_MAX_QUEUE = 1000
27
+
28
+ def initialize(api_key:, base_url: nil, request_timeout: nil, on_error: nil, logger: nil,
29
+ transport: nil, max_queue: DEFAULT_MAX_QUEUE, background: true)
30
+ raise ArgumentError, "api_key is required" if api_key.nil? || api_key.to_s.empty?
31
+
32
+ @url = "#{(base_url || DEFAULT_BASE_URL).sub(%r{/+\z}, "")}/sdk/v1/people/identify"
33
+ @request_timeout = (request_timeout || DEFAULT_REQUEST_TIMEOUT).to_f
34
+ @reporter = Reporting.reporter(on_error, logger)
35
+ @transport = transport || HTTP::NetHttpTransport.new
36
+ @max_queue = max_queue
37
+ @background = background
38
+ @headers = {
39
+ "Authorization" => "Bearer #{api_key}",
40
+ "Content-Type" => "application/json",
41
+ "x-lucerna-sdk" => SDK_IDENTITY,
42
+ }.freeze
43
+
44
+ @queue = Thread::Queue.new
45
+ @worker = nil
46
+ @pid = Process.pid
47
+ @lifecycle_mutex = Mutex.new
48
+ @last_sent = nil # worker-thread-private after spawn
49
+ end
50
+
51
+ # Accepts a Lucerna::Identity, a hash form, or the same keywords as
52
+ # Identity.new. Never blocks the caller: past max_queue the event is
53
+ # dropped and reported instead of applying backpressure.
54
+ def identify(identity = nil, **kwargs)
55
+ identity = identity.nil? && !kwargs.empty? ? Identity.new(**kwargs) : Identity.wrap(identity)
56
+ raise ArgumentError, "identify requires an identity" if identity.nil?
57
+
58
+ payload = JSON.generate(identity.to_payload)
59
+ if @queue.size >= @max_queue
60
+ @reporter.call(Error.new("identity queue full — dropped identify for #{identity.user_id}"))
61
+ return nil
62
+ end
63
+
64
+ if @background
65
+ # Fork guard first: in a freshly forked child it clears the
66
+ # inherited backlog — the enqueue below must survive that.
67
+ ensure_worker
68
+ @queue << payload
69
+ else
70
+ @queue << payload
71
+ drain_now # test seam: deliveries run synchronously on the caller
72
+ end
73
+ nil
74
+ end
75
+
76
+ # Blocks until everything queued before the call has been attempted,
77
+ # or the timeout elapses. Returns true when drained.
78
+ def flush(timeout: 2)
79
+ return true unless @background && @worker&.alive?
80
+
81
+ ack = Thread::Queue.new
82
+ @queue << [:flush, ack]
83
+ !ack.pop(timeout: timeout).nil?
84
+ end
85
+
86
+ # Flushes, then stops the worker. The client stays usable — the next
87
+ # identify re-arms it.
88
+ def close(timeout: 2)
89
+ drained = flush(timeout: timeout)
90
+ worker = @lifecycle_mutex.synchronize { @worker }
91
+ if worker&.alive?
92
+ @queue << :stop
93
+ worker.join(1)
94
+ end
95
+ drained
96
+ end
97
+
98
+ private
99
+
100
+ # Lazy worker spawn, fork-guarded: a forked child inherits the queue
101
+ # contents but not the thread; it must not replay the parent's
102
+ # backlog (N workers × the same events).
103
+ def ensure_worker
104
+ return if Process.pid == @pid && @worker&.alive?
105
+
106
+ @lifecycle_mutex.synchronize do
107
+ return if Process.pid == @pid && @worker&.alive?
108
+
109
+ if Process.pid != @pid
110
+ @queue.clear
111
+ @pid = Process.pid
112
+ end
113
+ @worker = Thread.new { work_loop }
114
+ @worker.name = "lucerna-identity-worker"
115
+ end
116
+ end
117
+
118
+ def work_loop
119
+ loop do
120
+ item = @queue.pop
121
+ case item
122
+ when :stop then return
123
+ when Array then item[1] << true if item[0] == :flush
124
+ else deliver(item)
125
+ end
126
+ end
127
+ end
128
+
129
+ def drain_now
130
+ until @queue.empty?
131
+ item = @queue.pop(true)
132
+ deliver(item) if item.is_a?(String)
133
+ end
134
+ rescue ThreadError
135
+ nil
136
+ end
137
+
138
+ # One attempt, no retry: the server upserts idempotently and the
139
+ # next identify is the retry. A resend of the exact previous
140
+ # successful payload is skipped (lastSent dedupe, sync.ts parity) —
141
+ # a failed send leaves lastSent unchanged so the retry goes through.
142
+ def deliver(payload)
143
+ return if payload == @last_sent
144
+
145
+ request = HTTP::Request.new(:post, @url, @headers, payload)
146
+ response = @transport.call(request, timeout: @request_timeout)
147
+ if (200..299).cover?(response.status)
148
+ @last_sent = payload
149
+ else
150
+ @reporter.call(
151
+ RequestError.new("identity sync failed with status #{response.status}", status: response.status),
152
+ )
153
+ end
154
+ rescue StandardError => error
155
+ @reporter.call(RequestError.new("#{error.class}: #{error.message}"))
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "traits"
4
+
5
+ module Lucerna
6
+ # The shared "who is the user" primitive — one immutable value, built
7
+ # once per request, that feeds both products: gates reads take it
8
+ # positionally, Lucerna.identity.identify sends it to People. Mirrors
9
+ # GatesIdentity / identity.current() in the JS SDKs.
10
+ #
11
+ # PII rules: user_id is a pseudonymous app id ("u_42"), never an email.
12
+ # email and name are first-class fields — never traits — and are only
13
+ # ever transmitted by identify, which routes them to People's encrypted
14
+ # columns. Traits are plaintext targeting data.
15
+ class Identity
16
+ attr_reader :user_id, :email, :name, :traits
17
+
18
+ def initialize(user_id:, email: nil, name: nil, traits: {})
19
+ unless user_id.is_a?(String) && !user_id.empty?
20
+ raise ArgumentError, "user_id must be a non-empty String (got #{user_id.inspect})"
21
+ end
22
+
23
+ @user_id = -user_id
24
+ @email = email&.to_s
25
+ @name = name&.to_s
26
+ @traits = Traits.normalize!(traits).freeze
27
+ freeze
28
+ end
29
+
30
+ # Coerces the accepted identity forms — Identity, Hash, nil — into an
31
+ # Identity (or nil for anonymous). Strict: bad input is a programming
32
+ # error. Gates reads have their own lenient path and never raise.
33
+ def self.wrap(value)
34
+ case value
35
+ when nil then nil
36
+ when Identity then value
37
+ when Hash
38
+ new(
39
+ user_id: value[:user_id] || value["user_id"],
40
+ email: value[:email] || value["email"],
41
+ name: value[:name] || value["name"],
42
+ traits: value[:traits] || value["traits"] || {},
43
+ )
44
+ else
45
+ raise ArgumentError, "expected a Lucerna::Identity, Hash, or nil (got #{value.class})"
46
+ end
47
+ end
48
+
49
+ # Projection for gates evaluation: user id + traits, with the
50
+ # first-class email merged in as a trait fallback so the engine's
51
+ # email→domain derivation makes domain overrides work. Purely local —
52
+ # gates evaluation transmits nothing; only identify sends PII.
53
+ def for_gates
54
+ traits = @traits
55
+ traits = traits.merge("email" => @email) if @email && !traits.key?("email")
56
+ { user_id: @user_id, traits: traits }
57
+ end
58
+
59
+ # Wire payload for POST /sdk/v1/people/identify. Note the rename:
60
+ # local user_id -> wire appUserId. email/name keys are omitted
61
+ # entirely when absent; traits are always present.
62
+ def to_payload
63
+ payload = { "appUserId" => @user_id }
64
+ payload["email"] = @email if @email
65
+ payload["name"] = @name if @name
66
+ payload["traits"] = @traits
67
+ payload
68
+ end
69
+ end
70
+ end
71
+
72
+ require_relative "identity/client"
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lucerna
4
+ # Thin Rails integration — deliberately two hooks and nothing else.
5
+ # Everything that matters (at_exit flush, fork recovery, lazy singleton
6
+ # construction) lives in the core and works without Rails.
7
+ class Railtie < ::Rails::Railtie
8
+ # Runs after config/initializers/lucerna.rb has called
9
+ # Lucerna.configure; fills the logger only when the app didn't set one.
10
+ initializer "lucerna.defaults", after: :load_config_initializers do
11
+ Lucerna.config.logger ||= ::Rails.logger
12
+ end
13
+
14
+ # Warm the gates runtime before the first request instead of on the
15
+ # first read. Off by default: lazy init is fork-safe by construction,
16
+ # and eager threads in a preloading master are wasted work the PID
17
+ # guard would just redo in each worker.
18
+ config.after_initialize do
19
+ Lucerna.gates if Lucerna.config.eager_init && Lucerna.config.server_key
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lucerna
4
+ module Reporting
5
+ module_function
6
+
7
+ # Wraps on_error / logger into an always-callable reporter. A raising
8
+ # handler must never take down a read, the poller, or a worker thread.
9
+ def reporter(on_error, logger)
10
+ handler =
11
+ if on_error
12
+ on_error
13
+ elsif logger
14
+ ->(error) { logger.warn("[lucerna] #{error.class}: #{error.message}") }
15
+ end
16
+ lambda do |error|
17
+ handler&.call(error)
18
+ rescue StandardError
19
+ nil
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lucerna
4
+ # Trait normalization. Traits are always Record<string, string> on the
5
+ # wire and in the engine — stringification happens at the boundary.
6
+ module Traits
7
+ module_function
8
+
9
+ # Strict normalization for Identity construction / identify: bad input
10
+ # is a programming error and fails loud.
11
+ def normalize!(traits)
12
+ raise ArgumentError, "traits must be a Hash (got #{traits.class})" unless traits.is_a?(Hash)
13
+
14
+ traits.each_with_object({}) do |(key, value), out|
15
+ unless value.is_a?(String) || value.is_a?(Symbol) || value.is_a?(Numeric) ||
16
+ value.equal?(true) || value.equal?(false)
17
+ raise ArgumentError,
18
+ "trait #{key.inspect} must be a String, Symbol, Numeric, or boolean (got #{value.class})"
19
+ end
20
+ out[key.to_s] = value.to_s
21
+ end
22
+ end
23
+
24
+ # Lenient normalization for gates reads, which never raise: anything
25
+ # stringifiable passes.
26
+ def normalize(traits)
27
+ return {} unless traits.is_a?(Hash)
28
+
29
+ traits.each_with_object({}) { |(key, value), out| out[key.to_s] = value.to_s }
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lucerna
4
+ VERSION = "0.1.0"
5
+
6
+ # Value of the x-lucerna-sdk header on every request.
7
+ SDK_IDENTITY = "ruby/#{VERSION}"
8
+ end
data/lib/lucerna.rb ADDED
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lucerna/version"
4
+ require_relative "lucerna/errors"
5
+ require_relative "lucerna/reporting"
6
+ require_relative "lucerna/traits"
7
+ require_relative "lucerna/http"
8
+ require_relative "lucerna/config"
9
+ require_relative "lucerna/identity"
10
+ require_relative "lucerna/gates/hashing"
11
+ require_relative "lucerna/gates/evaluate"
12
+ require_relative "lucerna/gates/runtime"
13
+ require_relative "lucerna/gates/exposures"
14
+ require_relative "lucerna/gates/client"
15
+
16
+ # Lucerna SDK for Ruby — Gates feature flags/experiments and Identity.
17
+ #
18
+ # Lucerna.configure do |c|
19
+ # c.server_key = ENV["LUCERNA_SERVER_KEY"]
20
+ # end
21
+ #
22
+ # identity = Lucerna::Identity.new(user_id: user.id.to_s, traits: { plan: "pro" })
23
+ # Lucerna.gates.flag("new_billing", identity)
24
+ # Lucerna.identity.identify(identity)
25
+ module Lucerna
26
+ @mutex = Mutex.new
27
+ @config = nil
28
+ @gates = nil
29
+ @identity = nil
30
+ @at_exit_registered = false
31
+
32
+ class << self
33
+ def config
34
+ @config || @mutex.synchronize { @config ||= Config.new }
35
+ end
36
+
37
+ # Yields the Config. Must run before the first use of Lucerna.gates /
38
+ # Lucerna.identity — configuring an already-built client would be a
39
+ # silent no-op, so it raises instead.
40
+ def configure
41
+ configuration = @mutex.synchronize do
42
+ if @gates || @identity
43
+ raise Error,
44
+ "Lucerna.configure must run before the first use of Lucerna.gates / " \
45
+ "Lucerna.identity — or call Lucerna.restart first"
46
+ end
47
+ @config ||= Config.new
48
+ end
49
+ yield configuration if block_given?
50
+ configuration
51
+ end
52
+
53
+ # The Gates client singleton, built lazily from config on first use.
54
+ def gates
55
+ @gates || @mutex.synchronize { @gates ||= build_gates }
56
+ end
57
+
58
+ # The Identity delivery client singleton, built lazily on first use.
59
+ def identity
60
+ @identity || @mutex.synchronize { @identity ||= build_identity }
61
+ end
62
+
63
+ # Stops polling/delivery and flushes queued exposures and identifies.
64
+ # Also registered via at_exit when the first singleton is built. The
65
+ # clients keep answering stale reads after close.
66
+ def close(timeout: 2)
67
+ gates_client, identity_client = @mutex.synchronize { [@gates, @identity] }
68
+ gates_client&.close(timeout: timeout)
69
+ identity_client&.close(timeout: timeout)
70
+ nil
71
+ end
72
+
73
+ # Abandons the current clients (no flush — the parent process owns its
74
+ # own at_exit flush) and lets the next access rebuild them. For
75
+ # Puma on_worker_boot / Unicorn after_fork / Sidekiq startup; optional,
76
+ # since the clients also self-heal on first read after a fork.
77
+ def restart
78
+ @mutex.synchronize do
79
+ @gates = nil
80
+ @identity = nil
81
+ end
82
+ nil
83
+ end
84
+
85
+ private
86
+
87
+ def build_gates
88
+ configuration = @config ||= Config.new
89
+ key = configuration.server_key
90
+ if key.nil? || key.to_s.empty?
91
+ raise Error, "Lucerna.config.server_key is required for Lucerna.gates — set it in Lucerna.configure"
92
+ end
93
+
94
+ register_at_exit
95
+ Gates::Client.new(
96
+ server_key: key,
97
+ base_url: configuration.base_url,
98
+ refresh_interval: configuration.refresh_interval,
99
+ request_timeout: configuration.request_timeout,
100
+ on_error: configuration.on_error,
101
+ logger: configuration.logger,
102
+ track_exposures: configuration.track_exposures,
103
+ transport: configuration.transport,
104
+ )
105
+ end
106
+
107
+ def build_identity
108
+ configuration = @config ||= Config.new
109
+ key = configuration.identity_key || configuration.server_key
110
+ if key.nil? || key.to_s.empty?
111
+ raise Error,
112
+ "Lucerna.config.identity_key (or server_key) is required for Lucerna.identity — " \
113
+ "set it in Lucerna.configure"
114
+ end
115
+
116
+ register_at_exit
117
+ Identity::Client.new(
118
+ api_key: key,
119
+ base_url: configuration.base_url,
120
+ request_timeout: configuration.request_timeout,
121
+ on_error: configuration.on_error,
122
+ logger: configuration.logger,
123
+ transport: configuration.transport,
124
+ )
125
+ end
126
+
127
+ def register_at_exit
128
+ return if @at_exit_registered
129
+
130
+ @at_exit_registered = true
131
+ at_exit { Lucerna.close }
132
+ end
133
+ end
134
+ end
135
+
136
+ require_relative "lucerna/railtie" if defined?(::Rails::Railtie)
metadata ADDED
@@ -0,0 +1,57 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: lucerna
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Lucerna
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: 'Server-side Lucerna SDK: local Gates evaluation (feature flags, experiments,
13
+ kill switches) with background runtime sync, and fire-and-forget People identify.
14
+ Pure Ruby, zero runtime dependencies, Rails Railtie included.'
15
+ executables: []
16
+ extensions: []
17
+ extra_rdoc_files: []
18
+ files:
19
+ - README.md
20
+ - lib/lucerna.rb
21
+ - lib/lucerna/config.rb
22
+ - lib/lucerna/errors.rb
23
+ - lib/lucerna/gates/client.rb
24
+ - lib/lucerna/gates/evaluate.rb
25
+ - lib/lucerna/gates/exposures.rb
26
+ - lib/lucerna/gates/hashing.rb
27
+ - lib/lucerna/gates/runtime.rb
28
+ - lib/lucerna/http.rb
29
+ - lib/lucerna/identity.rb
30
+ - lib/lucerna/identity/client.rb
31
+ - lib/lucerna/railtie.rb
32
+ - lib/lucerna/reporting.rb
33
+ - lib/lucerna/traits.rb
34
+ - lib/lucerna/version.rb
35
+ homepage: https://uselucerna.app
36
+ licenses:
37
+ - MIT
38
+ metadata:
39
+ rubygems_mfa_required: 'true'
40
+ rdoc_options: []
41
+ require_paths:
42
+ - lib
43
+ required_ruby_version: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '3.2'
48
+ required_rubygems_version: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: '0'
53
+ requirements: []
54
+ rubygems_version: 4.0.10
55
+ specification_version: 4
56
+ summary: Lucerna SDK for Ruby — Gates feature flags and experiments, and Identity.
57
+ test_files: []