code_sunset 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.
Files changed (71) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +242 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/images/code_sunset/.keep +0 -0
  6. data/app/assets/images/code_sunset/logo-dark.png +0 -0
  7. data/app/assets/images/code_sunset/logo-light.png +0 -0
  8. data/app/assets/images/code_sunset/topography.svg +1 -0
  9. data/app/assets/javascripts/code_sunset/application.js +179 -0
  10. data/app/assets/javascripts/code_sunset/vendor/chart.umd.min.js +14 -0
  11. data/app/assets/stylesheets/code_sunset/application.css +1075 -0
  12. data/app/controllers/code_sunset/application_controller.rb +50 -0
  13. data/app/controllers/code_sunset/dashboard_controller.rb +12 -0
  14. data/app/controllers/code_sunset/features_controller.rb +62 -0
  15. data/app/controllers/code_sunset/removal_candidates_controller.rb +9 -0
  16. data/app/controllers/concerns/.keep +0 -0
  17. data/app/helpers/code_sunset/application_helper.rb +244 -0
  18. data/app/jobs/code_sunset/aggregate_daily_rollups_job.rb +48 -0
  19. data/app/jobs/code_sunset/application_job.rb +4 -0
  20. data/app/jobs/code_sunset/cleanup_events_job.rb +10 -0
  21. data/app/jobs/code_sunset/evaluate_alerts_job.rb +15 -0
  22. data/app/jobs/code_sunset/flush_buffered_events_job.rb +9 -0
  23. data/app/jobs/code_sunset/persist_event_job.rb +9 -0
  24. data/app/mailers/code_sunset/application_mailer.rb +6 -0
  25. data/app/models/code_sunset/alert_delivery.rb +14 -0
  26. data/app/models/code_sunset/application_record.rb +5 -0
  27. data/app/models/code_sunset/daily_rollup.rb +31 -0
  28. data/app/models/code_sunset/event.rb +190 -0
  29. data/app/models/code_sunset/feature.rb +33 -0
  30. data/app/models/concerns/.keep +0 -0
  31. data/app/views/code_sunset/dashboard/index.html.erb +123 -0
  32. data/app/views/code_sunset/features/index.html.erb +88 -0
  33. data/app/views/code_sunset/features/show.html.erb +266 -0
  34. data/app/views/code_sunset/removal_candidates/index.html.erb +118 -0
  35. data/app/views/code_sunset/shared/_filter_bar.html.erb +81 -0
  36. data/app/views/layouts/code_sunset/application.html.erb +57 -0
  37. data/bin/rails +26 -0
  38. data/code_sunset.gemspec +35 -0
  39. data/config/routes.rb +5 -0
  40. data/db/migrate/20260330000000_create_code_sunset_tables.rb +62 -0
  41. data/db/migrate/20260331000000_harden_code_sunset_reliability.rb +39 -0
  42. data/lib/code_sunset/alert_dispatcher.rb +103 -0
  43. data/lib/code_sunset/analytics_filters.rb +59 -0
  44. data/lib/code_sunset/configuration.rb +104 -0
  45. data/lib/code_sunset/context.rb +49 -0
  46. data/lib/code_sunset/controller_context.rb +23 -0
  47. data/lib/code_sunset/engine.rb +24 -0
  48. data/lib/code_sunset/event_buffer.rb +28 -0
  49. data/lib/code_sunset/event_ingestor.rb +57 -0
  50. data/lib/code_sunset/event_payload.rb +79 -0
  51. data/lib/code_sunset/feature_query.rb +69 -0
  52. data/lib/code_sunset/feature_snapshot.rb +14 -0
  53. data/lib/code_sunset/feature_usage_series.rb +59 -0
  54. data/lib/code_sunset/identity_hash.rb +9 -0
  55. data/lib/code_sunset/instrumentation.rb +17 -0
  56. data/lib/code_sunset/job_context.rb +17 -0
  57. data/lib/code_sunset/removal_candidate_query.rb +17 -0
  58. data/lib/code_sunset/removal_prompt_builder.rb +331 -0
  59. data/lib/code_sunset/score_calculator.rb +83 -0
  60. data/lib/code_sunset/status_engine.rb +38 -0
  61. data/lib/code_sunset/subscriber.rb +24 -0
  62. data/lib/code_sunset/version.rb +3 -0
  63. data/lib/code_sunset.rb +109 -0
  64. data/lib/generators/code_sunset/eject_ui/eject_ui_generator.rb +33 -0
  65. data/lib/generators/code_sunset/initializer/initializer_generator.rb +13 -0
  66. data/lib/generators/code_sunset/initializer/templates/code_sunset.rb +34 -0
  67. data/lib/generators/code_sunset/install/install_generator.rb +23 -0
  68. data/lib/generators/code_sunset/migration/migration_generator.rb +21 -0
  69. data/lib/generators/code_sunset/migration/templates/create_code_sunset_tables.rb +75 -0
  70. data/lib/tasks/code_sunset_tasks.rake +21 -0
  71. metadata +185 -0
@@ -0,0 +1,39 @@
1
+ class HardenCodeSunsetReliability < ActiveRecord::Migration[7.1]
2
+ def change
3
+ add_column :code_sunset_daily_rollups, :app_env, :string
4
+ reversible do |dir|
5
+ dir.up do
6
+ execute <<~SQL
7
+ UPDATE code_sunset_daily_rollups rollups
8
+ SET app_env = matched.app_env
9
+ FROM (
10
+ SELECT feature_key, DATE(occurred_at) AS day, MAX(app_env) AS app_env
11
+ FROM code_sunset_events
12
+ GROUP BY feature_key, DATE(occurred_at)
13
+ ) matched
14
+ WHERE rollups.feature_key = matched.feature_key
15
+ AND rollups.day = matched.day
16
+ SQL
17
+
18
+ execute "UPDATE code_sunset_daily_rollups SET app_env = 'unknown' WHERE app_env IS NULL"
19
+ end
20
+ end
21
+
22
+ change_column_null :code_sunset_daily_rollups, :app_env, false
23
+
24
+ remove_index :code_sunset_daily_rollups, column: [:feature_key, :day]
25
+ add_index :code_sunset_daily_rollups, [:feature_key, :app_env, :day], unique: true, name: "index_code_sunset_rollups_on_feature_env_day"
26
+
27
+ create_table :code_sunset_alert_deliveries do |t|
28
+ t.string :feature_key, null: false
29
+ t.string :status, null: false
30
+ t.string :alert_kind, null: false
31
+ t.datetime :delivered_at, null: false
32
+ t.jsonb :payload, null: false, default: {}
33
+ t.timestamps
34
+ end
35
+
36
+ add_index :code_sunset_alert_deliveries, [:feature_key, :status, :alert_kind, :delivered_at], name: "index_code_sunset_alerts_on_feature_status_kind_time"
37
+ add_index :code_sunset_alert_deliveries, :delivered_at
38
+ end
39
+ end
@@ -0,0 +1,103 @@
1
+ module CodeSunset
2
+ class AlertDispatcher
3
+ ALERT_KIND = "deprecated_usage".freeze
4
+
5
+ def initialize(feature_key)
6
+ @feature = CodeSunset::Feature.find_by(key: feature_key)
7
+ end
8
+
9
+ def dispatch_if_due
10
+ return unless feature
11
+
12
+ snapshot = CodeSunset::FeatureQuery.new({}, scope: CodeSunset::Feature.where(id: feature.id)).call.first
13
+ return unless snapshot
14
+ return unless alertable?(snapshot)
15
+ return if recently_delivered?(snapshot)
16
+
17
+ payload = build_payload(snapshot)
18
+ notify_logger(payload)
19
+ notify_webhook(payload)
20
+ notify_slack(payload)
21
+ record_delivery(snapshot, payload)
22
+ payload
23
+ end
24
+
25
+ private
26
+
27
+ attr_reader :feature
28
+
29
+ def alertable?(snapshot)
30
+ snapshot.status != "active" && snapshot.hits_7d.to_i.positive?
31
+ end
32
+
33
+ def build_payload(snapshot)
34
+ sample_event = feature.events.recent_first.first
35
+
36
+ {
37
+ feature_key: feature.key,
38
+ status: snapshot.status,
39
+ last_seen_at: snapshot.last_seen_at,
40
+ hits_7d: snapshot.hits_7d,
41
+ hits_30d: snapshot.hits_30d,
42
+ unique_orgs_count: snapshot.unique_orgs_count,
43
+ unique_users_count: snapshot.unique_users_count,
44
+ example_org: sample_event&.display_org_identifier,
45
+ example_user: sample_event&.display_user_identifier
46
+ }
47
+ end
48
+
49
+ def recently_delivered?(snapshot)
50
+ CodeSunset::AlertDelivery.recent_for(
51
+ feature_key: feature.key,
52
+ status: snapshot.status,
53
+ alert_kind: ALERT_KIND,
54
+ since: CodeSunset.configuration.alert_cooldown.ago
55
+ ).exists?
56
+ end
57
+
58
+ def record_delivery(snapshot, payload)
59
+ CodeSunset::AlertDelivery.create!(
60
+ feature_key: feature.key,
61
+ status: snapshot.status,
62
+ alert_kind: ALERT_KIND,
63
+ delivered_at: Time.current,
64
+ payload: payload
65
+ )
66
+ end
67
+
68
+ def notify_logger(payload)
69
+ CodeSunset.logger.warn("[code_sunset] alert #{payload.to_json}")
70
+ end
71
+
72
+ def notify_webhook(payload)
73
+ proc_hook = CodeSunset.configuration.alert_webhook_proc
74
+ if proc_hook.respond_to?(:call)
75
+ CodeSunset.safe_execute("alert webhook proc") { proc_hook.call(payload) }
76
+ elsif CodeSunset.configuration.alert_webhook_url.present?
77
+ post_json(CodeSunset.configuration.alert_webhook_url, payload)
78
+ end
79
+ end
80
+
81
+ def notify_slack(payload)
82
+ return if CodeSunset.configuration.slack_webhook_url.blank?
83
+
84
+ message = {
85
+ text: "Feature #{payload[:feature_key]} is #{payload[:status]} and still saw #{payload[:hits_7d]} hits in the last 7 days across #{payload[:unique_orgs_count]} orgs."
86
+ }
87
+ post_json(CodeSunset.configuration.slack_webhook_url, message)
88
+ end
89
+
90
+ def post_json(url, payload)
91
+ CodeSunset.safe_execute("alert post #{url}") do
92
+ uri = URI.parse(url)
93
+ request = Net::HTTP::Post.new(uri.request_uri)
94
+ request["Content-Type"] = "application/json"
95
+ request.body = payload.to_json
96
+
97
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
98
+ http.request(request)
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,59 @@
1
+ module CodeSunset
2
+ class AnalyticsFilters
3
+ RAW_DIMENSION_KEYS = %i[org_id user_id plan paid_only exclude_internal].freeze
4
+
5
+ def initialize(filters = {})
6
+ @filters = filters.to_h.symbolize_keys
7
+ end
8
+
9
+ def raw_dimension_filters?
10
+ raw_dimension_keys.any? { |key| filters[key].present? }
11
+ end
12
+
13
+ def raw_dimension_keys
14
+ (RAW_DIMENSION_KEYS + CodeSunset.configuration.custom_filters.keys).select { |key| filters[key].present? }
15
+ end
16
+
17
+ def raw_filters
18
+ filters.slice(:app_env, :plan, :org_id, :user_id, :paid_only, :exclude_internal, :from, :to, *CodeSunset.configuration.custom_filters.keys)
19
+ end
20
+
21
+ def recent_raw_filters
22
+ filters.slice(:app_env)
23
+ end
24
+
25
+ def rollup_filters
26
+ filters.slice(:app_env, :from, :to)
27
+ end
28
+
29
+ def retain_range(range)
30
+ [range.begin, raw_retention_start].max..range.end
31
+ end
32
+
33
+ def raw_retained_range
34
+ raw_retention_start..Time.current.end_of_day
35
+ end
36
+
37
+ def raw_filter_notice
38
+ return unless raw_dimension_filters?
39
+
40
+ keys = raw_dimension_keys.map { |key| filter_label_for(key) }.join(", ")
41
+ "Filters for #{keys} use retained raw events only. Historical results are limited to the last #{CodeSunset.configuration.event_retention_days} days."
42
+ end
43
+
44
+ private
45
+
46
+ attr_reader :filters
47
+
48
+ def raw_retention_start
49
+ CodeSunset.configuration.event_retention_days.days.ago.beginning_of_day
50
+ end
51
+
52
+ def filter_label_for(key)
53
+ configured = CodeSunset.configuration.custom_filters[key.to_sym]
54
+ return configured[:label] if configured
55
+
56
+ key.to_s.tr("_", " ")
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,104 @@
1
+ module CodeSunset
2
+ class Configuration
3
+ attr_accessor :enabled,
4
+ :environment,
5
+ :async,
6
+ :sample_rate,
7
+ :app_version,
8
+ :identity_fields,
9
+ :hash_identity,
10
+ :dashboard_authorizer,
11
+ :org_resolver,
12
+ :user_resolver,
13
+ :plan_resolver,
14
+ :internal_org_resolver,
15
+ :paid_org_resolver,
16
+ :alert_webhook_url,
17
+ :alert_webhook_proc,
18
+ :slack_webhook_url,
19
+ :event_retention_days,
20
+ :enqueue_failure_policy,
21
+ :enqueue_buffer_size,
22
+ :alert_cooldown
23
+
24
+ def initialize
25
+ @enabled = true
26
+ @environment = defined?(Rails) ? Rails.env : ENV.fetch("RAILS_ENV", ENV.fetch("RACK_ENV", "development"))
27
+ @async = true
28
+ @sample_rate = 1.0
29
+ @app_version = ENV["CODE_SUNSET_APP_VERSION"] || ENV["APP_VERSION"] || ENV["GIT_SHA"]
30
+ @identity_fields = %i[user_id org_id]
31
+ @hash_identity = false
32
+ @dashboard_authorizer = nil
33
+ @org_resolver = nil
34
+ @user_resolver = nil
35
+ @plan_resolver = nil
36
+ @internal_org_resolver = nil
37
+ @paid_org_resolver = nil
38
+ @alert_webhook_url = nil
39
+ @alert_webhook_proc = nil
40
+ @slack_webhook_url = nil
41
+ @event_retention_days = 90
42
+ @enqueue_failure_policy = :drop
43
+ @enqueue_buffer_size = 1000
44
+ @alert_cooldown = 24.hours
45
+ @custom_filters = {}
46
+ end
47
+
48
+ def sample_rate=(value)
49
+ numeric = value.to_f
50
+ @sample_rate = numeric.clamp(0.0, 1.0)
51
+ end
52
+
53
+ def identity_fields=(value)
54
+ @identity_fields = Array(value).map(&:to_sym)
55
+ end
56
+
57
+ def enqueue_failure_policy=(value)
58
+ @enqueue_failure_policy = (value || :drop).to_sym
59
+ end
60
+
61
+ def enqueue_buffer_size=(value)
62
+ @enqueue_buffer_size = [value.to_i, 1].max
63
+ end
64
+
65
+ def alert_cooldown=(value)
66
+ @alert_cooldown = value.is_a?(Numeric) ? value.seconds : value
67
+ end
68
+
69
+ def custom_filters
70
+ @custom_filters ||= {}
71
+ end
72
+
73
+ def custom_filters=(value)
74
+ @custom_filters = value.to_h.each_with_object({}) do |(key, definition), memo|
75
+ next if definition.blank?
76
+
77
+ normalized = definition.to_h.symbolize_keys
78
+ memo[key.to_sym] = {
79
+ label: normalized[:label].presence || key.to_s.humanize,
80
+ type: (normalized[:type] || :string).to_sym,
81
+ metadata_key: (normalized[:metadata_key] || key).to_s,
82
+ options: normalize_filter_options(normalized[:options]),
83
+ placeholder: normalized[:placeholder].presence
84
+ }.compact
85
+ end
86
+ end
87
+
88
+ private
89
+
90
+ def normalize_filter_options(options)
91
+ return unless options.present?
92
+
93
+ Array(options).map do |option|
94
+ if option.is_a?(Array)
95
+ [option[0].to_s, option[1].to_s]
96
+ elsif option.is_a?(Hash)
97
+ [option[:label].to_s, option[:value].to_s]
98
+ else
99
+ [option.to_s.humanize, option.to_s]
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,49 @@
1
+ module CodeSunset
2
+ class Context
3
+ THREAD_KEY = :code_sunset_context_stack
4
+
5
+ class << self
6
+ def current
7
+ stack.last.deep_dup
8
+ end
9
+
10
+ def push(context)
11
+ stack << merge(current, normalize(context))
12
+ end
13
+
14
+ def pop
15
+ stack.pop if stack.size > 1
16
+ current
17
+ end
18
+
19
+ def with(context)
20
+ push(context)
21
+ yield
22
+ ensure
23
+ pop
24
+ end
25
+
26
+ private
27
+
28
+ def stack
29
+ Thread.current[THREAD_KEY] ||= [default_context]
30
+ end
31
+
32
+ def default_context
33
+ { metadata: {} }
34
+ end
35
+
36
+ def normalize(context)
37
+ context.to_h.symbolize_keys.tap do |normalized|
38
+ normalized[:metadata] = normalized.fetch(:metadata, {}).to_h.deep_symbolize_keys
39
+ end
40
+ end
41
+
42
+ def merge(left, right)
43
+ left.deep_merge(right) do |key, left_value, right_value|
44
+ key == :metadata ? left_value.to_h.merge(right_value.to_h) : right_value
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,23 @@
1
+ module CodeSunset
2
+ module ControllerContext
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ around_action :with_code_sunset_request_context
7
+ end
8
+
9
+ private
10
+
11
+ def with_code_sunset_request_context
12
+ CodeSunset.with_context(
13
+ request_id: request.request_id,
14
+ request_path: request.fullpath,
15
+ controller: controller_name,
16
+ action: action_name,
17
+ source: "request"
18
+ ) do
19
+ yield
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,24 @@
1
+ module CodeSunset
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace CodeSunset
4
+ engine_name "code_sunset"
5
+
6
+ config.generators do |g|
7
+ g.test_framework :test_unit
8
+ end
9
+
10
+ initializer "code_sunset.controller_context" do
11
+ ActiveSupport.on_load(:action_controller_base) do
12
+ include CodeSunset::ControllerContext
13
+ end
14
+
15
+ ActiveSupport.on_load(:active_job) do
16
+ include CodeSunset::JobContext
17
+ end
18
+ end
19
+
20
+ config.after_initialize do
21
+ CodeSunset::Subscriber.subscribe!
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,28 @@
1
+ module CodeSunset
2
+ class EventBuffer
3
+ def initialize
4
+ @payloads = []
5
+ @mutex = Mutex.new
6
+ end
7
+
8
+ def store(payload, limit:)
9
+ @mutex.synchronize do
10
+ return false if @payloads.size >= limit
11
+
12
+ @payloads << payload.deep_dup
13
+ true
14
+ end
15
+ end
16
+
17
+ def drain(limit: nil)
18
+ @mutex.synchronize do
19
+ count = limit ? [limit.to_i, 0].max : @payloads.size
20
+ @payloads.shift(count)
21
+ end
22
+ end
23
+
24
+ def size
25
+ @mutex.synchronize { @payloads.size }
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,57 @@
1
+ module CodeSunset
2
+ class EventIngestor
3
+ class << self
4
+ def ingest(payload)
5
+ return if payload.blank?
6
+
7
+ if CodeSunset.configuration.async && defined?(CodeSunset::PersistEventJob)
8
+ enqueue(payload)
9
+ else
10
+ persist(payload)
11
+ end
12
+ rescue StandardError => error
13
+ CodeSunset.log_error("event ingestion", error)
14
+ handle_async_failure(payload, error)
15
+ end
16
+
17
+ def persist(payload)
18
+ return unless defined?(CodeSunset::Event)
19
+
20
+ CodeSunset::Event.create_from_payload(payload)
21
+ rescue StandardError => error
22
+ CodeSunset.log_error("event persistence", error)
23
+ nil
24
+ end
25
+
26
+ def flush_buffer(limit: nil)
27
+ drained = CodeSunset.event_buffer.drain(limit: limit)
28
+ drained.each { |payload| persist(payload) }
29
+ drained.size
30
+ rescue StandardError => error
31
+ CodeSunset.log_error("buffer flush", error)
32
+ 0
33
+ end
34
+
35
+ private
36
+
37
+ def enqueue(payload)
38
+ CodeSunset::PersistEventJob.perform_later(payload)
39
+ rescue StandardError => error
40
+ CodeSunset.log_error("persist event job enqueue", error)
41
+ handle_async_failure(payload, error)
42
+ end
43
+
44
+ def handle_async_failure(payload, _error)
45
+ case CodeSunset.configuration.enqueue_failure_policy
46
+ when :memory_buffer
47
+ stored = CodeSunset.event_buffer.store(payload, limit: CodeSunset.configuration.enqueue_buffer_size)
48
+ CodeSunset.logger.warn("[code_sunset] event buffer full, dropping payload for #{payload[:feature_key]}") unless stored
49
+ stored
50
+ else
51
+ CodeSunset.logger.warn("[code_sunset] dropping payload after async enqueue failure for #{payload[:feature_key]}")
52
+ nil
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,79 @@
1
+ module CodeSunset
2
+ class EventPayload
3
+ class << self
4
+ def build(feature_key, attrs = {})
5
+ return unless CodeSunset.enabled?
6
+
7
+ key = feature_key.to_s.strip
8
+ return if key.blank?
9
+ return if sampled_out?
10
+
11
+ merged = merge_context(attrs)
12
+ metadata = merged.delete(:metadata).to_h.deep_symbolize_keys
13
+ org_id = merged[:org_id]
14
+ user_id = merged[:user_id]
15
+
16
+ {
17
+ feature_key: key,
18
+ occurred_at: (merged[:occurred_at] || Time.current).utc,
19
+ user_id: resolve(:user_resolver, user_id, metadata, merged) || user_id,
20
+ org_id: resolve(:org_resolver, org_id, metadata, merged) || org_id,
21
+ request_id: merged[:request_id],
22
+ metadata: metadata,
23
+ source: merged[:source].presence || infer_source(merged),
24
+ request_path: merged[:request_path],
25
+ controller: merged[:controller],
26
+ action: merged[:action],
27
+ job_class: merged[:job_class],
28
+ app_env: merged[:app_env].presence || CodeSunset.configuration.environment,
29
+ app_version: merged[:app_version].presence || CodeSunset.configuration.app_version,
30
+ plan: merged[:plan].presence || resolve(:plan_resolver, org_id, metadata, merged),
31
+ internal_org: coerce_boolean(merged.fetch(:internal_org, resolve(:internal_org_resolver, org_id, metadata, merged))),
32
+ paid_org: coerce_boolean(merged.fetch(:paid_org, resolve(:paid_org_resolver, org_id, metadata, merged)))
33
+ }.compact
34
+ end
35
+
36
+ private
37
+
38
+ def merge_context(attrs)
39
+ context = CodeSunset::Context.current
40
+ normalized = attrs.to_h.symbolize_keys
41
+ context.deep_merge(normalized) do |key, left_value, right_value|
42
+ key == :metadata ? left_value.to_h.merge(right_value.to_h) : right_value
43
+ end
44
+ end
45
+
46
+ def sampled_out?
47
+ sample_rate = CodeSunset.configuration.sample_rate.to_f
48
+ sample_rate < 1.0 && rand >= sample_rate
49
+ end
50
+
51
+ def resolve(name, *args)
52
+ resolver = CodeSunset.configuration.public_send(name)
53
+ return unless resolver.respond_to?(:call)
54
+
55
+ CodeSunset.safe_execute("resolver #{name}") do
56
+ case resolver.arity
57
+ when 1 then resolver.call(args[0])
58
+ when 2 then resolver.call(args[0], args[1])
59
+ else resolver.call(*args)
60
+ end
61
+ end
62
+ end
63
+
64
+ def infer_source(payload)
65
+ return "job" if payload[:job_class].present?
66
+ return "request" if payload[:request_path].present?
67
+
68
+ "manual"
69
+ end
70
+
71
+ def coerce_boolean(value)
72
+ return if value.nil?
73
+ return value if value == true || value == false
74
+
75
+ ActiveModel::Type::Boolean.new.cast(value)
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,69 @@
1
+ module CodeSunset
2
+ class FeatureQuery
3
+ def initialize(filters = {}, scope: CodeSunset::Feature.ordered)
4
+ @filters = filters.to_h.symbolize_keys
5
+ @scope = scope
6
+ end
7
+
8
+ def call
9
+ features = scope.to_a
10
+ return [] if features.empty?
11
+
12
+ metrics = metrics_by_feature_key(features.map(&:key))
13
+
14
+ features.map do |feature|
15
+ feature_metrics = metrics.fetch(feature.key, {})
16
+ last_seen_at = feature_metrics[:last_seen_at]
17
+ snapshot = CodeSunset::FeatureSnapshot.new(
18
+ feature: feature,
19
+ last_seen_at: last_seen_at,
20
+ hits_7d: feature_metrics[:hits_7d].to_i,
21
+ hits_30d: feature_metrics[:hits_30d].to_i,
22
+ unique_orgs_count: feature_metrics[:unique_orgs_count].to_i,
23
+ unique_users_count: feature_metrics[:unique_users_count].to_i,
24
+ paid_hits_30d: feature_metrics[:paid_hits_30d].to_i,
25
+ status: feature.computed_status(last_seen_at: last_seen_at),
26
+ score: 0.0
27
+ )
28
+
29
+ snapshot.score = CodeSunset::ScoreCalculator.new(feature: feature, snapshot: snapshot).score
30
+ snapshot
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ attr_reader :filters, :scope
37
+
38
+ def metrics_by_feature_key(feature_keys)
39
+ metrics = Hash.new { |hash, key| hash[key] = {} }
40
+ analytics = CodeSunset::AnalyticsFilters.new(filters)
41
+
42
+ if analytics.raw_dimension_filters?
43
+ event_scope = CodeSunset::Event.where(feature_key: feature_keys).apply_filters(analytics.raw_filters)
44
+ event_scope = event_scope.where(occurred_at: analytics.raw_retained_range)
45
+ merge_metric!(metrics, CodeSunset::Event.grouped_last_seen(event_scope), :last_seen_at)
46
+ recent_event_scope = event_scope
47
+ else
48
+ rollup_scope = CodeSunset::DailyRollup.where(feature_key: feature_keys).apply_filters(analytics.rollup_filters)
49
+ merge_metric!(metrics, rollup_scope.group(:feature_key).maximum(:last_seen_at), :last_seen_at)
50
+ current_event_scope = CodeSunset::Event.where(feature_key: feature_keys).apply_filters(analytics.rollup_filters)
51
+ merge_metric!(metrics, CodeSunset::Event.grouped_last_seen(current_event_scope), :last_seen_at)
52
+ recent_event_scope = CodeSunset::Event.where(feature_key: feature_keys).apply_filters(analytics.recent_raw_filters)
53
+ end
54
+
55
+ merge_metric!(metrics, CodeSunset::Event.grouped_counts(recent_event_scope.where(occurred_at: 7.days.ago..Time.current)), :hits_7d)
56
+ merge_metric!(metrics, CodeSunset::Event.grouped_counts(recent_event_scope.where(occurred_at: 30.days.ago..Time.current)), :hits_30d)
57
+ merge_metric!(metrics, CodeSunset::Event.grouped_counts(recent_event_scope.where(occurred_at: 30.days.ago..Time.current, paid_org: true)), :paid_hits_30d)
58
+ merge_metric!(metrics, CodeSunset::Event.grouped_distinct_org_counts(recent_event_scope.where(occurred_at: 30.days.ago..Time.current)), :unique_orgs_count)
59
+ merge_metric!(metrics, CodeSunset::Event.grouped_distinct_user_counts(recent_event_scope.where(occurred_at: 30.days.ago..Time.current)), :unique_users_count)
60
+ metrics
61
+ end
62
+
63
+ def merge_metric!(metrics, values, key)
64
+ values.each do |feature_key, value|
65
+ metrics[feature_key][key] = value
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,14 @@
1
+ module CodeSunset
2
+ FeatureSnapshot = Struct.new(
3
+ :feature,
4
+ :last_seen_at,
5
+ :hits_7d,
6
+ :hits_30d,
7
+ :unique_orgs_count,
8
+ :unique_users_count,
9
+ :paid_hits_30d,
10
+ :status,
11
+ :score,
12
+ keyword_init: true
13
+ )
14
+ end