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,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
|