pack_stats 0.0.1

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.
@@ -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