flipper_trail 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,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlipperTrail
4
+ # An immutable record of a single feature-flag change: which feature and gate,
5
+ # the operation, the before/after state, the {Actor}, and when it happened.
6
+ #
7
+ # @!attribute [rw] feature_name
8
+ # @return [String] the feature key
9
+ # @!attribute [rw] operation
10
+ # @return [Symbol] the write performed (`:add`, `:remove`, `:clear`,
11
+ # `:enable`, `:disable`)
12
+ # @!attribute [rw] gate_name
13
+ # @return [String, nil] the gate affected, if any
14
+ # @!attribute [rw] before
15
+ # @return [Object] the gate state before the change
16
+ # @!attribute [rw] after
17
+ # @return [Object] the gate state after the change
18
+ # @!attribute [rw] actor
19
+ # @return [Actor, nil] who made the change
20
+ # @!attribute [rw] created_at
21
+ # @return [Time] when the change was recorded
22
+ Entry = Struct.new(
23
+ :feature_name, :operation, :gate_name, :before, :after, :actor, :created_at,
24
+ keyword_init: true
25
+ ) do
26
+ # @return [Boolean] whether the change altered the feature's state
27
+ def changed?
28
+ before != after
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+ require 'rails/generators/migration'
5
+ require 'active_record'
6
+
7
+ module FlipperTrail
8
+ module Generators
9
+ class InstallGenerator < ::Rails::Generators::Base
10
+ include ::Rails::Generators::Migration
11
+
12
+ source_root File.expand_path('templates', __dir__)
13
+
14
+ def self.next_migration_number(dirname)
15
+ next_number = current_migration_number(dirname) + 1
16
+ ::ActiveRecord::Migration.next_migration_number(next_number)
17
+ end
18
+
19
+ def create_initializer
20
+ template 'initializer.rb.tt', 'config/initializers/flipper_trail.rb'
21
+ end
22
+
23
+ # NOTE: must NOT be named `create_migration` — Rails::Generators::Migration already defines an
24
+ # instance method `create_migration(destination, data, config)` that migration_template calls internally;
25
+ # a zero-arg Thor command of the same name shadows it and raises ArgumentError (given 3, expected 0).
26
+ def create_migration_file
27
+ migration_template 'migration.rb.tt', 'db/migrate/create_flipper_trail_entries.rb'
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ FlipperTrail.configure do |config|
4
+ # config.storage = :active_record # optional — inferred from the Flipper adapter you wrap; set to override
5
+ # Mongoid users: indexes are declared on the audit document but not auto-built. Create them once:
6
+ # bin/rails runner 'require "flipper_trail/storage/mongoid"; FlipperTrail::Storage::Mongoid::Entry.create_indexes'
7
+ # config.actor_resolver = -> { Current.user } # MUST be zero-arity (runs off-request too)
8
+ # config.system_actor = { type: "system", id: nil, label: "system" }
9
+ # config.ignored_features = []
10
+ # config.raise_on_audit_error = false # true = a failing audit write aborts the flag write
11
+ # config.on_error = ->(error, entry) { Rails.logger.error("[flipper_trail] #{error.message}") }
12
+ end
13
+
14
+ # Wrap the FlipperTrail decorator around your real Flipper adapter, e.g.:
15
+ #
16
+ # require "flipper/adapters/active_record"
17
+ #
18
+ # Flipper.configure do |config|
19
+ # config.adapter { FlipperTrail.wrap(Flipper::Adapters::ActiveRecord.new) }
20
+ # end
21
+ #
22
+ # Capture the acting user per request (e.g. in ApplicationController):
23
+ #
24
+ # before_action { FlipperTrail::Current.actor = current_user }
25
+ #
26
+ # In front of a mounted Flipper::UI, insert the middleware so UI toggles are attributed:
27
+ #
28
+ # config.middleware.use FlipperTrail::Middleware, resolver: ->(env) { ... }
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateFlipperTrailEntries < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
4
+ def change
5
+ create_table :flipper_trail_entries do |t|
6
+ t.string :feature_name, null: false
7
+ t.string :operation, null: false
8
+ t.string :gate_name
9
+ t.json :before_state
10
+ t.json :after_state
11
+ t.string :actor_type
12
+ t.string :actor_id
13
+ t.string :actor_label
14
+ t.datetime :created_at, null: false
15
+ end
16
+
17
+ add_index :flipper_trail_entries, %i[feature_name created_at]
18
+ add_index :flipper_trail_entries, :created_at
19
+ add_index :flipper_trail_entries, %i[actor_id created_at]
20
+ end
21
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'flipper_trail/current'
4
+
5
+ module FlipperTrail
6
+ # Rack middleware that resolves the current actor for the duration of a request
7
+ # and stores it on {Current}, so flag changes during that request are attributed
8
+ # to it.
9
+ #
10
+ # @example Insert into a Rails app
11
+ # config.middleware.use FlipperTrail::Middleware, resolver: ->(env) { env["warden"].user }
12
+ class Middleware
13
+ # @param app [#call] the next Rack app in the stack
14
+ # @param resolver [#call, nil] resolves the request's actor; may take the
15
+ # Rack env (called with it) or take no arguments (called zero-arity). When
16
+ # nil, falls back to the configured `actor_resolver`.
17
+ def initialize(app, resolver: nil)
18
+ @app = app
19
+ @resolver = resolver
20
+ end
21
+
22
+ # Sets the resolved actor on {Current} for the wrapped request.
23
+ #
24
+ # @param env [Hash] the Rack environment
25
+ # @return [Array(Integer, Hash, #each)] the Rack response
26
+ def call(env)
27
+ Current.set(actor: resolve(env)) { @app.call(env) }
28
+ end
29
+
30
+ private
31
+
32
+ # The injected `resolver:` MAY take env (it runs inside a request). The shared
33
+ # config.actor_resolver is ALWAYS called zero-arity — it's the same fallback the Recorder
34
+ # uses off-request (console/job), so keeping one contract avoids an arity-mismatch crash there.
35
+ def resolve(env)
36
+ if @resolver
37
+ @resolver.arity.zero? ? @resolver.call : @resolver.call(env)
38
+ else
39
+ FlipperTrail.configuration.actor_resolver&.call
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/railtie'
4
+
5
+ module FlipperTrail
6
+ class Railtie < ::Rails::Railtie
7
+ generators do
8
+ require 'flipper_trail/generators/flipper_trail/install_generator'
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'flipper_trail/actor'
4
+ require 'flipper_trail/entry'
5
+
6
+ module FlipperTrail
7
+ # Builds an {Entry} from a flag change and persists it through the configured
8
+ # storage backend. No-op changes (`before == after`) and ignored features are
9
+ # dropped.
10
+ class Recorder
11
+ # Records a single flag change as an audit entry.
12
+ #
13
+ # @param feature_name [String, Symbol] the feature key
14
+ # @param operation [Symbol] the write performed (`:add`, `:remove`,
15
+ # `:clear`, `:enable`, `:disable`)
16
+ # @param gate_name [String, Symbol, nil] the gate affected, if any
17
+ # @param before [Object] the feature's gate state before the change
18
+ # @param after [Object] the feature's gate state after the change
19
+ # @return [Entry, nil] the persisted entry, or nil when the change was a
20
+ # no-op or the feature is ignored
21
+ def record(feature_name:, operation:, gate_name:, before:, after:)
22
+ feature_name = feature_name.to_s
23
+ return if config.ignored_features.map(&:to_s).include?(feature_name)
24
+ return if before == after
25
+
26
+ entry = Entry.new(
27
+ feature_name: feature_name,
28
+ operation: operation,
29
+ gate_name: gate_name&.to_s,
30
+ before: before,
31
+ after: after,
32
+ actor: current_actor,
33
+ created_at: Time.now
34
+ ).freeze
35
+ persist(entry)
36
+ entry
37
+ end
38
+
39
+ # Resolves the actor to attribute a change to, trying the per-request
40
+ # {Current} actor, then the configured `actor_resolver`, then the system actor.
41
+ #
42
+ # @return [Actor, nil] the wrapped actor, or nil when none resolves
43
+ def current_actor
44
+ raw = Current.actor || config.actor_resolver&.call || config.system_actor
45
+ Actor.wrap(raw)
46
+ end
47
+
48
+ private
49
+
50
+ # Audit-write failures must never break the flag write that already succeeded.
51
+ # Swallow + route to on_error by default; opt into fail-closed via raise_on_audit_error.
52
+ def persist(entry)
53
+ config.storage_backend.record(entry)
54
+ rescue StandardError => e
55
+ raise if config.raise_on_audit_error
56
+
57
+ handler = config.on_error
58
+ handler ? handler.call(e, entry) : warn("[flipper_trail] audit write failed: #{e.class}: #{e.message}")
59
+ end
60
+
61
+ def config
62
+ FlipperTrail.configuration
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_record'
4
+ require 'flipper_trail/entry'
5
+ require 'flipper_trail/actor'
6
+
7
+ module FlipperTrail
8
+ module Storage
9
+ class ActiveRecord
10
+ class Entry < ::ActiveRecord::Base
11
+ self.table_name = 'flipper_trail_entries'
12
+ end
13
+
14
+ def record(entry)
15
+ Entry.create!(
16
+ feature_name: entry.feature_name,
17
+ operation: entry.operation.to_s,
18
+ gate_name: entry.gate_name,
19
+ before_state: entry.before,
20
+ after_state: entry.after,
21
+ actor_type: entry.actor&.type,
22
+ actor_id: entry.actor&.id,
23
+ actor_label: entry.actor&.label,
24
+ created_at: entry.created_at
25
+ )
26
+ end
27
+
28
+ def query(feature: nil, actor_id: nil, since: nil, until_time: nil, limit: 100)
29
+ scope = Entry.all
30
+ scope = scope.where(feature_name: feature.to_s) if feature
31
+ scope = scope.where(actor_id: actor_id.to_s) if actor_id
32
+ scope = scope.where(arel_table[:created_at].gteq(since)) if since
33
+ scope = scope.where(arel_table[:created_at].lteq(until_time)) if until_time
34
+ # id DESC is a deterministic tiebreaker so bursts with equal created_at stay newest-first.
35
+ scope.order(created_at: :desc, id: :desc).limit(limit).map { |row| to_entry(row) }
36
+ end
37
+
38
+ private
39
+
40
+ def arel_table
41
+ Entry.arel_table
42
+ end
43
+
44
+ def to_entry(row)
45
+ FlipperTrail::Entry.new(
46
+ feature_name: row.feature_name,
47
+ operation: row.operation.to_sym,
48
+ gate_name: row.gate_name,
49
+ before: row.before_state,
50
+ after: row.after_state,
51
+ actor: FlipperTrail::Actor.new(type: row.actor_type, id: row.actor_id, label: row.actor_label),
52
+ created_at: row.created_at
53
+ ).freeze
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mongoid'
4
+ require 'flipper_trail/entry'
5
+ require 'flipper_trail/actor'
6
+
7
+ module FlipperTrail
8
+ module Storage
9
+ class Mongoid
10
+ class Entry
11
+ include ::Mongoid::Document
12
+
13
+ store_in collection: 'flipper_trail_entries'
14
+
15
+ field :feature_name, type: String
16
+ field :operation, type: String
17
+ field :gate_name, type: String
18
+ field :before_state, type: Hash
19
+ field :after_state, type: Hash
20
+ field :actor_type, type: String
21
+ field :actor_id, type: String
22
+ field :actor_label, type: String
23
+ field :created_at, type: Time
24
+
25
+ index({ feature_name: 1, created_at: -1 })
26
+ index({ created_at: -1 })
27
+ index({ actor_id: 1, created_at: -1 })
28
+ end
29
+
30
+ def record(entry)
31
+ Entry.create!(
32
+ feature_name: entry.feature_name,
33
+ operation: entry.operation.to_s,
34
+ gate_name: entry.gate_name,
35
+ before_state: entry.before,
36
+ after_state: entry.after,
37
+ actor_type: entry.actor&.type,
38
+ actor_id: entry.actor&.id,
39
+ actor_label: entry.actor&.label,
40
+ created_at: entry.created_at
41
+ )
42
+ end
43
+
44
+ def query(feature: nil, actor_id: nil, since: nil, until_time: nil, limit: 100)
45
+ scope = Entry.all
46
+ scope = scope.where(feature_name: feature.to_s) if feature
47
+ scope = scope.where(actor_id: actor_id.to_s) if actor_id
48
+ scope = scope.gte(created_at: since) if since
49
+ scope = scope.lte(created_at: until_time) if until_time
50
+ # _id DESC is a deterministic tiebreaker (BSON ObjectId is creation-ordered)
51
+ # so equal-timestamp bursts stay newest-first.
52
+ scope.order_by(created_at: :desc, _id: :desc).limit(limit).map { |doc| to_entry(doc) }
53
+ end
54
+
55
+ private
56
+
57
+ def to_entry(doc)
58
+ FlipperTrail::Entry.new(
59
+ feature_name: doc.feature_name,
60
+ operation: doc.operation.to_sym,
61
+ gate_name: doc.gate_name,
62
+ before: doc.before_state,
63
+ after: doc.after_state,
64
+ actor: FlipperTrail::Actor.new(type: doc.actor_type, id: doc.actor_id, label: doc.actor_label),
65
+ created_at: doc.created_at
66
+ ).freeze
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlipperTrail
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'flipper_trail/version'
4
+ require 'flipper_trail/configuration'
5
+ require 'flipper_trail/current'
6
+ require 'flipper_trail/actor'
7
+ require 'flipper_trail/entry'
8
+ require 'flipper_trail/recorder'
9
+ require 'flipper_trail/adapter'
10
+ require 'flipper_trail/middleware'
11
+
12
+ # Records an append-only audit trail of Flipper feature-flag changes — who
13
+ # changed what, when, and the before/after state — via an adapter decorator.
14
+ # {configure} it once, then query it with {history}.
15
+ module FlipperTrail
16
+ class << self
17
+ # The memoized global configuration.
18
+ #
19
+ # @return [Configuration] the shared configuration instance
20
+ def configuration
21
+ @configuration ||= Configuration.new
22
+ end
23
+
24
+ # The memoized global recorder that builds and persists audit entries.
25
+ #
26
+ # @return [Recorder] the shared recorder instance
27
+ def recorder
28
+ @recorder ||= Recorder.new
29
+ end
30
+
31
+ # Wrap a Flipper adapter so every write through it is audited.
32
+ #
33
+ # Shorthand for FlipperTrail::Adapter.new(adapter); pass the result to Flipper's config.adapter.
34
+ #
35
+ # @param adapter [Object] the real Flipper adapter to decorate
36
+ # @param recorder [FlipperTrail::Recorder, nil] optional recorder override (mainly for tests)
37
+ # @return [FlipperTrail::Adapter] the audit decorator to give to +config.adapter+
38
+ # @example
39
+ # Flipper.configure { |c| c.adapter { FlipperTrail.wrap(Flipper::Adapters::ActiveRecord.new) } }
40
+ def wrap(adapter, recorder: nil)
41
+ Adapter.new(adapter, recorder: recorder)
42
+ end
43
+
44
+ # Configures the gem by yielding the global {Configuration}.
45
+ #
46
+ # @yieldparam config [Configuration] the configuration to mutate
47
+ # @return [void]
48
+ #
49
+ # @example
50
+ # FlipperTrail.configure do |config|
51
+ # config.storage = :active_record
52
+ # config.actor_resolver = -> { Current.user }
53
+ # end
54
+ def configure
55
+ yield configuration
56
+ end
57
+
58
+ # Queries the audit trail through the configured storage backend.
59
+ #
60
+ # @param filters [Hash] optional filters
61
+ # @option filters [String] :feature only entries for this feature key
62
+ # @option filters [String] :actor_id only entries by this actor id
63
+ # @option filters [Time] :since only entries created at or after this time
64
+ # @option filters [Time] :until only entries created at or before this time
65
+ # @option filters [Integer] :limit maximum number of entries (default 100)
66
+ # @return [Array<Entry>] the matching audit entries, newest first
67
+ #
68
+ # @example
69
+ # FlipperTrail.history(feature: "search", limit: 10)
70
+ def history(**filters)
71
+ configuration.storage_backend.query(
72
+ feature: filters[:feature],
73
+ actor_id: filters[:actor_id],
74
+ since: filters[:since],
75
+ until_time: filters[:until],
76
+ limit: filters[:limit] || 100
77
+ )
78
+ end
79
+
80
+ # The actor the recorder would attribute a change to right now.
81
+ #
82
+ # @return [Actor, nil] the resolved actor, or nil when none is set
83
+ def current_actor
84
+ recorder.current_actor
85
+ end
86
+
87
+ # Resets the memoized configuration, recorder, and per-request actor.
88
+ #
89
+ # @return [void]
90
+ def reset!
91
+ @configuration = nil
92
+ @recorder = nil
93
+ Current.reset if defined?(Current)
94
+ end
95
+ end
96
+ end
97
+
98
+ require 'flipper_trail/railtie' if defined?(Rails::Railtie)
@@ -0,0 +1,68 @@
1
+ # RBS type signatures for the public API of FlipperTrail.
2
+ #
3
+ # Only the public surface is described here. Everything that touches an external
4
+ # library (flipper, activerecord, mongoid, rack env, ActiveSupport::CurrentAttributes)
5
+ # is intentionally left as `untyped` — we do not chase typing the dependency surface.
6
+
7
+ module FlipperTrail
8
+ VERSION: String
9
+
10
+ def self.configure: () { (Configuration) -> void } -> void
11
+ def self.configuration: () -> Configuration
12
+ def self.history: (**untyped) -> Array[Entry]
13
+ def self.current_actor: () -> Actor
14
+ def self.recorder: () -> Recorder
15
+ def self.wrap: (untyped adapter, ?recorder: untyped) -> Adapter
16
+ def self.reset!: () -> void
17
+
18
+ class Configuration
19
+ attr_accessor storage: untyped
20
+ attr_accessor actor_resolver: untyped
21
+ attr_accessor system_actor: untyped
22
+ attr_accessor ignored_features: untyped
23
+ attr_accessor on_error: untyped
24
+ attr_accessor raise_on_audit_error: bool
25
+
26
+ def initialize: () -> void
27
+ def storage_backend: () -> untyped
28
+ end
29
+
30
+ class Entry
31
+ attr_reader feature_name: untyped
32
+ attr_reader operation: untyped
33
+ attr_reader gate_name: untyped
34
+ attr_reader before: untyped
35
+ attr_reader after: untyped
36
+ attr_reader actor: untyped
37
+ attr_reader created_at: untyped
38
+
39
+ def changed?: () -> bool
40
+ end
41
+
42
+ class Actor
43
+ attr_reader type: untyped
44
+ attr_reader id: String?
45
+ attr_reader label: untyped
46
+
47
+ def initialize: (type: untyped, id: untyped, label: untyped) -> void
48
+ def self.wrap: (untyped) -> Actor?
49
+ def to_h: () -> Hash[Symbol, untyped]
50
+ end
51
+
52
+ class Current
53
+ end
54
+
55
+ class Adapter
56
+ def initialize: (untyped, ?recorder: untyped) -> void
57
+ end
58
+
59
+ class Middleware
60
+ def initialize: (untyped, ?resolver: untyped) -> void
61
+ def call: (untyped) -> untyped
62
+ end
63
+
64
+ class Recorder
65
+ def record: (**untyped) -> untyped
66
+ def current_actor: () -> Actor
67
+ end
68
+ end