pack_stats 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,36 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module PackStats
5
+ module Private
6
+ module Metrics
7
+ class PublicUsage
8
+ extend T::Sig
9
+
10
+ sig { params(prefix: String, packages: T::Array[ParsePackwerk::Package], package_tags: T::Array[Tag]).returns(T::Array[GaugeMetric]) }
11
+ def self.get_public_usage_metrics(prefix, packages, package_tags)
12
+ packages_except_for_root = packages.reject { |package| package.name == ParsePackwerk::ROOT_PACKAGE_NAME }
13
+ all_files = packages_except_for_root.flat_map do |package|
14
+ package.directory.glob('**/**.rb')
15
+ end
16
+
17
+ all_public_files = T.let([], T::Array[Pathname])
18
+ is_using_public_directory = 0
19
+ packages_except_for_root.each do |package|
20
+ public_files = package.directory.glob('app/public/**/**.rb')
21
+ all_public_files += public_files
22
+ is_using_public_directory += 1 if public_files.any?
23
+ end
24
+
25
+ # In Datadog, we can divide public files by all files to get the ratio.
26
+ # This is not a metric that we are targeting -- its for observability and reflection only.
27
+ [
28
+ GaugeMetric.for("#{prefix}.all_files.count", all_files.count, package_tags),
29
+ GaugeMetric.for("#{prefix}.public_files.count", all_public_files.count, package_tags),
30
+ GaugeMetric.for("#{prefix}.using_public_directory.count", is_using_public_directory, package_tags),
31
+ ]
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,98 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module PackStats
5
+ module Private
6
+ module Metrics
7
+ class RubocopUsage
8
+ extend T::Sig
9
+
10
+ sig { params(prefix: String, packages: T::Array[ParsePackwerk::Package], package_tags: T::Array[Tag]).returns(T::Array[GaugeMetric]) }
11
+ def self.get_metrics(prefix, packages, package_tags)
12
+ [
13
+ *get_rubocop_exclusions(prefix, packages, package_tags),
14
+ *get_rubocop_usage_metrics(prefix, packages, package_tags)
15
+ ]
16
+ end
17
+
18
+ sig { params(prefix: String, packages: T::Array[ParsePackwerk::Package], package_tags: T::Array[Tag]).returns(T::Array[GaugeMetric]) }
19
+ def self.get_rubocop_usage_metrics(prefix, packages, package_tags)
20
+ metrics = T.let([], T::Array[GaugeMetric])
21
+
22
+ rubocops.each do |cop_name|
23
+ ['false', 'true', 'strict'].each do |enabled_mode|
24
+ count_of_packages = ParsePackwerk.all.count do |package|
25
+ # We will likely want a rubocop-packs API for this, to be able to ask if a cop is enabled for a pack.
26
+ # It's possible we will want to allow these to be enabled at the top-level `.rubocop.yml`,
27
+ # in which case we wouldn't get the right metrics with this approach. However, we can also accept
28
+ # that as a current limitation.
29
+ rubocop_yml_file = package.directory.join(RuboCop::Packs::PACK_LEVEL_RUBOCOP_YML)
30
+ next false if !rubocop_yml_file.exist?
31
+ rubocop_yml = YAML.load_file(rubocop_yml_file)
32
+ cop_config = rubocop_yml[cop_name]
33
+
34
+ strict_mode = cop_config && cop_config['FailureMode'] == 'strict'
35
+ enabled = cop_config && cop_config['Enabled']
36
+ case enabled_mode
37
+ when 'false'
38
+ !enabled
39
+ when 'true'
40
+ enabled && !strict_mode
41
+ when 'strict'
42
+ !!strict_mode
43
+ end
44
+ end
45
+
46
+ metric_name = "#{prefix}.rubocops.#{to_tag_name(cop_name)}.#{enabled_mode}.count"
47
+ metrics << GaugeMetric.for(metric_name, count_of_packages, package_tags)
48
+ end
49
+ end
50
+
51
+ metrics
52
+ end
53
+
54
+ sig { params(prefix: String, packages: T::Array[ParsePackwerk::Package], package_tags: T::Array[Tag]).returns(T::Array[GaugeMetric]) }
55
+ def self.get_rubocop_exclusions(prefix, packages, package_tags)
56
+ rubocops.flat_map do |cop_name|
57
+ metric_name = "#{prefix}.rubocops.#{to_tag_name(cop_name)}.exclusions.count"
58
+ all_exclusions_count = ParsePackwerk.all.sum { |package| exclude_count_for_package_and_protection(package, cop_name)}
59
+ GaugeMetric.for(metric_name, all_exclusions_count, package_tags)
60
+ end
61
+ end
62
+
63
+ # TODO: `rubocop-packs` may want to expose API for this
64
+ sig { params(package: ParsePackwerk::Package, cop_name: String).returns(Integer) }
65
+ def self.exclude_count_for_package_and_protection(package, cop_name)
66
+ if package.name == ParsePackwerk::ROOT_PACKAGE_NAME
67
+ rubocop_todo = package.directory.join('.rubocop_todo.yml')
68
+ else
69
+ rubocop_todo = package.directory.join(RuboCop::Packs::PACK_LEVEL_RUBOCOP_TODO_YML)
70
+ end
71
+
72
+ if rubocop_todo.exist?
73
+ loaded_rubocop_todo = YAML.load_file(rubocop_todo)
74
+ cop_config = loaded_rubocop_todo.fetch(cop_name, {})
75
+ cop_config.fetch('Exclude', []).count
76
+ else
77
+ 0
78
+ end
79
+ end
80
+
81
+ sig { returns(T::Array[String])}
82
+ def self.rubocops
83
+ [
84
+ 'Packs/ClassMethodsAsPublicApis',
85
+ 'Packs/RootNamespaceIsPackName',
86
+ 'Packs/TypedPublicApis',
87
+ 'Packs/DocumentedPublicApis',
88
+ ]
89
+ end
90
+
91
+ sig { params(cop_name: String).returns(String) }
92
+ def self.to_tag_name(cop_name)
93
+ cop_name.gsub('/', '_').downcase
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,49 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module PackStats
5
+ module Private
6
+ module Metrics
7
+ extend T::Sig
8
+ UNKNOWN_OWNER = T.let('Unknown', String)
9
+
10
+ sig { params(team_name: T.nilable(String)).returns(T::Array[Tag]) }
11
+ def self.tags_for_team(team_name)
12
+ [Tag.for('team', team_name || UNKNOWN_OWNER)]
13
+ end
14
+
15
+ sig { params(package: ParsePackwerk::Package, app_name: String).returns(T::Array[Tag]) }
16
+ def self.tags_for_package(package, app_name)
17
+ [
18
+ Tag.new(key: 'package', value: humanized_package_name(package.name)),
19
+ Tag.new(key: 'app', value: app_name),
20
+ *Metrics.tags_for_team(CodeOwnership.for_package(package)&.name),
21
+ ]
22
+ end
23
+
24
+ sig { params(team_name: T.nilable(String)).returns(T::Array[Tag]) }
25
+ def self.tags_for_to_team(team_name)
26
+ [Tag.for('to_team', team_name || Metrics::UNKNOWN_OWNER)]
27
+ end
28
+
29
+ sig { params(name: String).returns(String) }
30
+ def self.humanized_package_name(name)
31
+ if name == ParsePackwerk::ROOT_PACKAGE_NAME
32
+ 'root'
33
+ else
34
+ name
35
+ end
36
+ end
37
+
38
+ sig { params(violations: T::Array[ParsePackwerk::Violation]).returns(Integer) }
39
+ def self.file_count(violations)
40
+ violations.sum { |v| v.files.count }
41
+ end
42
+
43
+ sig { params(package: ParsePackwerk::Package).returns(T::Boolean) }
44
+ def self.has_readme?(package)
45
+ package.directory.join('README.md').exist?
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,24 @@
1
+ # typed: strict
2
+
3
+ module PackStats
4
+ module Private
5
+ class SourceCodeFile < T::Struct
6
+ extend T::Sig
7
+
8
+ const :is_componentized_file, T::Boolean
9
+ const :is_packaged_file, T::Boolean
10
+ const :team_owner, T.nilable(CodeTeams::Team)
11
+ const :pathname, Pathname
12
+
13
+ sig { returns(T::Boolean) }
14
+ def componentized_file?
15
+ self.is_componentized_file
16
+ end
17
+
18
+ sig { returns(T::Boolean) }
19
+ def packaged_file?
20
+ self.is_packaged_file
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,54 @@
1
+ # typed: strict
2
+
3
+ module PackStats
4
+ module Private
5
+ extend T::Sig
6
+
7
+ METRIC_REPLACEMENTS = T.let({
8
+ # enforce_dependencies
9
+ 'packwerk_checkers.enforce_dependencies.false' => 'prevent_this_package_from_violating_its_stated_dependencies.no',
10
+ 'packwerk_checkers.enforce_dependencies.true' => 'prevent_this_package_from_violating_its_stated_dependencies.fail_the_build_if_new_instances_appear',
11
+ 'packwerk_checkers.enforce_dependencies.strict' => 'prevent_this_package_from_violating_its_stated_dependencies.fail_the_build_on_any_instances',
12
+ # enforce_privacy
13
+ 'packwerk_checkers.enforce_privacy.false' => 'prevent_other_packages_from_using_this_packages_internals.no',
14
+ 'packwerk_checkers.enforce_privacy.true' => 'prevent_other_packages_from_using_this_packages_internals.fail_the_build_if_new_instances_appear',
15
+ 'packwerk_checkers.enforce_privacy.strict' => 'prevent_other_packages_from_using_this_packages_internals.fail_the_build_on_any_instances',
16
+ # Packs/TypedPublicApis
17
+ 'rubocops.packs_typedpublicapis.false' => 'prevent_this_package_from_exposing_an_untyped_api.no',
18
+ 'rubocops.packs_typedpublicapis.true' => 'prevent_this_package_from_exposing_an_untyped_api.fail_the_build_if_new_instances_appear',
19
+ 'rubocops.packs_typedpublicapis.strict' => 'prevent_this_package_from_exposing_an_untyped_api.fail_the_build_on_any_instances',
20
+ 'rubocops.packs_typedpublicapis.exclusions' => 'prevent_this_package_from_exposing_an_untyped_api.rubocop_exclusions',
21
+ # Packs/RootNamespaceIsPackName
22
+ 'rubocops.packs_rootnamespaceispackname.false' => 'prevent_this_package_from_creating_other_namespaces.no',
23
+ 'rubocops.packs_rootnamespaceispackname.true' => 'prevent_this_package_from_creating_other_namespaces.fail_the_build_if_new_instances_appear',
24
+ 'rubocops.packs_rootnamespaceispackname.strict' => 'prevent_this_package_from_creating_other_namespaces.fail_the_build_on_any_instances',
25
+ 'rubocops.packs_rootnamespaceispackname.exclusions' => 'prevent_this_package_from_creating_other_namespaces.rubocop_exclusions',
26
+ # Packs/ClassMethodsAsPublicApis
27
+ 'rubocops.packs_classmethodsaspublicapis.false' => 'prevent_this_package_from_exposing_instance_method_public_apis.no',
28
+ 'rubocops.packs_classmethodsaspublicapis.true' => 'prevent_this_package_from_exposing_instance_method_public_apis.fail_the_build_if_new_instances_appear',
29
+ 'rubocops.packs_classmethodsaspublicapis.strict' => 'prevent_this_package_from_exposing_instance_method_public_apis.fail_the_build_on_any_instances',
30
+ 'rubocops.packs_classmethodsaspublicapis.exclusions' => 'prevent_this_package_from_exposing_instance_method_public_apis.rubocop_exclusions',
31
+ # Packs/DocumentedPublicApis
32
+ 'rubocops.packs_documentedpublicapis.false' => 'prevent_this_package_from_exposing_undocumented_public_apis.no',
33
+ 'rubocops.packs_documentedpublicapis.true' => 'prevent_this_package_from_exposing_undocumented_public_apis.fail_the_build_if_new_instances_appear',
34
+ 'rubocops.packs_documentedpublicapis.strict' => 'prevent_this_package_from_exposing_undocumented_public_apis.fail_the_build_on_any_instances',
35
+ 'rubocops.packs_documentedpublicapis.exclusions' => 'prevent_this_package_from_exposing_undocumented_public_apis.rubocop_exclusions',
36
+ }, T::Hash[String, String])
37
+
38
+ sig { params(metrics: T::Array[GaugeMetric]).returns(T::Array[GaugeMetric]) }
39
+ def self.convert_metrics_to_legacy(metrics)
40
+ metrics.map do |metric|
41
+ new_metric = metric
42
+ METRIC_REPLACEMENTS.each do |current_name, legacy_name|
43
+ new_metric = new_metric.with(
44
+ name: new_metric.name.gsub(current_name, legacy_name)
45
+ )
46
+ end
47
+
48
+ new_metric
49
+ end
50
+ end
51
+ end
52
+
53
+ private_constant :Private
54
+ end
@@ -0,0 +1,28 @@
1
+ # typed: strict
2
+
3
+ module PackStats
4
+ class Tag < T::Struct
5
+ extend T::Sig
6
+ const :key, String
7
+ const :value, String
8
+
9
+ sig { returns(String) }
10
+ def to_s
11
+ "#{key}:#{value}"
12
+ end
13
+
14
+ sig { params(key: String, value: String).returns(Tag) }
15
+ def self.for(key, value)
16
+ new(
17
+ key: key,
18
+ value: value
19
+ )
20
+ end
21
+
22
+ sig { params(other: Tag).returns(T::Boolean) }
23
+ def ==(other)
24
+ other.key == self.key &&
25
+ other.value == self.value
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,20 @@
1
+ # typed: strict
2
+
3
+ module PackStats
4
+ module Tags
5
+ extend T::Sig
6
+
7
+ sig { params(colon_delimited_tag_strings: T::Array[String]).returns(T::Array[Tag]) }
8
+ def self.for(colon_delimited_tag_strings)
9
+ colon_delimited_tag_strings.map do |colon_delimited_tag_string|
10
+ key, value = colon_delimited_tag_string.split(':')
11
+ raise StandardError, "Improperly formatted tag `#{colon_delimited_tag_string}`" if key.nil? || value.nil?
12
+
13
+ Tag.new(
14
+ key: key,
15
+ value: value
16
+ )
17
+ end
18
+ end
19
+ end
20
+ end
data/lib/pack_stats.rb ADDED
@@ -0,0 +1,154 @@
1
+ # typed: strict
2
+
3
+ require 'sorbet-runtime'
4
+ require 'json'
5
+ require 'yaml'
6
+ require 'benchmark'
7
+ require 'code_teams'
8
+ require 'code_ownership'
9
+ require 'pathname'
10
+ require 'pack_stats/private'
11
+ require 'pack_stats/private/source_code_file'
12
+ require 'pack_stats/private/datadog_reporter'
13
+ require 'parse_packwerk'
14
+ require 'pack_stats/tag'
15
+ require 'pack_stats/tags'
16
+ require 'pack_stats/gauge_metric'
17
+
18
+ module PackStats
19
+ extend T::Sig
20
+
21
+ ROOT_PACKAGE_NAME = T.let('root'.freeze, String)
22
+
23
+ DEFAULT_COMPONENTIZED_SOURCE_CODE_LOCATIONS = T.let(
24
+ [
25
+ Pathname.new('components'),
26
+ Pathname.new('gems'),
27
+ ].freeze, T::Array[Pathname]
28
+ )
29
+
30
+ DEFAULT_PACKAGED_SOURCE_CODE_LOCATIONS = T.let(
31
+ [
32
+ Pathname.new('packs'),
33
+ Pathname.new('packages'),
34
+ ].freeze, T::Array[Pathname]
35
+ )
36
+
37
+ sig do
38
+ params(
39
+ datadog_client: Dogapi::Client,
40
+ app_name: String,
41
+ source_code_pathnames: T::Array[Pathname],
42
+ componentized_source_code_locations: T::Array[Pathname],
43
+ packaged_source_code_locations: T::Array[Pathname],
44
+ report_time: Time,
45
+ verbose: T::Boolean,
46
+ # See note on get_metrics
47
+ use_gusto_legacy_names: T::Boolean
48
+ ).void
49
+ end
50
+ def self.report_to_datadog!(
51
+ datadog_client:,
52
+ app_name:,
53
+ source_code_pathnames:,
54
+ componentized_source_code_locations: DEFAULT_COMPONENTIZED_SOURCE_CODE_LOCATIONS,
55
+ packaged_source_code_locations: DEFAULT_PACKAGED_SOURCE_CODE_LOCATIONS,
56
+ report_time: Time.now, # rubocop:disable Rails/TimeZone
57
+ verbose: false,
58
+ use_gusto_legacy_names: false
59
+ )
60
+
61
+ all_metrics = self.get_metrics(
62
+ source_code_pathnames: source_code_pathnames,
63
+ componentized_source_code_locations: componentized_source_code_locations,
64
+ packaged_source_code_locations: packaged_source_code_locations,
65
+ app_name: app_name,
66
+ use_gusto_legacy_names: use_gusto_legacy_names,
67
+ )
68
+
69
+ # This helps us debug what metrics are being sent
70
+ if verbose
71
+ all_metrics.each do |metric|
72
+ puts "Sending metric: #{metric}"
73
+ end
74
+ end
75
+
76
+ if use_gusto_legacy_names
77
+ all_metrics = Private.convert_metrics_to_legacy(all_metrics)
78
+ end
79
+
80
+ Private::DatadogReporter.report!(
81
+ datadog_client: datadog_client,
82
+ report_time: report_time,
83
+ metrics: all_metrics
84
+ )
85
+ end
86
+
87
+ sig do
88
+ params(
89
+ source_code_pathnames: T::Array[Pathname],
90
+ componentized_source_code_locations: T::Array[Pathname],
91
+ packaged_source_code_locations: T::Array[Pathname],
92
+ app_name: String,
93
+ # It is not recommended to set this to true.
94
+ # Gusto uses this to preserve historical trends in Dashboards as the names of
95
+ # things changed, but new dashboards can use names that better match current tooling conventions.
96
+ # The behavior of setting this parameter to true might change without warning
97
+ use_gusto_legacy_names: T::Boolean
98
+ ).returns(T::Array[GaugeMetric])
99
+ end
100
+ def self.get_metrics(
101
+ source_code_pathnames:,
102
+ componentized_source_code_locations:,
103
+ packaged_source_code_locations:,
104
+ app_name:,
105
+ use_gusto_legacy_names: false
106
+ )
107
+ all_metrics = Private::DatadogReporter.get_metrics(
108
+ source_code_files: source_code_files(
109
+ source_code_pathnames: source_code_pathnames,
110
+ componentized_source_code_locations: componentized_source_code_locations,
111
+ packaged_source_code_locations: packaged_source_code_locations
112
+ ),
113
+ app_name: app_name
114
+ )
115
+
116
+ if use_gusto_legacy_names
117
+ all_metrics = Private.convert_metrics_to_legacy(all_metrics)
118
+ end
119
+
120
+ all_metrics
121
+ end
122
+
123
+ sig do
124
+ params(
125
+ source_code_pathnames: T::Array[Pathname],
126
+ componentized_source_code_locations: T::Array[Pathname],
127
+ packaged_source_code_locations: T::Array[Pathname]
128
+ ).returns(T::Array[Private::SourceCodeFile])
129
+ end
130
+ def self.source_code_files(
131
+ source_code_pathnames:,
132
+ componentized_source_code_locations:,
133
+ packaged_source_code_locations:
134
+ )
135
+
136
+ # Sorbet has the wrong signatures for `Pathname#find`, whoops!
137
+ componentized_file_set = Set.new(componentized_source_code_locations.select(&:exist?).flat_map { |pathname| T.unsafe(pathname).find.to_a })
138
+ packaged_file_set = Set.new(packaged_source_code_locations.select(&:exist?).flat_map { |pathname| T.unsafe(pathname).find.to_a })
139
+
140
+ source_code_pathnames.map do |pathname|
141
+ componentized_file = componentized_file_set.include?(pathname)
142
+ packaged_file = packaged_file_set.include?(pathname)
143
+
144
+ Private::SourceCodeFile.new(
145
+ pathname: pathname,
146
+ team_owner: CodeOwnership.for_file(pathname.to_s),
147
+ is_componentized_file: componentized_file,
148
+ is_packaged_file: packaged_file
149
+ )
150
+ end
151
+ end
152
+
153
+ private_class_method :source_code_files
154
+ end
data/sorbet/config ADDED
@@ -0,0 +1,4 @@
1
+ --dir
2
+ .
3
+ --ignore=/vendor/bundle
4
+ --enable-experimental-requires-ancestor