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,59 @@
1
+ module CodeSunset
2
+ class FeatureUsageSeries
3
+ DEFAULT_WINDOW_DAYS = 30
4
+
5
+ def initialize(feature:, filters: {})
6
+ @feature = feature
7
+ @filters = filters.to_h.symbolize_keys
8
+ end
9
+
10
+ def call
11
+ date_range = normalized_range
12
+ analytics = CodeSunset::AnalyticsFilters.new(filters)
13
+
14
+ series = if analytics.raw_dimension_filters?
15
+ effective_range = analytics.retain_range(date_range)
16
+ feature.events.apply_filters(analytics.raw_filters).where(occurred_at: effective_range).group("DATE(occurred_at)").count
17
+ else
18
+ rollup_series = feature.daily_rollups.apply_filters(analytics.rollup_filters).where(day: date_range.first.to_date..date_range.last.to_date).group(:day).sum(:hits_count)
19
+ raw_series_fallback(rollup_series, date_range, analytics)
20
+ end
21
+
22
+ fill_gaps(series, date_range)
23
+ end
24
+
25
+ private
26
+
27
+ attr_reader :feature, :filters
28
+
29
+ def normalized_range
30
+ from = parse_time(filters[:from]) || DEFAULT_WINDOW_DAYS.days.ago.beginning_of_day
31
+ to = parse_time(filters[:to]) || Time.current.end_of_day
32
+ from.beginning_of_day..to.end_of_day
33
+ end
34
+
35
+ def raw_series_fallback(rollup_series, date_range, analytics)
36
+ return rollup_series if rollup_series.present?
37
+
38
+ feature.events
39
+ .apply_filters(analytics.rollup_filters)
40
+ .where(occurred_at: date_range)
41
+ .group("DATE(occurred_at)")
42
+ .count
43
+ end
44
+
45
+ def parse_time(value)
46
+ return if value.blank?
47
+
48
+ Time.zone.parse(value.to_s)
49
+ rescue ArgumentError
50
+ nil
51
+ end
52
+
53
+ def fill_gaps(series, date_range)
54
+ (date_range.first.to_date..date_range.last.to_date).each_with_object({}) do |day, memo|
55
+ memo[day] = series[day] || series[day.to_s] || 0
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,9 @@
1
+ module CodeSunset
2
+ class IdentityHash
3
+ class << self
4
+ def digest(value)
5
+ Digest::SHA256.hexdigest(value.to_s)
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,17 @@
1
+ module CodeSunset
2
+ class Instrumentation
3
+ EVENT_NAME = "code_sunset.hit".freeze
4
+
5
+ class << self
6
+ def hit(feature_key, **attrs)
7
+ payload = CodeSunset::EventPayload.build(feature_key, attrs)
8
+ return unless payload
9
+
10
+ ActiveSupport::Notifications.instrument(EVENT_NAME, payload)
11
+ rescue StandardError => error
12
+ CodeSunset.log_error("instrumentation", error)
13
+ nil
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,17 @@
1
+ module CodeSunset
2
+ module JobContext
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ around_perform :with_code_sunset_job_context
7
+ end
8
+
9
+ private
10
+
11
+ def with_code_sunset_job_context
12
+ CodeSunset.with_context(job_class: self.class.name, source: "job") do
13
+ yield
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,17 @@
1
+ module CodeSunset
2
+ class RemovalCandidateQuery
3
+ def initialize(filters = {})
4
+ @filters = filters
5
+ end
6
+
7
+ def call
8
+ CodeSunset::FeatureQuery.new(filters).call.select do |snapshot|
9
+ %w[candidate_for_removal safe_to_remove].include?(snapshot.status)
10
+ end.sort_by { |snapshot| [-snapshot.score.to_f, snapshot.feature.key] }
11
+ end
12
+
13
+ private
14
+
15
+ attr_reader :filters
16
+ end
17
+ end
@@ -0,0 +1,331 @@
1
+ require "find"
2
+ require "pathname"
3
+
4
+ module CodeSunset
5
+ RemovalPromptArtifact = Struct.new(
6
+ :prompt_markdown,
7
+ :evidence_markdown,
8
+ :summary,
9
+ :search_matches,
10
+ :signals,
11
+ :warnings,
12
+ keyword_init: true
13
+ )
14
+
15
+ class RemovalPromptBuilder
16
+ SearchMatch = Struct.new(:path, :line_number, :line_preview, keyword_init: true)
17
+
18
+ SEARCH_DIRECTORIES = %w[app config lib db test spec].freeze
19
+ EXCLUDED_DIRECTORY_NAMES = %w[log tmp storage node_modules .git].freeze
20
+ EXCLUDED_PATH_FRAGMENTS = ["vendor/bundle"].freeze
21
+ MATCH_LIMIT = 20
22
+ RECENT_EVENT_LIMIT = 5
23
+ PREVIEW_LIMIT = 180
24
+
25
+ def initialize(feature:, snapshot:, filters:, top_orgs:, top_users:, request_paths:, job_usage:, recent_events:, recent_events_total_count:, usage_series:, root: Rails.root)
26
+ @feature = feature
27
+ @snapshot = snapshot
28
+ @filters = filters.to_h.symbolize_keys
29
+ @top_orgs = Array(top_orgs)
30
+ @top_users = Array(top_users)
31
+ @request_paths = Array(request_paths)
32
+ @job_usage = Array(job_usage)
33
+ @recent_events = Array(recent_events)
34
+ @recent_events_total_count = recent_events_total_count.to_i
35
+ @usage_series = usage_series.to_h
36
+ @root = Pathname.new(root.to_s)
37
+ end
38
+
39
+ def call
40
+ search_matches = build_search_matches
41
+ warnings = build_warnings(search_matches)
42
+ signals = build_signals(search_matches)
43
+
44
+ RemovalPromptArtifact.new(
45
+ prompt_markdown: build_prompt(signals, warnings, search_matches),
46
+ evidence_markdown: build_evidence(signals, warnings, search_matches),
47
+ summary: build_summary,
48
+ search_matches: search_matches,
49
+ signals: signals,
50
+ warnings: warnings
51
+ )
52
+ end
53
+
54
+ private
55
+
56
+ attr_reader :feature, :snapshot, :filters, :top_orgs, :top_users, :request_paths, :job_usage, :recent_events, :recent_events_total_count, :usage_series, :root
57
+
58
+ def build_prompt(signals, warnings, search_matches)
59
+ lines = []
60
+ lines << "# Removal Prompt: #{feature.key}"
61
+ lines << ""
62
+ lines << "You are helping remove the Rails feature `#{feature.key}` conservatively."
63
+ lines << ""
64
+ lines << "## Objective"
65
+ lines << "Assess whether `#{feature.key}` can be retired and identify the safest way to remove the feature and associated code."
66
+ lines << ""
67
+ lines << "## Working Rules"
68
+ lines << "- Treat runtime evidence as the source of truth for recent usage."
69
+ lines << "- Inspect the code references below before changing anything."
70
+ lines << "- Prefer staged disablement or guarded rollout steps before hard deletion when risk is non-trivial."
71
+ lines << "- If the evidence is insufficient for deletion, say so clearly and recommend the next safest step."
72
+ lines << ""
73
+ lines << "## Runtime Evidence"
74
+ lines << "- Status: #{signals[:status]}"
75
+ lines << "- Removal score: #{signals[:score]}"
76
+ lines << "- Last seen: #{signals[:last_seen]}"
77
+ lines << "- Activity: #{signals[:hits_7d]} hits in 7d, #{signals[:hits_30d]} hits in 30d"
78
+ lines << "- Reach: #{signals[:unique_orgs_count]} orgs, #{signals[:unique_users_count]} users in 30d"
79
+ lines << "- Paid usage: #{signals[:paid_hits_30d]}"
80
+ lines << "- Filters applied: #{signals[:filter_context]}"
81
+ lines << "- Repo matches found: #{search_matches.count}"
82
+ lines << ""
83
+ lines << "## What To Do"
84
+ lines << "1. Verify whether this feature still appears necessary based on the runtime evidence."
85
+ lines << "2. Review the matching code references and identify controllers, jobs, services, views, routes, tests, configs, and docs tied to `#{feature.key}`."
86
+ lines << "3. Propose the safest removal approach, including any staged disablement, cleanup ordering, and rollback considerations."
87
+ lines << "4. List the exact files likely to change."
88
+ lines << "5. Call out risks, unknowns, and what would block safe removal."
89
+ lines << "6. Provide a verification plan covering tests, runtime checks, and post-removal monitoring."
90
+ lines << ""
91
+ lines << "## Expected Output"
92
+ lines << "- A short assessment of whether the feature is removable now"
93
+ lines << "- A conservative removal plan"
94
+ lines << "- The exact files to inspect or change"
95
+ lines << "- Risks and unknowns"
96
+ lines << "- Verification steps"
97
+ lines << ""
98
+ lines << "## Notes"
99
+ lines << "- Recent event detail and raw-only filters may be limited by retained raw event history."
100
+ lines << "- Use the evidence pack below as the starting point, then inspect the codebase directly as needed."
101
+ if warnings.any?
102
+ lines << ""
103
+ lines << "## Warnings"
104
+ warnings.each { |warning| lines << "- #{warning}" }
105
+ end
106
+ lines.join("\n")
107
+ end
108
+
109
+ def build_evidence(signals, warnings, search_matches)
110
+ lines = []
111
+ lines << "# Evidence Pack: #{feature.key}"
112
+ lines << ""
113
+ lines << "## Summary"
114
+ lines << summary_line(build_summary)
115
+ lines << ""
116
+ lines << "## Feature Metadata"
117
+ lines << summary_line("Owner: #{feature.owner.presence || "Unassigned"}")
118
+ lines << summary_line("Description: #{feature.description.presence || "No description provided."}")
119
+ lines << summary_line("Sunset after days: #{feature.sunset_after_days}")
120
+ lines << summary_line("Remove after days unused: #{feature.remove_after_days_unused}")
121
+ lines << ""
122
+ lines << "## Runtime Signals"
123
+ lines << summary_line("Status: #{signals[:status]}")
124
+ lines << summary_line("Removal score: #{signals[:score]}")
125
+ lines << summary_line("Last seen: #{signals[:last_seen]}")
126
+ lines << summary_line("Hits: #{signals[:hits_7d]} in 7d, #{signals[:hits_30d]} in 30d")
127
+ lines << summary_line("Reach: #{signals[:unique_orgs_count]} orgs, #{signals[:unique_users_count]} users in 30d")
128
+ lines << summary_line("Paid usage in 30d: #{signals[:paid_hits_30d]}")
129
+ lines << summary_line("Filters applied: #{signals[:filter_context]}")
130
+ lines << summary_line("Trend points captured: #{signals[:usage_points]}")
131
+ lines << ""
132
+ lines << "## Score Breakdown"
133
+ score_breakdown.each do |label, detail|
134
+ lines << summary_line("#{label}: #{detail}")
135
+ end
136
+ lines << ""
137
+ lines << "## Top Orgs"
138
+ lines.concat(list_or_empty(top_orgs))
139
+ lines << ""
140
+ lines << "## Top Users"
141
+ lines.concat(list_or_empty(top_users))
142
+ lines << ""
143
+ lines << "## Request Paths"
144
+ lines.concat(list_or_empty(request_paths))
145
+ lines << ""
146
+ lines << "## Job Classes"
147
+ lines.concat(list_or_empty(job_usage))
148
+ lines << ""
149
+ lines << "## Recent Event Window"
150
+ lines << summary_line(recent_event_window_note)
151
+ lines.concat(recent_event_lines)
152
+ lines << ""
153
+ lines << "## Exact Host-App Matches"
154
+ if search_matches.any?
155
+ search_matches.each do |match|
156
+ lines << summary_line("`#{match.path}:#{match.line_number}` — #{match.line_preview}")
157
+ end
158
+ else
159
+ lines << summary_line("No exact feature-key references were found under #{SEARCH_DIRECTORIES.join(', ')}.")
160
+ end
161
+ if warnings.any?
162
+ lines << ""
163
+ lines << "## Warnings"
164
+ warnings.each { |warning| lines << summary_line(warning) }
165
+ end
166
+ lines.join("\n")
167
+ end
168
+
169
+ def build_summary
170
+ case snapshot&.status
171
+ when "safe_to_remove"
172
+ "This feature looks like a low-risk removal candidate based on inactivity and the current removal score."
173
+ when "candidate_for_removal"
174
+ "This feature appears close to removable, but the remaining references and recent signals should be reviewed before code is deleted."
175
+ when "cooling_down"
176
+ "This feature is trending toward removal, but recent usage still suggests a staged approach."
177
+ else
178
+ "This feature still shows active or uncertain usage and should be treated as a higher-risk removal candidate."
179
+ end
180
+ end
181
+
182
+ def build_signals(search_matches)
183
+ {
184
+ status: snapshot&.status.to_s.tr("_", " ").titleize.presence || "Unknown",
185
+ score: format("%.1f/100", snapshot&.score.to_f),
186
+ last_seen: if snapshot&.last_seen_at.present?
187
+ snapshot.last_seen_at.in_time_zone.strftime("%Y-%m-%d %H:%M:%S %Z")
188
+ else
189
+ "Never observed"
190
+ end,
191
+ hits_7d: snapshot&.hits_7d.to_i,
192
+ hits_30d: snapshot&.hits_30d.to_i,
193
+ unique_orgs_count: snapshot&.unique_orgs_count.to_i,
194
+ unique_users_count: snapshot&.unique_users_count.to_i,
195
+ paid_hits_30d: snapshot&.paid_hits_30d.to_i,
196
+ usage_points: usage_series.size,
197
+ filter_context: filter_context,
198
+ search_match_count: search_matches.count
199
+ }
200
+ end
201
+
202
+ def build_warnings(search_matches)
203
+ warnings = []
204
+ warnings << analytics.raw_filter_notice if analytics.raw_filter_notice.present?
205
+ warnings << "Paid-org traffic still exists in the last 30 days." if snapshot&.paid_hits_30d.to_i.positive?
206
+ warnings << "The feature still has hits in the last 7 days." if snapshot&.hits_7d.to_i.positive?
207
+ warnings << "No recent runtime events matched the selected filters." if recent_events_total_count.zero?
208
+ warnings << "No exact feature-key references were found in the host app." if search_matches.empty?
209
+ warnings
210
+ end
211
+
212
+ def score_breakdown
213
+ return {} unless snapshot
214
+
215
+ calculator = CodeSunset::ScoreCalculator.new(feature: feature, snapshot: snapshot)
216
+ calculator.breakdown.transform_values do |component|
217
+ "#{format('%.1f', component[:score])}/#{format('%.0f', component[:max])} from #{component[:detail]}"
218
+ end.transform_keys do |key|
219
+ key.to_s.tr("_", " ").titleize
220
+ end
221
+ end
222
+
223
+ def list_or_empty(entries)
224
+ return [summary_line("None in the current window.")] if entries.empty?
225
+
226
+ entries.map do |identifier, hits|
227
+ summary_line("`#{identifier}` — #{hits.to_i} hits")
228
+ end
229
+ end
230
+
231
+ def recent_event_window_note
232
+ "Showing #{[recent_events.size, RECENT_EVENT_LIMIT].min} of #{recent_events_total_count} recent events that matched the current filters."
233
+ end
234
+
235
+ def recent_event_lines
236
+ return [summary_line("No recent events matched the selected filters.")] if recent_events.empty?
237
+
238
+ recent_events.first(RECENT_EVENT_LIMIT).map do |event|
239
+ summary_line("#{event.occurred_at.in_time_zone.strftime('%Y-%m-%d %H:%M:%S %Z')} — #{event.request_path.presence || event.job_class.presence || event.source}")
240
+ end
241
+ end
242
+
243
+ def build_search_matches
244
+ matches = []
245
+
246
+ searchable_files.each do |path|
247
+ File.foreach(path, encoding: "UTF-8", invalid: :replace, undef: :replace, replace: "").with_index(1) do |line, line_number|
248
+ next unless line.include?(feature.key)
249
+
250
+ matches << SearchMatch.new(
251
+ path: relative_path(path),
252
+ line_number: line_number,
253
+ line_preview: compact_preview(line)
254
+ )
255
+ return matches if matches.size >= MATCH_LIMIT
256
+ end
257
+ rescue StandardError
258
+ next
259
+ end
260
+
261
+ matches
262
+ end
263
+
264
+ def searchable_files
265
+ files = []
266
+
267
+ SEARCH_DIRECTORIES.each do |entry|
268
+ directory = root.join(entry)
269
+ next unless directory.exist?
270
+
271
+ Find.find(directory.to_s) do |path|
272
+ if File.directory?(path)
273
+ basename = File.basename(path)
274
+ if EXCLUDED_DIRECTORY_NAMES.include?(basename) || excluded_path_fragment?(path)
275
+ Find.prune
276
+ else
277
+ next
278
+ end
279
+ end
280
+
281
+ next unless File.file?(path)
282
+ next if excluded_path_fragment?(path)
283
+ next unless text_file?(path)
284
+
285
+ files << path
286
+ end
287
+ end
288
+
289
+ files.sort
290
+ end
291
+
292
+ def excluded_path_fragment?(path)
293
+ EXCLUDED_PATH_FRAGMENTS.any? { |fragment| path.include?(fragment) }
294
+ end
295
+
296
+ def text_file?(path)
297
+ sample = File.binread(path, 1024)
298
+ !sample.include?("\x00")
299
+ rescue StandardError
300
+ false
301
+ end
302
+
303
+ def compact_preview(line)
304
+ compact = line.to_s.strip.gsub(/\s+/, " ")
305
+ return compact if compact.length <= PREVIEW_LIMIT
306
+
307
+ "#{compact[0, PREVIEW_LIMIT - 1]}…"
308
+ end
309
+
310
+ def relative_path(path)
311
+ Pathname.new(path).relative_path_from(root).to_s
312
+ end
313
+
314
+ def filter_context
315
+ visible_filters = filters.except(:page)
316
+ return "None" if visible_filters.blank?
317
+
318
+ visible_filters.map do |key, value|
319
+ "#{key}=#{value}"
320
+ end.join(", ")
321
+ end
322
+
323
+ def analytics
324
+ @analytics ||= CodeSunset::AnalyticsFilters.new(filters)
325
+ end
326
+
327
+ def summary_line(text)
328
+ "- #{text}"
329
+ end
330
+ end
331
+ end
@@ -0,0 +1,83 @@
1
+ module CodeSunset
2
+ class ScoreCalculator
3
+ def initialize(feature:, snapshot:)
4
+ @feature = feature
5
+ @snapshot = snapshot
6
+ end
7
+
8
+ def score
9
+ return 0.0 unless snapshot
10
+
11
+ breakdown.values.sum { |component| component[:score] }.round(2)
12
+ end
13
+
14
+ def breakdown
15
+ return {} unless snapshot
16
+
17
+ {
18
+ recency: recency_breakdown,
19
+ volume: volume_breakdown,
20
+ orgs: org_breakdown,
21
+ paid_usage: paid_usage_breakdown
22
+ }
23
+ end
24
+
25
+ private
26
+
27
+ attr_reader :feature, :snapshot
28
+
29
+ def days_unused
30
+ @days_unused ||= if snapshot.last_seen_at.present?
31
+ (Time.current.to_date - snapshot.last_seen_at.to_date).to_i
32
+ else
33
+ (Time.current.to_date - (feature.created_at || Time.current).to_date).to_i
34
+ end
35
+ end
36
+
37
+ def recency_breakdown
38
+ score = ([days_unused, 120].min / 120.0 * 45).round(2)
39
+
40
+ {
41
+ score: score,
42
+ max: 45.0,
43
+ detail: "#{days_unused} days unused"
44
+ }
45
+ end
46
+
47
+ def volume_breakdown
48
+ recent_hits = [snapshot.hits_30d.to_i, 100].min
49
+ score = ((1 - (recent_hits / 100.0)) * 25).round(2)
50
+
51
+ {
52
+ score: score,
53
+ max: 25.0,
54
+ detail: "#{snapshot.hits_30d.to_i} hits in last 30d"
55
+ }
56
+ end
57
+
58
+ def org_breakdown
59
+ orgs = [snapshot.unique_orgs_count.to_i, 20].min
60
+ score = ((1 - (orgs / 20.0)) * 20).round(2)
61
+
62
+ {
63
+ score: score,
64
+ max: 20.0,
65
+ detail: "#{snapshot.unique_orgs_count.to_i} orgs in last 30d"
66
+ }
67
+ end
68
+
69
+ def paid_usage_breakdown
70
+ score = snapshot.paid_hits_30d.to_i.zero? ? 10.0 : 0.0
71
+
72
+ {
73
+ score: score,
74
+ max: 10.0,
75
+ detail: if snapshot.paid_hits_30d.to_i.zero?
76
+ "no paid hits in last 30d"
77
+ else
78
+ "#{snapshot.paid_hits_30d.to_i} paid hits in last 30d"
79
+ end
80
+ }
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,38 @@
1
+ module CodeSunset
2
+ class StatusEngine
3
+ def initialize(feature:, last_seen_at:, reference_time: Time.current)
4
+ @feature = feature
5
+ @last_seen_at = last_seen_at
6
+ @reference_time = reference_time
7
+ end
8
+
9
+ def status
10
+ return "safe_to_remove" if days_unused >= sunset_after_days
11
+ return "candidate_for_removal" if days_unused >= remove_after_days_unused
12
+ return "cooling_down" if days_unused >= cooling_down_days
13
+
14
+ "active"
15
+ end
16
+
17
+ private
18
+
19
+ attr_reader :feature, :last_seen_at, :reference_time
20
+
21
+ def days_unused
22
+ baseline = last_seen_at || feature.created_at || reference_time
23
+ (reference_time.to_date - baseline.to_date).to_i
24
+ end
25
+
26
+ def sunset_after_days
27
+ feature.sunset_after_days.presence || 90
28
+ end
29
+
30
+ def remove_after_days_unused
31
+ feature.remove_after_days_unused.presence || 60
32
+ end
33
+
34
+ def cooling_down_days
35
+ [14, (remove_after_days_unused / 2.0).ceil].max
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,24 @@
1
+ module CodeSunset
2
+ class Subscriber
3
+ class << self
4
+ def subscribe!
5
+ return @subscription if @subscription
6
+
7
+ @subscription = ActiveSupport::Notifications.subscribe(CodeSunset::Instrumentation::EVENT_NAME) do |_name, _start, _finish, _id, payload|
8
+ CodeSunset::EventIngestor.ingest(payload)
9
+ end
10
+ end
11
+
12
+ def unsubscribe!
13
+ return unless @subscription
14
+
15
+ ActiveSupport::Notifications.unsubscribe(@subscription)
16
+ @subscription = nil
17
+ end
18
+
19
+ def subscription
20
+ @subscription
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,3 @@
1
+ module CodeSunset
2
+ VERSION = "0.1.0"
3
+ end