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,190 @@
1
+ module CodeSunset
2
+ class Event < ApplicationRecord
3
+ self.table_name = "code_sunset_events"
4
+
5
+ belongs_to :feature, primary_key: :key, foreign_key: :feature_key, inverse_of: :events, optional: true
6
+
7
+ validates :feature_key, presence: true
8
+ validates :occurred_at, presence: true
9
+
10
+ before_validation :apply_defaults
11
+
12
+ scope :recent_first, -> { order(occurred_at: :desc) }
13
+
14
+ class << self
15
+ def create_from_payload(payload)
16
+ attrs = sanitize_payload(payload)
17
+
18
+ transaction do
19
+ CodeSunset::Feature.find_or_create_by!(key: attrs[:feature_key])
20
+ create!(attrs)
21
+ end
22
+ end
23
+
24
+ def apply_filters(filters = {})
25
+ scoped = all
26
+ normalized = filters.to_h.symbolize_keys
27
+
28
+ scoped = scoped.where(app_env: normalized[:app_env]) if normalized[:app_env].present?
29
+ scoped = scoped.where(plan: normalized[:plan]) if normalized[:plan].present?
30
+ if normalized[:org_id].present?
31
+ scoped = apply_identity_filter(scoped, :org_id, :hashed_org_id, normalized[:org_id])
32
+ end
33
+ if normalized[:user_id].present?
34
+ scoped = apply_identity_filter(scoped, :user_id, :hashed_user_id, normalized[:user_id])
35
+ end
36
+ scoped = scoped.where(paid_org: true) if truthy?(normalized[:paid_only])
37
+ scoped = scoped.where.not(internal_org: true) if truthy?(normalized[:exclude_internal])
38
+ scoped = apply_custom_metadata_filters(scoped, normalized)
39
+
40
+ from_time = parse_time(normalized[:from])&.beginning_of_day
41
+ to_time = parse_time(normalized[:to])&.end_of_day
42
+
43
+ scoped = scoped.where(occurred_at: from_time..) if from_time
44
+ scoped = scoped.where(occurred_at: ..to_time) if to_time
45
+ scoped
46
+ end
47
+
48
+ def grouped_last_seen(scope)
49
+ scope.group(:feature_key).maximum(:occurred_at)
50
+ end
51
+
52
+ def grouped_counts(scope)
53
+ scope.group(:feature_key).count
54
+ end
55
+
56
+ def grouped_distinct_org_counts(scope)
57
+ scope.group(:feature_key).distinct.count(Arel.sql(org_identifier_expression))
58
+ end
59
+
60
+ def grouped_distinct_user_counts(scope)
61
+ scope.group(:feature_key).distinct.count(Arel.sql(user_identifier_expression))
62
+ end
63
+
64
+ def top_request_paths(scope, limit: 10)
65
+ grouped_top(scope.where.not(request_path: [nil, ""]), "request_path", limit: limit)
66
+ end
67
+
68
+ def top_job_classes(scope, limit: 10)
69
+ grouped_top(scope.where.not(job_class: [nil, ""]), "job_class", limit: limit)
70
+ end
71
+
72
+ def top_orgs(scope, limit: 10)
73
+ grouped_top(scope, org_identifier_expression, limit: limit, alias_name: "org_identifier")
74
+ end
75
+
76
+ def top_users(scope, limit: 10)
77
+ grouped_top(scope, user_identifier_expression, limit: limit, alias_name: "user_identifier")
78
+ end
79
+
80
+ def org_identifier_expression
81
+ "COALESCE(code_sunset_events.hashed_org_id, code_sunset_events.org_id::text)"
82
+ end
83
+
84
+ def user_identifier_expression
85
+ "COALESCE(code_sunset_events.hashed_user_id, code_sunset_events.user_id::text)"
86
+ end
87
+
88
+ private
89
+
90
+ def sanitize_payload(payload)
91
+ attrs = payload.to_h.symbolize_keys.slice(
92
+ :feature_key,
93
+ :occurred_at,
94
+ :user_id,
95
+ :org_id,
96
+ :request_id,
97
+ :source,
98
+ :request_path,
99
+ :controller,
100
+ :action,
101
+ :job_class,
102
+ :app_env,
103
+ :app_version,
104
+ :metadata,
105
+ :plan,
106
+ :internal_org,
107
+ :paid_org
108
+ )
109
+ attrs[:metadata] = attrs.fetch(:metadata, {}).to_h
110
+
111
+ if CodeSunset.configuration.hash_identity
112
+ apply_identity_hash!(attrs, :user_id, :hashed_user_id)
113
+ apply_identity_hash!(attrs, :org_id, :hashed_org_id)
114
+ end
115
+
116
+ attrs
117
+ end
118
+
119
+ def apply_identity_hash!(attrs, source_key, target_key)
120
+ return unless CodeSunset.configuration.identity_fields.include?(source_key)
121
+ return if attrs[source_key].blank?
122
+
123
+ attrs[target_key] = CodeSunset::IdentityHash.digest(attrs[source_key])
124
+ attrs[source_key] = nil
125
+ end
126
+
127
+ def grouped_top(scope, expression, limit:, alias_name: "identifier")
128
+ scope
129
+ .select("#{expression} AS #{alias_name}", "COUNT(*) AS hits")
130
+ .where(Arel.sql("#{expression} IS NOT NULL"))
131
+ .group(alias_name)
132
+ .order("hits DESC")
133
+ .limit(limit)
134
+ .map { |row| [row.public_send(alias_name), row.hits.to_i] }
135
+ end
136
+
137
+ def apply_custom_metadata_filters(scope, filters)
138
+ CodeSunset.configuration.custom_filters.each do |param_key, definition|
139
+ next unless filters[param_key].present?
140
+
141
+ metadata_key = definition[:metadata_key]
142
+ case definition[:type].to_sym
143
+ when :boolean
144
+ scope = scope.where("code_sunset_events.metadata ->> ? = ?", metadata_key, truthy?(filters[param_key]).to_s)
145
+ else
146
+ scope = scope.where("code_sunset_events.metadata ->> ? = ?", metadata_key, filters[param_key].to_s)
147
+ end
148
+ end
149
+
150
+ scope
151
+ end
152
+
153
+ def apply_identity_filter(scope, raw_key, hashed_key, value)
154
+ if CodeSunset.configuration.hash_identity && CodeSunset.configuration.identity_fields.include?(raw_key)
155
+ scope.where(hashed_key => CodeSunset::IdentityHash.digest(value))
156
+ else
157
+ scope.where(raw_key => value)
158
+ end
159
+ end
160
+
161
+ def parse_time(value)
162
+ return if value.blank?
163
+
164
+ Time.zone.parse(value.to_s)
165
+ rescue ArgumentError
166
+ nil
167
+ end
168
+
169
+ def truthy?(value)
170
+ ActiveModel::Type::Boolean.new.cast(value)
171
+ end
172
+ end
173
+
174
+ def display_org_identifier
175
+ hashed_org_id.presence || org_id&.to_s
176
+ end
177
+
178
+ def display_user_identifier
179
+ hashed_user_id.presence || user_id&.to_s
180
+ end
181
+
182
+ private
183
+
184
+ def apply_defaults
185
+ self.app_env = CodeSunset.configuration.environment if app_env.blank?
186
+ self.metadata = metadata.to_h if metadata.present?
187
+ self.metadata ||= {}
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,33 @@
1
+ module CodeSunset
2
+ class Feature < ApplicationRecord
3
+ self.table_name = "code_sunset_features"
4
+
5
+ STATUSES = %w[active cooling_down candidate_for_removal safe_to_remove].freeze
6
+
7
+ has_many :events, primary_key: :key, foreign_key: :feature_key, inverse_of: :feature, dependent: :nullify
8
+ has_many :daily_rollups, primary_key: :key, foreign_key: :feature_key, inverse_of: :feature, dependent: :delete_all
9
+
10
+ validates :key, presence: true, uniqueness: true
11
+ validates :status, inclusion: { in: STATUSES }, allow_blank: true
12
+
13
+ before_validation :apply_defaults
14
+
15
+ scope :ordered, -> { order(:key) }
16
+
17
+ def computed_status(last_seen_at: nil, reference_time: Time.current)
18
+ CodeSunset::StatusEngine.new(feature: self, last_seen_at: last_seen_at, reference_time: reference_time).status
19
+ end
20
+
21
+ def removal_score(snapshot = nil)
22
+ snapshot ||= CodeSunset::FeatureQuery.new({}, scope: self.class.where(id: id)).call.first
23
+ CodeSunset::ScoreCalculator.new(feature: self, snapshot: snapshot).score
24
+ end
25
+
26
+ private
27
+
28
+ def apply_defaults
29
+ self.sunset_after_days = 90 if sunset_after_days.blank?
30
+ self.remove_after_days_unused = 60 if remove_after_days_unused.blank?
31
+ end
32
+ end
33
+ end
File without changes
@@ -0,0 +1,123 @@
1
+ <section class="cs-page-header">
2
+ <div>
3
+ <h2 class="cs-page-title">Overview</h2>
4
+ <p class="cs-page-copy">Start with what is active this week, then review what looks ready to retire.</p>
5
+ </div>
6
+ <div class="cs-page-actions cs-page-actions--compact">
7
+ <%= link_to "Features", features_path, class: "cs-inline-link" %>
8
+ <%= link_to "Review Removal Queue", removal_candidates_path, class: "cs-button cs-button--secondary" %>
9
+ </div>
10
+ </section>
11
+
12
+ <section class="cs-grid">
13
+ <article class="cs-stat">
14
+ <p class="cs-stat__label">Tracked Features</p>
15
+ <p class="cs-stat__value"><%= human_count(@summaries.count) %></p>
16
+ <p class="cs-stat__hint">Registered or observed.</p>
17
+ </article>
18
+ <article class="cs-stat">
19
+ <p class="cs-stat__label">Active Features</p>
20
+ <p class="cs-stat__value"><%= human_count(@active_count) %></p>
21
+ <p class="cs-stat__hint">Still seeing traffic.</p>
22
+ </article>
23
+ <article class="cs-stat">
24
+ <p class="cs-stat__label">Removable Features</p>
25
+ <p class="cs-stat__value"><%= human_count(@removable_count) %></p>
26
+ <p class="cs-stat__hint">Candidate or safe to remove.</p>
27
+ </article>
28
+ </section>
29
+
30
+ <section class="cs-overview-layout">
31
+ <article class="cs-panel">
32
+ <div class="cs-panel__heading">
33
+ <div>
34
+ <h2>Active This Week</h2>
35
+ </div>
36
+ <span class="cs-panel__count"><%= human_count(@recent_features.count) %> features</span>
37
+ </div>
38
+ <p class="cs-panel__copy">A quick pulse on the busiest legacy paths before you drill into the full feature inventory.</p>
39
+ <div class="cs-table-wrap">
40
+ <table class="cs-table">
41
+ <thead>
42
+ <tr>
43
+ <th>Feature</th>
44
+ <th>Status</th>
45
+ <th>Hits 7d</th>
46
+ <th>Last Seen</th>
47
+ </tr>
48
+ </thead>
49
+ <tbody>
50
+ <% @recent_features.each do |summary| %>
51
+ <tr>
52
+ <td><%= link_to summary.feature.key, feature_path(summary.feature.key), class: "cs-code" %></td>
53
+ <td><%= status_badge(summary.status) %></td>
54
+ <td><%= human_count(summary.hits_7d) %></td>
55
+ <td><%= format_seen_at(summary.last_seen_at) %></td>
56
+ </tr>
57
+ <% end %>
58
+ <% if @recent_features.empty? %>
59
+ <tr>
60
+ <td colspan="4" class="cs-subtle">No recent feature usage has been recorded yet.</td>
61
+ </tr>
62
+ <% end %>
63
+ </tbody>
64
+ </table>
65
+ </div>
66
+ </article>
67
+
68
+ <div class="cs-stack">
69
+ <article class="cs-panel">
70
+ <div class="cs-panel__heading">
71
+ <div>
72
+ <h2>Ready To Review</h2>
73
+ </div>
74
+ <span class="cs-panel__count"><%= human_count(@safe_to_remove.count) %> items</span>
75
+ </div>
76
+ <p class="cs-panel__copy">Highest-confidence removals based on inactivity, low breadth, and low paid usage.</p>
77
+
78
+ <ul class="cs-queue-list">
79
+ <% @safe_to_remove.each do |summary| %>
80
+ <li class="cs-queue-list__item">
81
+ <div class="cs-queue-list__body">
82
+ <%= link_to summary.feature.key, feature_path(summary.feature.key), class: "cs-code cs-queue-list__title" %>
83
+ <p class="cs-queue-list__meta">Last seen <%= format_seen_at(summary.last_seen_at) %></p>
84
+ </div>
85
+ <div class="cs-queue-list__aside">
86
+ <%= removal_score_badge(summary.feature, summary, label: "Score #{summary.score.round(1)}") %>
87
+ </div>
88
+ </li>
89
+ <% end %>
90
+ <% if @safe_to_remove.empty? %>
91
+ <li class="cs-subtle">Nothing is confidently removable yet.</li>
92
+ <% end %>
93
+ </ul>
94
+ </article>
95
+
96
+ <article class="cs-panel">
97
+ <div class="cs-panel__heading">
98
+ <div>
99
+ <h2>Cooling Down</h2>
100
+ </div>
101
+ <span class="cs-panel__count"><%= human_count(@cooling_down.count) %> items</span>
102
+ </div>
103
+ <p class="cs-panel__copy">Features that still have some recent traffic, but are trending toward the removal queue.</p>
104
+
105
+ <ul class="cs-queue-list">
106
+ <% @cooling_down.each do |summary| %>
107
+ <li class="cs-queue-list__item">
108
+ <div class="cs-queue-list__body">
109
+ <%= link_to summary.feature.key, feature_path(summary.feature.key), class: "cs-code cs-queue-list__title" %>
110
+ <p class="cs-queue-list__meta"><%= human_count(summary.hits_30d) %> hits in the last 30 days</p>
111
+ </div>
112
+ <div class="cs-queue-list__aside">
113
+ <%= removal_score_badge(summary.feature, summary, label: "Score #{summary.score.round(1)}") %>
114
+ </div>
115
+ </li>
116
+ <% end %>
117
+ <% if @cooling_down.empty? %>
118
+ <li class="cs-subtle">No features are currently in the cooldown queue.</li>
119
+ <% end %>
120
+ </ul>
121
+ </article>
122
+ </div>
123
+ </section>
@@ -0,0 +1,88 @@
1
+ <% active_count = @summaries.count { |summary| summary.status == "active" } %>
2
+ <% removable_count = @summaries.count { |summary| %w[candidate_for_removal safe_to_remove].include?(summary.status) } %>
3
+
4
+ <section class="cs-page-header">
5
+ <div>
6
+ <h2 class="cs-page-title">Features</h2>
7
+ <p class="cs-page-copy">Scan the inventory, then narrow it with filters when you need a closer look.</p>
8
+ </div>
9
+ <div class="cs-page-actions cs-page-actions--compact">
10
+ <% if filters_applied? %>
11
+ <%= link_to "Reset Filters", features_path, class: "cs-inline-link" %>
12
+ <% end %>
13
+ </div>
14
+ </section>
15
+
16
+ <section class="cs-grid">
17
+ <article class="cs-stat">
18
+ <p class="cs-stat__label">Features</p>
19
+ <p class="cs-stat__value"><%= human_count(@summaries.count) %></p>
20
+ <p class="cs-stat__hint">Visible in the current view.</p>
21
+ </article>
22
+ <article class="cs-stat">
23
+ <p class="cs-stat__label">Active</p>
24
+ <p class="cs-stat__value"><%= human_count(active_count) %></p>
25
+ <p class="cs-stat__hint">Still seeing traffic.</p>
26
+ </article>
27
+ <article class="cs-stat">
28
+ <p class="cs-stat__label">Review Soon</p>
29
+ <p class="cs-stat__value"><%= human_count(removable_count) %></p>
30
+ <p class="cs-stat__hint">Candidate or safe to remove.</p>
31
+ </article>
32
+ </section>
33
+
34
+ <%= render_filter_bar(
35
+ form_url: features_path,
36
+ reset_url: features_path,
37
+ title: "Filters",
38
+ copy: "Environment and dates use rollups where possible. Org, user, plan, internal, and custom metadata filters use retained raw events."
39
+ ) %>
40
+
41
+ <section class="cs-panel">
42
+ <div class="cs-panel__heading">
43
+ <div>
44
+ <h2>Feature Inventory</h2>
45
+ </div>
46
+ <span class="cs-panel__count"><%= human_count(@summaries.count) %></span>
47
+ </div>
48
+ <p class="cs-panel__copy">Use this table to decide what needs inspection now, then open a feature for the full drill-down.</p>
49
+
50
+ <div class="cs-table-wrap">
51
+ <table class="cs-table cs-table--features">
52
+ <thead>
53
+ <tr>
54
+ <th>Feature</th>
55
+ <th>Status</th>
56
+ <th>Last Seen</th>
57
+ <th>Activity</th>
58
+ <th>Orgs</th>
59
+ <th>Owner</th>
60
+ <th>Score</th>
61
+ </tr>
62
+ </thead>
63
+ <tbody>
64
+ <% @summaries.each do |summary| %>
65
+ <tr>
66
+ <td><%= link_to summary.feature.key, filter_path_for(feature_path(summary.feature.key)), class: "cs-code" %></td>
67
+ <td><%= status_badge(summary.status) %></td>
68
+ <td><%= format_seen_at(summary.last_seen_at) %></td>
69
+ <td>
70
+ <div class="cs-metric-pair">
71
+ <span class="cs-metric-pair__item"><strong><%= human_count(summary.hits_7d) %></strong><span>7d</span></span>
72
+ <span class="cs-metric-pair__item"><strong><%= human_count(summary.hits_30d) %></strong><span>30d</span></span>
73
+ </div>
74
+ </td>
75
+ <td><%= human_count(summary.unique_orgs_count) %></td>
76
+ <td><%= summary.feature.owner.presence || "Unassigned" %></td>
77
+ <td><%= removal_score_badge(summary.feature, summary, label: summary.score.round(1).to_s) %></td>
78
+ </tr>
79
+ <% end %>
80
+ <% if @summaries.empty? %>
81
+ <tr>
82
+ <td colspan="7" class="cs-subtle">No features matched the current filters.</td>
83
+ </tr>
84
+ <% end %>
85
+ </tbody>
86
+ </table>
87
+ </div>
88
+ </section>