flagdash 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: 3fdf5074eeb7ba8c22b3a292be35ddd6fd8cabd0e6e81205d3da171435363534
4
+ data.tar.gz: 36dd77ce355adba2e9d4d1efb20e8301372789f09dd91e3cf6e44230e572e1a4
5
+ SHA512:
6
+ metadata.gz: 9beb6ee3cd7499008d39b34bf4fe54e7481664bd2eec5805b15dfd317020114a1da3373b032c6450ac191b1fdd954031bbb63a77c96af4c71ed5758c8a4a5da5
7
+ data.tar.gz: 4d963f879cd9650135a689dfed75f5f1f018b7edd6a7d5bfe8d60b722d1523ecf7120f40c8d313d17ce9ba82435fe883c4788b6afaf5fdb06344d121a71ecd03
data/LICENSE ADDED
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 FlagDash
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,187 @@
1
+ # FlagDash Ruby SDK
2
+
3
+ Feature flags, remote config, AI configs, translations and experiments for
4
+ Ruby 3.1+.
5
+
6
+ Standard library only — no runtime dependencies.
7
+
8
+ ## Installation
9
+
10
+ ```ruby
11
+ gem "flagdash"
12
+ ```
13
+
14
+ Or straight from GitHub, pinned to an immutable tag:
15
+
16
+ ```ruby
17
+ gem "flagdash", github: "flagdash/flagdash-ruby", tag: "v0.1.0"
18
+ ```
19
+
20
+ ## Quick start
21
+
22
+ ```ruby
23
+ require "flagdash"
24
+
25
+ client = FlagDash::Client.new(sdk_key: ENV.fetch("FLAGDASH_SDK_KEY"))
26
+
27
+ if client.flag("checkout-v2", default: false, context: {user_id: "alice"})
28
+ # new checkout
29
+ end
30
+
31
+ client.close
32
+ ```
33
+
34
+ ## API key tiers
35
+
36
+ The key decides which project and environment you read, and what you may
37
+ reach. There is no `environment` argument anywhere in this SDK — the key
38
+ carries it.
39
+
40
+ | Key | Prefix | Reaches |
41
+ |---|---|---|
42
+ | Client | `pk_` | Flag values and configs. Safe in a browser or mobile app. |
43
+ | Server | `sk_` | The above, plus targeting rules, translations and experiments. Keep it server-side. |
44
+
45
+ ## Configuration
46
+
47
+ ```ruby
48
+ client = FlagDash::Client.new(
49
+ sdk_key: ENV.fetch("FLAGDASH_SDK_KEY"),
50
+ base_url: "https://flagdash.io", # self-hosted? point it here
51
+ timeout: 5, # seconds per request
52
+ cache_ttl: 60, # seconds; 0 disables caching
53
+ region: "eu-west-1", # omit to auto-detect
54
+ transport: nil # inject for tests
55
+ )
56
+ ```
57
+
58
+ `region` is detected from `FLY_REGION`, `AWS_REGION` and friends when omitted,
59
+ so region-scoped targeting works with no wiring.
60
+
61
+ ## Feature flags
62
+
63
+ ```ruby
64
+ # One flag, with the fallback used whenever FlagDash cannot be reached.
65
+ client.flag("checkout-v2", default: false, context: {user_id: "alice"})
66
+
67
+ # Every flag for this context in one request.
68
+ client.all_flags(context: {user_id: "alice", country: "GB"})
69
+
70
+ # Why did it resolve that way?
71
+ detail = client.flag_detail("checkout-v2", context: {user_id: "alice"})
72
+ detail.value # => true
73
+ detail.reason # => "rule_match"
74
+ detail.variation # => "treatment"
75
+
76
+ # Flag metadata without evaluating anything.
77
+ client.list_flags
78
+ ```
79
+
80
+ ### Context
81
+
82
+ Both shapes work. A nested `user:` hash is flattened — `id` becomes `user_id`
83
+ and every other key becomes `user_<key>`:
84
+
85
+ ```ruby
86
+ client.flag("beta", context: {user_id: "alice", country: "GB"})
87
+ client.flag("beta", context: {user: {id: "alice", plan: "premium"}})
88
+ ```
89
+
90
+ **Send an identifier** (`user_id`, `unit_id`, or `user: {id:}`) whenever you
91
+ want a stable answer. Percentage rollouts and A/B variations hash it, so an
92
+ anonymous context re-rolls on every call by design.
93
+
94
+ Note that `flag` without a context serves from the cached `all_flags` payload;
95
+ with a context it asks for a fresh evaluation.
96
+
97
+ ## Remote config
98
+
99
+ ```ruby
100
+ client.config("rate_limit", default: 100)
101
+ client.get_config("rate_limit") # full record, not just the value
102
+ client.list_configs
103
+ ```
104
+
105
+ ## AI configs
106
+
107
+ Prompts, agents, skills and rules, versioned per environment and editable
108
+ without a deploy.
109
+
110
+ ```ruby
111
+ client.ai_config("support-agent.md")
112
+ client.list_ai_configs(file_type: "agent")
113
+ client.list_ai_configs(folder: "support") # :any for every folder
114
+ ```
115
+
116
+ ## Translations
117
+
118
+ ```ruby
119
+ client.translation("checkout.greeting", locale: "fr", default: "Hello",
120
+ variables: {name: "Alice"})
121
+ ```
122
+
123
+ The key is `namespace.message`. `{placeholders}` come from `variables`, and the
124
+ default is returned whenever the catalogue, namespace or message is missing — a
125
+ lookup never raises.
126
+
127
+ ## Experiments
128
+
129
+ ```ruby
130
+ assignment = client.experiment("checkout-redesign", context: {user_id: "alice"})
131
+
132
+ if assignment && assignment["variant"] == "treatment"
133
+ # ...
134
+ end
135
+
136
+ client.track_experiment_metric(
137
+ experiment_key: "checkout-redesign",
138
+ event_name: "purchase",
139
+ user_id: "alice",
140
+ value: 42.50,
141
+ properties: {currency: "GBP"}
142
+ )
143
+ ```
144
+
145
+ `experiment` returns `nil` for a context with no identifier — an assignment
146
+ that cannot be stable is worse than none.
147
+
148
+ Metrics are buffered in memory and only touch the network on `flush` or
149
+ `close`:
150
+
151
+ ```ruby
152
+ client.flush # send now
153
+ client.close # flush and release the connection
154
+ ```
155
+
156
+ In a long-running process call `flush` periodically; in a request/response
157
+ cycle, `close` at the end is enough.
158
+
159
+ ## Caching
160
+
161
+ Reads are cached in memory for `cache_ttl` seconds (60 by default), so a burst
162
+ of `flag` calls costs one request.
163
+
164
+ ```ruby
165
+ client.clear_cache
166
+ ```
167
+
168
+ ## Failure behaviour
169
+
170
+ This SDK deliberately splits the two cases:
171
+
172
+ - **Evaluation reads return your default.** `flag`, `config` and `translation`
173
+ degrade to the fallback rather than raising, so an outage cannot take a
174
+ request path down with it.
175
+ - **Metadata lists raise.** `list_flags`, `list_configs` and `list_ai_configs`
176
+ are operational calls, and swallowing their failure would hide a bad key or a
177
+ wrong base URL behind an empty array.
178
+
179
+ ## Security
180
+
181
+ Keep the server key out of anything you ship to a browser or a phone. Grant a
182
+ key only the read scopes it needs; a client key never receives targeting rules,
183
+ so the client cannot see who else you are targeting.
184
+
185
+ ## License
186
+
187
+ MIT
@@ -0,0 +1,3 @@
1
+ module FlagDash
2
+ VERSION = "0.1.0"
3
+ end
data/lib/flagdash.rb ADDED
@@ -0,0 +1,189 @@
1
+ require "json"
2
+ require "net/http"
3
+ require "securerandom"
4
+ require "time"
5
+ require "uri"
6
+ require_relative "flagdash/version"
7
+
8
+ module FlagDash
9
+ Detail = Struct.new(:key, :value, :reason, :variation_key, keyword_init: true)
10
+
11
+ class Client
12
+ REGION_ENV = %w[FLAGDASH_REGION FLY_REGION AWS_REGION AWS_DEFAULT_REGION VERCEL_REGION GOOGLE_CLOUD_REGION RAILWAY_REPLICA_REGION RENDER_REGION].freeze
13
+
14
+ def initialize(sdk_key:, base_url: "https://flagdash.io", timeout: 5, cache_ttl: 60, region: nil, transport: nil)
15
+ raise ArgumentError, "sdk_key is required" if sdk_key.to_s.empty?
16
+
17
+ @sdk_key = sdk_key
18
+ @base_url = base_url.delete_suffix("/")
19
+ @timeout = timeout
20
+ @cache_ttl = cache_ttl
21
+ @region = region || REGION_ENV.filter_map { |name| ENV[name] }.first
22
+ @transport = transport || method(:http_request)
23
+ @cache = {}
24
+ @mutex = Mutex.new
25
+ @events = []
26
+ end
27
+
28
+ def flag(key, default: false, context: nil)
29
+ return flag_detail(key, default: default, context: context).value if context && !context.empty?
30
+
31
+ cached("flag:#{key}") { all_flags.fetch(key, default) }
32
+ end
33
+
34
+ def flag_detail(key, default: nil, context: nil)
35
+ data = request(:get, "/server/flags/#{segment(key)}", params: context_params(context))
36
+ flag = data.fetch("flag")
37
+ Detail.new(key: flag.fetch("key", key), value: flag.fetch("evaluated_value", default),
38
+ reason: flag.fetch("evaluation_path", "default"), variation_key: flag["variation_key"])
39
+ rescue StandardError
40
+ Detail.new(key: key, value: default, reason: "default", variation_key: nil)
41
+ end
42
+
43
+ def all_flags(context: nil)
44
+ return fetch_flags(context) if context && !context.empty?
45
+
46
+ cached("all_flags") { fetch_flags(nil) }
47
+ end
48
+
49
+ def list_flags
50
+ request(:get, "/server/flags", params: context_params(nil)).fetch("flags", [])
51
+ end
52
+
53
+ def config(key, default: nil)
54
+ cached("config:#{key}") do
55
+ request(:get, "/server/configs/#{segment(key)}").fetch("config", {}).fetch("value", default)
56
+ end
57
+ rescue StandardError
58
+ default
59
+ end
60
+
61
+ def get_config(key)
62
+ request(:get, "/server/configs/#{segment(key)}")["config"]
63
+ end
64
+
65
+ def list_configs
66
+ request(:get, "/server/configs").fetch("configs", [])
67
+ end
68
+
69
+ def ai_config(file_name)
70
+ cached("ai:#{file_name}") { request(:get, "/server/ai-configs/#{segment(file_name)}")["ai_config"] }
71
+ rescue StandardError
72
+ nil
73
+ end
74
+
75
+ def list_ai_configs(file_type: nil, folder: :any)
76
+ request(:get, "/server/ai-configs").fetch("ai_configs", []).select do |item|
77
+ (!file_type || item["file_type"] == file_type.to_s) && (folder == :any || item["folder"] == folder)
78
+ end
79
+ end
80
+
81
+ def translation(key, locale:, default: nil, variables: {})
82
+ namespace, message = key.split(".", 2)
83
+ return default || key unless message
84
+
85
+ catalog = cached("translation:#{locale}:#{namespace}") do
86
+ request(:get, "/server/translations/#{segment(locale)}/#{segment(namespace)}").fetch("catalog", {})
87
+ end
88
+ pattern = catalog.fetch("messages", {})[message]
89
+ return default || key unless pattern
90
+
91
+ pattern.gsub(/\{([\w.]+)\}/) { |match| variables.fetch(Regexp.last_match(1), variables.fetch(Regexp.last_match(1).to_sym, match)).to_s }
92
+ rescue StandardError
93
+ default || key
94
+ end
95
+
96
+ def experiment(key, context:)
97
+ return nil unless identity(context)
98
+
99
+ request(:get, "/server/experiments/#{segment(key)}", params: context_params(context))["experiment"]
100
+ rescue StandardError
101
+ nil
102
+ end
103
+
104
+ def track_experiment_metric(experiment_key:, event_name:, user_id:, value: nil, properties: {}, event_id: nil, occurred_at: nil)
105
+ event = {event_id: event_id || "evt_#{SecureRandom.uuid}", experiment_key: experiment_key,
106
+ event_name: event_name, user_id: user_id, value: value, properties: properties,
107
+ occurred_at: occurred_at || Time.now.utc.iso8601}
108
+ @mutex.synchronize { @events << event if @events.length < 1_000 }
109
+ nil
110
+ end
111
+
112
+ def flush
113
+ loop do
114
+ batch = @mutex.synchronize { @events.first(100) }
115
+ break if batch.empty?
116
+
117
+ request(:post, "/server/experiment-events/batch", json: {events: batch})
118
+ @mutex.synchronize { @events.shift(batch.length) }
119
+ end
120
+ true
121
+ rescue StandardError
122
+ false
123
+ end
124
+
125
+ def clear_cache
126
+ @mutex.synchronize { @cache.clear }
127
+ end
128
+
129
+ def close
130
+ flush
131
+ end
132
+
133
+ private
134
+
135
+ def fetch_flags(context)
136
+ values = request(:get, "/server/flags", params: context_params(context)).fetch("evaluated", {})
137
+ @mutex.synchronize { values.each { |key, value| put_cache("flag:#{key}", value) } } unless context
138
+ values
139
+ end
140
+
141
+ def cached(key)
142
+ return yield if @cache_ttl.zero?
143
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
144
+ hit = @mutex.synchronize { @cache[key] }
145
+ return hit[0] if hit && hit[1] > now
146
+
147
+ value = yield
148
+ @mutex.synchronize { put_cache(key, value) }
149
+ value
150
+ end
151
+
152
+ def put_cache(key, value)
153
+ @cache[key] = [value, Process.clock_gettime(Process::CLOCK_MONOTONIC) + @cache_ttl]
154
+ end
155
+
156
+ def request(method, path, params: {}, json: nil)
157
+ @transport.call(method, "#{@base_url}/api/v1#{path}", params, json, @sdk_key, @timeout)
158
+ end
159
+
160
+ def http_request(method, url, params, json, key, timeout)
161
+ uri = URI(url)
162
+ uri.query = URI.encode_www_form(params) unless params.empty?
163
+ request = method == :post ? Net::HTTP::Post.new(uri) : Net::HTTP::Get.new(uri)
164
+ request["Authorization"] = "Bearer #{key}"
165
+ request["Content-Type"] = "application/json"
166
+ request.body = JSON.generate(json) if json
167
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: timeout, read_timeout: timeout) { |http| http.request(request) }
168
+ raise "FlagDash HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
169
+
170
+ JSON.parse(response.body)
171
+ end
172
+
173
+ def context_params(context)
174
+ result = (context || {}).transform_keys(&:to_s)
175
+ user = result.delete("user")
176
+ user&.each { |key, value| result[key.to_s == "id" ? "user_id" : "user_#{key}"] = value }
177
+ result["region"] ||= @region if @region
178
+ result
179
+ end
180
+
181
+ def identity(context)
182
+ context[:user_id] || context["user_id"] || context[:unit_id] || context["unit_id"] || context.dig(:user, :id) || context.dig("user", "id")
183
+ end
184
+
185
+ def segment(value)
186
+ URI.encode_www_form_component(value.to_s).gsub("+", "%20")
187
+ end
188
+ end
189
+ end
metadata ADDED
@@ -0,0 +1,49 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: flagdash
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - FlagDash
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-08-25 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description:
14
+ email:
15
+ - support@flagdash.com
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - LICENSE
21
+ - README.md
22
+ - lib/flagdash.rb
23
+ - lib/flagdash/version.rb
24
+ homepage: https://flagdash.com/docs#sdk-ruby
25
+ licenses:
26
+ - MIT
27
+ metadata:
28
+ source_code_uri: https://github.com/flagdash/flagdash-ruby
29
+ changelog_uri: https://github.com/flagdash/flagdash-ruby/releases
30
+ post_install_message:
31
+ rdoc_options: []
32
+ require_paths:
33
+ - lib
34
+ required_ruby_version: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '3.1'
39
+ required_rubygems_version: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - ">="
42
+ - !ruby/object:Gem::Version
43
+ version: '0'
44
+ requirements: []
45
+ rubygems_version: 3.5.22
46
+ signing_key:
47
+ specification_version: 4
48
+ summary: Official server-side FlagDash SDK for Ruby
49
+ test_files: []