flagnetic 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/LICENSE +202 -0
- data/README.md +276 -0
- data/lib/flagnetic/backoff.rb +29 -0
- data/lib/flagnetic/changes.rb +88 -0
- data/lib/flagnetic/client.rb +609 -0
- data/lib/flagnetic/controller_helpers.rb +97 -0
- data/lib/flagnetic/detail.rb +73 -0
- data/lib/flagnetic/eval/evaluation.rb +382 -0
- data/lib/flagnetic/eval/instant.rb +71 -0
- data/lib/flagnetic/eval/murmur3.rb +70 -0
- data/lib/flagnetic/eval/operators.rb +225 -0
- data/lib/flagnetic/eval/regex.rb +647 -0
- data/lib/flagnetic/eval/ruleset.rb +267 -0
- data/lib/flagnetic/eval/semver.rb +80 -0
- data/lib/flagnetic/eval/types.rb +148 -0
- data/lib/flagnetic/eval/unicode_tables.rb +180 -0
- data/lib/flagnetic/eval.rb +38 -0
- data/lib/flagnetic/event_batch.rb +119 -0
- data/lib/flagnetic/exposures.rb +342 -0
- data/lib/flagnetic/fallback_chain.rb +230 -0
- data/lib/flagnetic/generations.rb +50 -0
- data/lib/flagnetic/global.rb +59 -0
- data/lib/flagnetic/local_overrides.rb +65 -0
- data/lib/flagnetic/logging.rb +62 -0
- data/lib/flagnetic/railtie.rb +146 -0
- data/lib/flagnetic/recorder.rb +135 -0
- data/lib/flagnetic/reporter.rb +94 -0
- data/lib/flagnetic/sse_parser.rb +94 -0
- data/lib/flagnetic/store.rb +34 -0
- data/lib/flagnetic/stream.rb +65 -0
- data/lib/flagnetic/summaries.rb +257 -0
- data/lib/flagnetic/testing.rb +160 -0
- data/lib/flagnetic/transport.rb +106 -0
- data/lib/flagnetic/uuid.rb +25 -0
- data/lib/flagnetic/version.rb +16 -0
- data/lib/flagnetic/worker.rb +270 -0
- data/lib/flagnetic.rb +34 -0
- metadata +79 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Flagnetic
|
|
6
|
+
# LocalOverrides substitutes flag values inside this process, on a
|
|
7
|
+
# developer's machine or in a non-production build. A substituted value
|
|
8
|
+
# never reaches the server. An overridden flag reads reason
|
|
9
|
+
# local_override, with no experiment block and the stage unchanged.
|
|
10
|
+
#
|
|
11
|
+
# Local overrides exist only on a client constructed with
|
|
12
|
+
# allow_overrides: true, and are refused when the held ruleset belongs to
|
|
13
|
+
# a protected environment: set raises LocalOverrideRefused and logs
|
|
14
|
+
# override_refused, and a protected ruleset arriving later drops every
|
|
15
|
+
# override. Setting or removing one fires on_change.
|
|
16
|
+
#
|
|
17
|
+
# client.local_overrides.set("new_checkout", true)
|
|
18
|
+
class LocalOverrides
|
|
19
|
+
def initialize(client)
|
|
20
|
+
@client = client
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# set substitutes value for the flag key. The value must be of the
|
|
24
|
+
# flag's type: true or false, a String, a Numeric, or for a JSON flag
|
|
25
|
+
# anything JSON can encode. A key the ruleset lacks takes the type of
|
|
26
|
+
# the value. Raises LocalOverridesNotAllowed, LocalOverrideRefused, or
|
|
27
|
+
# ArgumentError for a value of the wrong type.
|
|
28
|
+
def set(key, value)
|
|
29
|
+
@client.__send__(:set_local_override, key.to_s, value)
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# remove drops the local override of key, firing on_change when there
|
|
34
|
+
# was one.
|
|
35
|
+
def remove(key)
|
|
36
|
+
@client.__send__(:remove_local_overrides, [key.to_s])
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# clear drops every local override, firing on_change with their keys.
|
|
41
|
+
def clear
|
|
42
|
+
@client.__send__(:remove_local_overrides, nil)
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# list returns the local overrides by flag key.
|
|
47
|
+
def list = @client.__send__(:local_override_values)
|
|
48
|
+
|
|
49
|
+
# normalize checks value against a flag type and returns the frozen
|
|
50
|
+
# value to serve.
|
|
51
|
+
def self.normalize(type, value)
|
|
52
|
+
case type
|
|
53
|
+
when "boolean" then return value if [true, false].include?(value)
|
|
54
|
+
when "string" then return value.dup.freeze if value.is_a?(String)
|
|
55
|
+
when "number" then return value if value.is_a?(Numeric) && value.real? && value.to_f.finite?
|
|
56
|
+
when "json" then return JSON.parse(JSON.generate(value), freeze: true)
|
|
57
|
+
else raise ArgumentError, "flag type #{type.inspect} is unknown"
|
|
58
|
+
end
|
|
59
|
+
raise ArgumentError, "a #{value.class} is not a value of a #{type} flag"
|
|
60
|
+
rescue JSON::GeneratorError, JSON::ParserError => e
|
|
61
|
+
raise ArgumentError, "a #{value.class} is not a JSON value: #{e.message}"
|
|
62
|
+
end
|
|
63
|
+
private_class_method :normalize
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flagnetic
|
|
4
|
+
# EVENTS are the stable names of log events: alert on them. The fields
|
|
5
|
+
# each carries are listed in the README.
|
|
6
|
+
EVENTS = %i[
|
|
7
|
+
ruleset_loaded fetch_failed sse_connected sse_dropped stage_changed key_revoked schema_unsupported
|
|
8
|
+
rule_unsupported override_set override_refused local_overrides_allowed type_mismatch evaluation_failed
|
|
9
|
+
listener_failed store_failed worker_failed queue_dropped exposure_skipped ingest_failed exposures_disabled
|
|
10
|
+
client_not_configured
|
|
11
|
+
].freeze
|
|
12
|
+
|
|
13
|
+
# LEVELS are the log levels, lowest first.
|
|
14
|
+
LEVELS = %i[debug info warn error].freeze
|
|
15
|
+
|
|
16
|
+
# Logging adapts the logger option: a callable receiving
|
|
17
|
+
# (level, event, fields), or a standard library ::Logger (anything
|
|
18
|
+
# responding to add), which receives every level and applies its own.
|
|
19
|
+
module Logging
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# SEVERITIES are the standard library Logger's severities by level.
|
|
23
|
+
SEVERITIES = { debug: 0, info: 1, warn: 2, error: 3 }.freeze
|
|
24
|
+
|
|
25
|
+
# standard is the default logger: warn and above to $stderr, through
|
|
26
|
+
# the standard library's Logger when the application has loaded it (a
|
|
27
|
+
# Rails application has), else as the same lines through Kernel#warn.
|
|
28
|
+
# The gem never requires logger itself: from Ruby 3.5 it is a bundled
|
|
29
|
+
# gem, which would be a runtime dependency.
|
|
30
|
+
def standard(level: :warn)
|
|
31
|
+
logger = defined?(::Logger) ? ::Logger.new($stderr, progname: "flagnetic") : nil
|
|
32
|
+
min = LEVELS.index(level)
|
|
33
|
+
lambda do |lvl, event, fields|
|
|
34
|
+
next if LEVELS.index(lvl) < min
|
|
35
|
+
|
|
36
|
+
line = format_line(event, fields)
|
|
37
|
+
logger ? logger.add(SEVERITIES[lvl], line) : Kernel.warn("flagnetic #{lvl.upcase}: #{line}")
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# adapt turns the logger option into a callable.
|
|
42
|
+
def adapt(logger)
|
|
43
|
+
return standard if logger.nil?
|
|
44
|
+
return logger if logger.respond_to?(:call)
|
|
45
|
+
|
|
46
|
+
if logger.respond_to?(:add)
|
|
47
|
+
return lambda { |lvl, event, fields|
|
|
48
|
+
logger.add(SEVERITIES[lvl], format_line(event, fields))
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
raise ArgumentError, "logger must respond to call(level, event, fields) or be a Logger, got #{logger.class}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def format_line(event, fields)
|
|
56
|
+
([event.to_s] + fields.map { |k, v| "#{k}=#{v.is_a?(String) ? v.inspect : v}" }).join(" ")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private_constant :SEVERITIES
|
|
60
|
+
private_class_method :standard, :format_line
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/railtie"
|
|
4
|
+
require_relative "../flagnetic"
|
|
5
|
+
require_relative "controller_helpers"
|
|
6
|
+
|
|
7
|
+
module Flagnetic
|
|
8
|
+
# Railtie builds Flagnetic.client from the Rails configuration. The gem
|
|
9
|
+
# loads it only when Rails is loaded; nothing else in the gem knows Rails.
|
|
10
|
+
#
|
|
11
|
+
# Configuration, all under config.flagnetic, with the SDK key and base
|
|
12
|
+
# URL taken from the first of config, the FLAGNETIC_SDK_KEY and
|
|
13
|
+
# FLAGNETIC_BASE_URL environment variables, and credentials
|
|
14
|
+
# (flagnetic.sdk_key, flagnetic.base_url):
|
|
15
|
+
#
|
|
16
|
+
# - sdk_key, base_url: see above. Without both the client serves
|
|
17
|
+
# built-in defaults and logs client_not_configured.
|
|
18
|
+
# - offline_file: a ruleset document to serve instead; no network.
|
|
19
|
+
# - start: nil (default) starts the client at boot in a server or job
|
|
20
|
+
# process and on first use in rails console, rails runner and rake;
|
|
21
|
+
# true starts it at boot everywhere; false only on first use.
|
|
22
|
+
# - ready_timeout: seconds a boot waits for the ruleset (default 5).
|
|
23
|
+
# - close_timeout: seconds the exit flush may take (default 5).
|
|
24
|
+
# - allow_overrides: false disables local overrides, which are otherwise
|
|
25
|
+
# allowed in every Rails environment but production, where no option
|
|
26
|
+
# enables them.
|
|
27
|
+
# - logger: the logger option of Client.new (default Rails.logger).
|
|
28
|
+
# - client_options: any other Client.new option, as a Hash.
|
|
29
|
+
# - platform, app_version: predeclared attributes of every request
|
|
30
|
+
# context, and the client's platform and app_version options, so
|
|
31
|
+
# User-Agent, evaluation summaries and exposures outside a request
|
|
32
|
+
# carry them too.
|
|
33
|
+
# - user_unit_type, user_id, anonymous_unit_type, anonymous_cookie,
|
|
34
|
+
# attributes: how the controller helper builds a request's context;
|
|
35
|
+
# see ControllerHelpers.
|
|
36
|
+
#
|
|
37
|
+
# The client is started per process: a Puma or Unicorn worker starts its
|
|
38
|
+
# own background thread the moment it is forked, and every process
|
|
39
|
+
# closes its client at exit, which flushes what it has queued.
|
|
40
|
+
class Railtie < ::Rails::Railtie
|
|
41
|
+
config.flagnetic = ActiveSupport::OrderedOptions.new
|
|
42
|
+
config.flagnetic.start = nil
|
|
43
|
+
config.flagnetic.ready_timeout = 5
|
|
44
|
+
config.flagnetic.close_timeout = 5
|
|
45
|
+
config.flagnetic.client_options = {}
|
|
46
|
+
config.flagnetic.anonymous_cookie = "flagnetic_anonymous_id"
|
|
47
|
+
config.flagnetic.user_id = ControllerHelpers::SIGNED_IN_USER_ID
|
|
48
|
+
|
|
49
|
+
# EMPTY is the ruleset an unconfigured client serves: no flags, so every
|
|
50
|
+
# getter returns its built-in default.
|
|
51
|
+
EMPTY = { "schema_version" => 1, "version" => 0, "scope" => "server", "flags" => [] }.freeze
|
|
52
|
+
private_constant :EMPTY
|
|
53
|
+
|
|
54
|
+
# A Rakefile loads the application's tasks before any task boots the
|
|
55
|
+
# environment, so this runs first: rake is a one-off process.
|
|
56
|
+
rake_tasks { Railtie.one_off = true }
|
|
57
|
+
|
|
58
|
+
initializer "flagnetic.controller_helpers" do
|
|
59
|
+
ActiveSupport.on_load(:action_controller) { include Flagnetic::ControllerHelpers }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
config.after_initialize { |app| Railtie.boot(app) }
|
|
63
|
+
|
|
64
|
+
class << self
|
|
65
|
+
attr_writer :one_off
|
|
66
|
+
|
|
67
|
+
# boot installs the builder of Flagnetic.client and, in a server or
|
|
68
|
+
# job process, builds the client now and waits for its ruleset.
|
|
69
|
+
def boot(app)
|
|
70
|
+
config = app.config.flagnetic
|
|
71
|
+
Flagnetic.client_builder = -> { build(app) }
|
|
72
|
+
Process.singleton_class.prepend(ForkHook) unless Process.singleton_class < ForkHook
|
|
73
|
+
start = config.start.nil? ? !one_off? : config.start
|
|
74
|
+
Flagnetic.client if start
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# build constructs the client from the configuration and registers
|
|
78
|
+
# its close at exit.
|
|
79
|
+
def build(app)
|
|
80
|
+
config = app.config.flagnetic
|
|
81
|
+
client = new_client(app)
|
|
82
|
+
at_exit { client.close(timeout: config.close_timeout) }
|
|
83
|
+
client
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# after_fork starts the background thread of a forked child: a Puma
|
|
87
|
+
# or Unicorn worker then follows the change stream before its first
|
|
88
|
+
# request. A client not built yet is left to be built on first use.
|
|
89
|
+
def after_fork
|
|
90
|
+
Flagnetic.client.after_fork if Flagnetic.client?
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
# new_client constructs the client the configuration describes.
|
|
96
|
+
# Local overrides stay off in production whatever client_options say.
|
|
97
|
+
def new_client(app)
|
|
98
|
+
config = app.config.flagnetic
|
|
99
|
+
log = Logging.adapt(config.logger || ::Rails.logger)
|
|
100
|
+
sdk_key = setting(app, :sdk_key)
|
|
101
|
+
base_url = setting(app, :base_url)
|
|
102
|
+
allow_overrides = config.allow_overrides != false && !::Rails.env.production?
|
|
103
|
+
options = { **config.client_options, logger: log, allow_overrides: allow_overrides }
|
|
104
|
+
options[:platform] = config.platform if config.platform
|
|
105
|
+
options[:app_version] = config.app_version if config.app_version
|
|
106
|
+
if config.offline_file
|
|
107
|
+
Client.new(offline_file: config.offline_file, **options)
|
|
108
|
+
elsif sdk_key && base_url
|
|
109
|
+
Client.new(sdk_key, base_url: base_url, **options).tap { |c| c.ready(timeout: config.ready_timeout) }
|
|
110
|
+
else
|
|
111
|
+
log.call(:warn, :client_not_configured, { rails_env: ::Rails.env.to_s })
|
|
112
|
+
Client.new(offline: EMPTY, **options, send_summaries: false)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# setting reads name from config, the environment variable, then
|
|
117
|
+
# credentials; blank is unset.
|
|
118
|
+
def setting(app, name)
|
|
119
|
+
[app.config.flagnetic[name], ENV.fetch("FLAGNETIC_#{name.upcase}", nil), credential(app, name)].find(&:present?)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def credential(app, name)
|
|
123
|
+
app.credentials.dig(:flagnetic, name)
|
|
124
|
+
rescue StandardError
|
|
125
|
+
nil
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# one_off tells whether this process is rails console, rails runner or
|
|
129
|
+
# rake: a process the client must not start in unless it is asked
|
|
130
|
+
# for.
|
|
131
|
+
def one_off?
|
|
132
|
+
@one_off || defined?(::Rails::Console) || defined?(::Rails::Command::RunnerCommand) ? true : false
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# ForkHook runs Railtie.after_fork in every forked child (Ruby 3.1's
|
|
137
|
+
# Process._fork, which every fork goes through).
|
|
138
|
+
module ForkHook
|
|
139
|
+
def _fork
|
|
140
|
+
pid = super
|
|
141
|
+
Railtie.after_fork if pid.zero?
|
|
142
|
+
pid
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Flagnetic
|
|
6
|
+
# Recorder holds in memory what an offline client would report, so a
|
|
7
|
+
# test can assert on it: the running evaluation counts, every evaluation
|
|
8
|
+
# summary a flush or close would have sent, and every exposure and alias
|
|
9
|
+
# the client recorded. It is thread-safe.
|
|
10
|
+
class Recorder
|
|
11
|
+
# SummaryCount is one row of an evaluation summary: how often a flag
|
|
12
|
+
# served a variation for an evaluation reason. It carries no unit IDs
|
|
13
|
+
# and no values; variation is nil for local_override and fallback.
|
|
14
|
+
# count is the wire name; Struct#count is not used on it.
|
|
15
|
+
SummaryCount = Struct.new(:flag, :variation, :reason, :count, keyword_init: true) # rubocop:disable Lint/StructNewOverride
|
|
16
|
+
|
|
17
|
+
# EvaluationSummary is one evaluation summary an offline client would
|
|
18
|
+
# have sent, as its own event: a deduplication key, the interval and
|
|
19
|
+
# ruleset version it reports, the app_version option, and its rows.
|
|
20
|
+
EvaluationSummary = Struct.new(:event_id, :interval_start, :interval_end, :ruleset_version, :app_version, :counts,
|
|
21
|
+
keyword_init: true) do
|
|
22
|
+
def initialize(**fields)
|
|
23
|
+
super
|
|
24
|
+
freeze
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Exposure is one exposure an offline client would have sent.
|
|
29
|
+
Exposure = Struct.new(:event_id, :experiment_key, :flag_key, :variation_key, :unit_type, :unit_id, :platform,
|
|
30
|
+
:app_version, :reason, :override, :ruleset_version, :occurred_at, keyword_init: true) do
|
|
31
|
+
def initialize(**fields)
|
|
32
|
+
super
|
|
33
|
+
freeze
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Alias is one alias an offline client would have sent.
|
|
38
|
+
Alias = Struct.new(:event_id, :from_unit_type, :from_unit_id, :to_unit_type, :to_unit_id, :occurred_at,
|
|
39
|
+
keyword_init: true) do
|
|
40
|
+
def initialize(**fields)
|
|
41
|
+
super
|
|
42
|
+
freeze
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def initialize
|
|
47
|
+
@mutex = Mutex.new
|
|
48
|
+
@counts = Hash.new(0)
|
|
49
|
+
@summaries = []
|
|
50
|
+
@exposures = []
|
|
51
|
+
@aliases = []
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# summary returns the running evaluation counts since construction or
|
|
55
|
+
# the last reset, ordered by flag, variation and reason. Closing a
|
|
56
|
+
# window (flush, close) does not touch it.
|
|
57
|
+
def summary
|
|
58
|
+
rows = @mutex.synchronize { @counts.dup }
|
|
59
|
+
rows.map { |(flag, variation, reason), n| SummaryCount.new(flag:, variation:, reason:, count: n).freeze }
|
|
60
|
+
.sort_by { |r| [r.flag, r.variation.to_s, r.reason.to_s] }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# reset forgets everything recorded.
|
|
64
|
+
def reset
|
|
65
|
+
@mutex.synchronize do
|
|
66
|
+
@counts.clear
|
|
67
|
+
@summaries.clear
|
|
68
|
+
@exposures.clear
|
|
69
|
+
@aliases.clear
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# count records one evaluation into the running total.
|
|
74
|
+
def count(flag, detail)
|
|
75
|
+
key = [flag, detail.variation, detail.reason]
|
|
76
|
+
@mutex.synchronize { @counts[key] += 1 }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# record keeps an evaluation summary the client would have sent once a
|
|
80
|
+
# window closed.
|
|
81
|
+
def record(summary)
|
|
82
|
+
@mutex.synchronize { @summaries << summary }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# evaluation_summaries returns the evaluation summaries the client
|
|
86
|
+
# would have sent since construction or the last reset, in the order
|
|
87
|
+
# they were reported.
|
|
88
|
+
def evaluation_summaries
|
|
89
|
+
@mutex.synchronize { @summaries.dup }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# record_event keeps an exposure or an alias the client would have
|
|
93
|
+
# sent, from the wire-shaped Hash Exposures builds. An offline client
|
|
94
|
+
# records it at the call itself, so a test asserts on it without
|
|
95
|
+
# flushing.
|
|
96
|
+
def record_event(event)
|
|
97
|
+
case event["type"]
|
|
98
|
+
when "exposure"
|
|
99
|
+
@mutex.synchronize do
|
|
100
|
+
@exposures << Exposure.new(event_id: event["event_id"], experiment_key: event["experiment_key"],
|
|
101
|
+
flag_key: event["flag_key"], variation_key: event["variation_key"],
|
|
102
|
+
unit_type: event["unit_type"], unit_id: event["unit_id"],
|
|
103
|
+
platform: event["platform"], app_version: event["app_version"],
|
|
104
|
+
reason: event["evaluation_reason"].to_sym, override: event["override"],
|
|
105
|
+
ruleset_version: event["ruleset_version"],
|
|
106
|
+
occurred_at: Time.parse(event["occurred_at"]))
|
|
107
|
+
end
|
|
108
|
+
when "alias"
|
|
109
|
+
@mutex.synchronize do
|
|
110
|
+
@aliases << Alias.new(event_id: event["event_id"], from_unit_type: event["from_unit_type"],
|
|
111
|
+
from_unit_id: event["from_unit_id"], to_unit_type: event["to_unit_type"],
|
|
112
|
+
to_unit_id: event["to_unit_id"], occurred_at: Time.parse(event["occurred_at"]))
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# exposures returns the exposures the client would have sent since
|
|
118
|
+
# construction or the last reset, in the order they were recorded.
|
|
119
|
+
# Repeats per experiment, unit and variation are suppressed before they
|
|
120
|
+
# reach the recorder, exactly as they are online.
|
|
121
|
+
def exposures
|
|
122
|
+
@mutex.synchronize { @exposures.dup }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# aliases returns the aliases the client would have sent since
|
|
126
|
+
# construction or the last reset, in the order they were recorded.
|
|
127
|
+
def aliases
|
|
128
|
+
@mutex.synchronize { @aliases.dup }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# The client hands the recorder what it counts and reports; a test only
|
|
132
|
+
# reads it.
|
|
133
|
+
private :count, :record, :record_event
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "event_batch"
|
|
4
|
+
|
|
5
|
+
module Flagnetic
|
|
6
|
+
# Reporter is the send-with-backoff machinery shared by Exposures and
|
|
7
|
+
# Summaries: both post batches through EventBatch under the same retry
|
|
8
|
+
# policy (429, 5xx and network errors retried with backoff bounded by
|
|
9
|
+
# the flush's own budget; 401/403 backed off to the unauthorized cap
|
|
10
|
+
# and logged once) and the same bounded wait for another flush already
|
|
11
|
+
# in progress. schedule_retry is the one place they differ (the protocol
|
|
12
|
+
# allows honouring Retry-After or backing off on its own schedule); each
|
|
13
|
+
# includer defines its own. The includer supplies @transport, @timing,
|
|
14
|
+
# @log, @backoff, @not_before, @revoked_logged, @send_mutex and a
|
|
15
|
+
# private #kind.
|
|
16
|
+
module Reporter
|
|
17
|
+
# MIN_ATTEMPT_TIME is the least budget left an attempt needs to be
|
|
18
|
+
# worth making at all, so a very short flush_interval (tests) still
|
|
19
|
+
# gets one real attempt instead of giving up before trying.
|
|
20
|
+
MIN_ATTEMPT_TIME = 0.01
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
25
|
+
|
|
26
|
+
# wait_for_send_lock waits for @send_mutex, up to deadline, so a flush
|
|
27
|
+
# bounded by its own budget never blocks on another flush already in
|
|
28
|
+
# progress for longer than that budget. Returns whether it was
|
|
29
|
+
# acquired; the caller must unlock it.
|
|
30
|
+
def wait_for_send_lock(deadline)
|
|
31
|
+
until @send_mutex.try_lock
|
|
32
|
+
return false if now >= deadline
|
|
33
|
+
|
|
34
|
+
sleep([deadline - now, 0.01].min)
|
|
35
|
+
end
|
|
36
|
+
true
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# attempt sends group, retrying it whole (same events, same event IDs)
|
|
40
|
+
# on 429, 5xx and network errors, until the platform answers it, the
|
|
41
|
+
# deadline is up or the key is refused. Each call is itself bounded by
|
|
42
|
+
# whatever is left of the deadline, so a hung platform never holds a
|
|
43
|
+
# flush past its budget.
|
|
44
|
+
def attempt(group, deadline)
|
|
45
|
+
loop do
|
|
46
|
+
remaining = deadline - now
|
|
47
|
+
if remaining <= MIN_ATTEMPT_TIME
|
|
48
|
+
result = EventBatch::Outcome.new(kind: :retry, error: "flush budget exhausted")
|
|
49
|
+
# schedule_retry's own doing: without it @not_before never
|
|
50
|
+
# advances, and the next due? is immediately true again.
|
|
51
|
+
schedule_retry(group, result)
|
|
52
|
+
return result
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
result = EventBatch.send_batch(@transport, group, [remaining, @timing.request_timeout].min)
|
|
56
|
+
case result.kind
|
|
57
|
+
when :retry then wait = schedule_retry(group, result)
|
|
58
|
+
when :unauthorized then (schedule_unauthorized(result)
|
|
59
|
+
return result)
|
|
60
|
+
else return result
|
|
61
|
+
end
|
|
62
|
+
return result if now + wait > deadline
|
|
63
|
+
|
|
64
|
+
sleep(wait)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def schedule_unauthorized(result)
|
|
69
|
+
wait = @backoff.next_delay(cap: @timing.unauthorized_cap)
|
|
70
|
+
@not_before = now + wait
|
|
71
|
+
return if @revoked_logged
|
|
72
|
+
|
|
73
|
+
@revoked_logged = true
|
|
74
|
+
log(:error, :key_revoked, status: result.status, source: "events")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def log_ingest_failed(group, result, wait)
|
|
78
|
+
log(:warn, :ingest_failed, kind: kind, events: group.size, retry_in: wait.round(3), status: result.status,
|
|
79
|
+
error: result.error)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def report_dropped(group, result)
|
|
83
|
+
log(:warn, :queue_dropped, kind: kind, cause: "client_error",
|
|
84
|
+
events: group.map { |e| e["event_id"] }, status: result.status, error: result.error)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def log(level, event, **fields)
|
|
88
|
+
@log.call(level, event, fields.compact)
|
|
89
|
+
rescue StandardError
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
private_constant :Reporter
|
|
94
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flagnetic
|
|
4
|
+
# SSEParser reads the text/event-stream format of the change stream by the
|
|
5
|
+
# WHATWG rules: CRLF, LF or CR end a line, even when split between reads;
|
|
6
|
+
# a blank line dispatches the event gathered so far; lines starting with
|
|
7
|
+
# a colon are comments; data lines join with newlines; the last event ID
|
|
8
|
+
# carries over between events. The server only ever writes `retry`, a
|
|
9
|
+
# `ruleset` event and `: hb` heartbeats, but a proxy may reframe them.
|
|
10
|
+
class SSEParser
|
|
11
|
+
# Event is one dispatched event. type defaults to "message"; id is the
|
|
12
|
+
# last event ID seen, or nil.
|
|
13
|
+
Event = Struct.new(:type, :data, :id, keyword_init: true)
|
|
14
|
+
|
|
15
|
+
# Error is a stream the parser refuses: a line longer than max_line.
|
|
16
|
+
class Error < StandardError; end
|
|
17
|
+
|
|
18
|
+
BOM = "\xEF\xBB\xBF".b.freeze
|
|
19
|
+
LINE_END = /[\r\n]/n
|
|
20
|
+
private_constant :BOM, :LINE_END
|
|
21
|
+
|
|
22
|
+
def initialize(max_line: 1 << 20)
|
|
23
|
+
@max_line = max_line
|
|
24
|
+
@buffer = +"".b
|
|
25
|
+
@skip_lf = false
|
|
26
|
+
@started = false
|
|
27
|
+
@type = +""
|
|
28
|
+
@data = +""
|
|
29
|
+
@id = nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# feed parses the next bytes of the stream, yielding [:event, Event],
|
|
33
|
+
# [:retry, milliseconds] and [:comment, text] as they complete.
|
|
34
|
+
def feed(chunk, &)
|
|
35
|
+
bytes = chunk.b
|
|
36
|
+
if @skip_lf && !bytes.empty?
|
|
37
|
+
bytes = bytes.byteslice(1..) if bytes.getbyte(0) == 10
|
|
38
|
+
@skip_lf = false
|
|
39
|
+
end
|
|
40
|
+
@buffer << bytes
|
|
41
|
+
strip_bom
|
|
42
|
+
while (i = @buffer.index(LINE_END))
|
|
43
|
+
line = @buffer.byteslice(0, i)
|
|
44
|
+
@buffer = @buffer.byteslice((i + line_end_length(i))..)
|
|
45
|
+
line(line.force_encoding(Encoding::UTF_8).scrub, &)
|
|
46
|
+
end
|
|
47
|
+
raise Error, "an event-stream line exceeds #{@max_line} bytes" if @buffer.bytesize > @max_line
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def strip_bom
|
|
53
|
+
return if @started || @buffer.bytesize < BOM.bytesize
|
|
54
|
+
|
|
55
|
+
@started = true
|
|
56
|
+
@buffer = @buffer.byteslice(BOM.bytesize..) if @buffer.start_with?(BOM)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# line_end_length is 2 for CRLF, else 1. A CR ending the buffer may be
|
|
60
|
+
# the first half of a CRLF: the LF is skipped when it arrives.
|
|
61
|
+
def line_end_length(index)
|
|
62
|
+
return 1 unless @buffer.getbyte(index) == 13
|
|
63
|
+
return 2 if @buffer.getbyte(index + 1) == 10
|
|
64
|
+
|
|
65
|
+
@skip_lf = index + 1 == @buffer.bytesize
|
|
66
|
+
1
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def line(line)
|
|
70
|
+
return dispatch { |e| yield :event, e } if line.empty?
|
|
71
|
+
return yield(:comment, line[1..]) if line.start_with?(":")
|
|
72
|
+
|
|
73
|
+
field, value = line.split(":", 2)
|
|
74
|
+
value = value.to_s.delete_prefix(" ")
|
|
75
|
+
case field
|
|
76
|
+
when "event" then @type = value
|
|
77
|
+
when "data" then @data << value << "\n"
|
|
78
|
+
when "id" then @id = value unless value.include?("\u0000")
|
|
79
|
+
when "retry" then yield :retry, value.to_i if value.match?(/\A\d+\z/)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def dispatch
|
|
84
|
+
data = @data
|
|
85
|
+
type = @type
|
|
86
|
+
@data = +""
|
|
87
|
+
@type = +""
|
|
88
|
+
return if data.empty?
|
|
89
|
+
|
|
90
|
+
yield Event.new(type: type.empty? ? "message" : type, data: data.chomp, id: @id)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
private_constant :SSEParser
|
|
94
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flagnetic
|
|
4
|
+
# FileStore keeps the last good ruleset in one file: the ruleset document
|
|
5
|
+
# exactly as the endpoint served it, so the file also works as an offline
|
|
6
|
+
# document. Its modification time is when the ruleset was received.
|
|
7
|
+
# Writes go to a unique temporary file renamed over the target, so
|
|
8
|
+
# processes sharing the path (Puma workers) never read a torn file.
|
|
9
|
+
class FileStore
|
|
10
|
+
def initialize(path)
|
|
11
|
+
@path = path.to_s
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# load returns [json, received_at], or nil when there is no file.
|
|
15
|
+
def load
|
|
16
|
+
json = File.binread(@path)
|
|
17
|
+
[json, File.mtime(@path)]
|
|
18
|
+
rescue Errno::ENOENT
|
|
19
|
+
nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def save(json)
|
|
23
|
+
tmp = "#{@path}.#{Process.pid}.#{Thread.current.object_id}.tmp"
|
|
24
|
+
# The server ruleset holds private segments: readable by the owner only.
|
|
25
|
+
File.open(tmp, File::WRONLY | File::CREAT | File::TRUNC | File::BINARY, 0o600) { |f| f.write(json) }
|
|
26
|
+
File.rename(tmp, @path)
|
|
27
|
+
ensure
|
|
28
|
+
File.delete(tmp) if tmp && File.exist?(tmp)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def to_s = @path
|
|
32
|
+
end
|
|
33
|
+
private_constant :FileStore
|
|
34
|
+
end
|