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.
- checksums.yaml +7 -0
- data/README.md +88 -0
- data/lib/lucerna/config.rb +37 -0
- data/lib/lucerna/errors.rb +19 -0
- data/lib/lucerna/gates/client.rb +355 -0
- data/lib/lucerna/gates/evaluate.rb +309 -0
- data/lib/lucerna/gates/exposures.rb +105 -0
- data/lib/lucerna/gates/hashing.rb +71 -0
- data/lib/lucerna/gates/runtime.rb +23 -0
- data/lib/lucerna/http.rb +77 -0
- data/lib/lucerna/identity/client.rb +159 -0
- data/lib/lucerna/identity.rb +72 -0
- data/lib/lucerna/railtie.rb +22 -0
- data/lib/lucerna/reporting.rb +23 -0
- data/lib/lucerna/traits.rb +32 -0
- data/lib/lucerna/version.rb +8 -0
- data/lib/lucerna.rb +136 -0
- metadata +57 -0
|
@@ -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
|
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: []
|