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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +242 -0
- data/Rakefile +6 -0
- data/app/assets/images/code_sunset/.keep +0 -0
- data/app/assets/images/code_sunset/logo-dark.png +0 -0
- data/app/assets/images/code_sunset/logo-light.png +0 -0
- data/app/assets/images/code_sunset/topography.svg +1 -0
- data/app/assets/javascripts/code_sunset/application.js +179 -0
- data/app/assets/javascripts/code_sunset/vendor/chart.umd.min.js +14 -0
- data/app/assets/stylesheets/code_sunset/application.css +1075 -0
- data/app/controllers/code_sunset/application_controller.rb +50 -0
- data/app/controllers/code_sunset/dashboard_controller.rb +12 -0
- data/app/controllers/code_sunset/features_controller.rb +62 -0
- data/app/controllers/code_sunset/removal_candidates_controller.rb +9 -0
- data/app/controllers/concerns/.keep +0 -0
- data/app/helpers/code_sunset/application_helper.rb +244 -0
- data/app/jobs/code_sunset/aggregate_daily_rollups_job.rb +48 -0
- data/app/jobs/code_sunset/application_job.rb +4 -0
- data/app/jobs/code_sunset/cleanup_events_job.rb +10 -0
- data/app/jobs/code_sunset/evaluate_alerts_job.rb +15 -0
- data/app/jobs/code_sunset/flush_buffered_events_job.rb +9 -0
- data/app/jobs/code_sunset/persist_event_job.rb +9 -0
- data/app/mailers/code_sunset/application_mailer.rb +6 -0
- data/app/models/code_sunset/alert_delivery.rb +14 -0
- data/app/models/code_sunset/application_record.rb +5 -0
- data/app/models/code_sunset/daily_rollup.rb +31 -0
- data/app/models/code_sunset/event.rb +190 -0
- data/app/models/code_sunset/feature.rb +33 -0
- data/app/models/concerns/.keep +0 -0
- data/app/views/code_sunset/dashboard/index.html.erb +123 -0
- data/app/views/code_sunset/features/index.html.erb +88 -0
- data/app/views/code_sunset/features/show.html.erb +266 -0
- data/app/views/code_sunset/removal_candidates/index.html.erb +118 -0
- data/app/views/code_sunset/shared/_filter_bar.html.erb +81 -0
- data/app/views/layouts/code_sunset/application.html.erb +57 -0
- data/bin/rails +26 -0
- data/code_sunset.gemspec +35 -0
- data/config/routes.rb +5 -0
- data/db/migrate/20260330000000_create_code_sunset_tables.rb +62 -0
- data/db/migrate/20260331000000_harden_code_sunset_reliability.rb +39 -0
- data/lib/code_sunset/alert_dispatcher.rb +103 -0
- data/lib/code_sunset/analytics_filters.rb +59 -0
- data/lib/code_sunset/configuration.rb +104 -0
- data/lib/code_sunset/context.rb +49 -0
- data/lib/code_sunset/controller_context.rb +23 -0
- data/lib/code_sunset/engine.rb +24 -0
- data/lib/code_sunset/event_buffer.rb +28 -0
- data/lib/code_sunset/event_ingestor.rb +57 -0
- data/lib/code_sunset/event_payload.rb +79 -0
- data/lib/code_sunset/feature_query.rb +69 -0
- data/lib/code_sunset/feature_snapshot.rb +14 -0
- data/lib/code_sunset/feature_usage_series.rb +59 -0
- data/lib/code_sunset/identity_hash.rb +9 -0
- data/lib/code_sunset/instrumentation.rb +17 -0
- data/lib/code_sunset/job_context.rb +17 -0
- data/lib/code_sunset/removal_candidate_query.rb +17 -0
- data/lib/code_sunset/removal_prompt_builder.rb +331 -0
- data/lib/code_sunset/score_calculator.rb +83 -0
- data/lib/code_sunset/status_engine.rb +38 -0
- data/lib/code_sunset/subscriber.rb +24 -0
- data/lib/code_sunset/version.rb +3 -0
- data/lib/code_sunset.rb +109 -0
- data/lib/generators/code_sunset/eject_ui/eject_ui_generator.rb +33 -0
- data/lib/generators/code_sunset/initializer/initializer_generator.rb +13 -0
- data/lib/generators/code_sunset/initializer/templates/code_sunset.rb +34 -0
- data/lib/generators/code_sunset/install/install_generator.rb +23 -0
- data/lib/generators/code_sunset/migration/migration_generator.rb +21 -0
- data/lib/generators/code_sunset/migration/templates/create_code_sunset_tables.rb +75 -0
- data/lib/tasks/code_sunset_tasks.rake +21 -0
- 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,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
|