modularization_statistics 1.33.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: dfd014b44b30778ec250a807009b4803b8e4b81c99e4fcc46d70cfca8feca8bb
4
+ data.tar.gz: ba83025eaa394685d99a28eadb5a7e30893107396b1c723b055e7ab6c6e09895
5
+ SHA512:
6
+ metadata.gz: ebad79ae91d8d0731d259574f515375ed2306cb6b89458e969141f5c02ff4c88f67f926e7eec2ffc29659c5486d49f61bcbe51fcec5f4157aa277c9ad46b2c7c
7
+ data.tar.gz: 3b833b67284142468e86361797fec62761db59e9917db398179593bd2df6e4de4bb392a8ce6b999fe91f7bde496aeabba1165b3341e69ba989ac733e2f27de4c
data/README.md ADDED
@@ -0,0 +1,67 @@
1
+ # ModularizationStatistics
2
+
3
+ This gem is used to report opinionated statistics about modularization to DataDog and other observability systems.
4
+
5
+ # Usage
6
+ The main method to this gem is `ModularizationStatistics#report_to_datadog!`. Refer to the Sorbet signature for this method for the exact types to be passed in.
7
+
8
+ This is an example of how to use this API:
9
+
10
+ ```ruby
11
+ ModularizationStatistics.report_to_datadog!(
12
+ #
13
+ # A properly initialized `Dogapi::Client`
14
+ # Example: Dogapi::Client.new(ENV.fetch('DATADOG_API_KEY')
15
+ #
16
+ datadog_client: datadog_client,
17
+ #
18
+ # Time attached to the metrics
19
+ # Example: Time.now
20
+ #
21
+ report_time: report_time
22
+ #
23
+ # This is used to determine what files to look at for building statistics about what types of files are packaged, componentized, or unpackaged.
24
+ # This is an array of `Pathname`. `Pathname` can be relative or absolute paths.
25
+ #
26
+ # Example: source_code_pathnames = Pathname.glob('./**/**.rb')
27
+ #
28
+ source_code_pathnames: source_code_pathnames,
29
+ #
30
+ # A file is determined to be componentized if it exists in any of these directories.
31
+ # This is an array of `Pathname`. `Pathname` can be relative or absolute paths.
32
+ #
33
+ # Example: [Pathname.new("./gems")]
34
+ #
35
+ componentized_source_code_locations: componentized_source_code_locations,
36
+ #
37
+ # A file is determined to be packaged if it exists in any of these directories.
38
+ # This is an array of `Pathname`. `Pathname` can be relative or absolute paths.
39
+ #
40
+ # Example: [Pathname.new("./packs")]
41
+ #
42
+ packaged_source_code_locations: packaged_source_code_locations,
43
+ )
44
+ ```
45
+
46
+ # Using Other Observability Tools
47
+
48
+ Right now this tool sends metrics to DataDog early. However, if you want to use this with other tools, you can call `ModularizationStatistics.get_metrics(...)` to get generic metrics that you can then send to whatever observability provider you use.
49
+
50
+ # Setting Up Your Dashboards
51
+
52
+ Gusto has two dashboards that we've created to view these metrics. We've also exported and released the Dashboard JSON for each of these dashboards. You can create a new dashboard and then click "import dashboard JSON" to get a jump start on tracking your metrics. Note you may want to make some tweaks to these dashboards to better fit your organization's circumstances and goals.
53
+
54
+ ## [Modularization] Executive Summary
55
+
56
+ This helps answer questions like:
57
+ - How are we doing on reducing dependency and privacy violations in your monolith overall?
58
+ - How are we doing overall on adopting package protections?
59
+
60
+ [Dashboard JSON](docs/executive_summary.json)
61
+
62
+ ## [Modularization] Per-Package and Per-Team
63
+ - How is each team and package doing on reducing dependency and privacy violations in your monolith?
64
+ - What is the total count of dependency/privacy violations for each pack/team and what's the change since last month?
65
+ - Which pack/team does my pack/team have the most dependency/privacy violations on?
66
+
67
+ [Dashboard JSON](docs/per_package_and_per_team.json)
@@ -0,0 +1,39 @@
1
+ # typed: strict
2
+
3
+ module ModularizationStatistics
4
+ class GaugeMetric < T::Struct
5
+ extend T::Sig
6
+
7
+ const :name, String
8
+ const :count, Integer
9
+ const :tags, T::Array[Tag]
10
+
11
+ sig { params(metric_name: String, count: Integer, tags: T::Array[Tag]).returns(GaugeMetric) }
12
+ def self.for(metric_name, count, tags)
13
+ name = "modularization.#{metric_name}"
14
+ # https://docs.datadoghq.com/metrics/custom_metrics/#naming-custom-metrics
15
+ # Metric names must not exceed 200 characters. Fewer than 100 is preferred from a UI perspective
16
+ if name.length > 200
17
+ raise StandardError.new("Metrics names must not exceed 200 characters: #{name}") # rubocop:disable Style/RaiseArgs
18
+ end
19
+
20
+ new(
21
+ name: name,
22
+ count: count,
23
+ tags: tags
24
+ )
25
+ end
26
+
27
+ sig { returns(String) }
28
+ def to_s
29
+ "#{name} with count #{count}, with tags #{tags.map(&:to_s).join(', ')}"
30
+ end
31
+
32
+ sig { params(other: GaugeMetric).returns(T::Boolean) }
33
+ def ==(other)
34
+ other.name == self.name &&
35
+ other.count == self.count &&
36
+ other.tags == self.tags
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,325 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'dogapi'
5
+
6
+ module ModularizationStatistics
7
+ module Private
8
+ class DatadogReporter
9
+ extend T::Sig
10
+
11
+ UNKNOWN_OWNER = T.let('Unknown', String)
12
+
13
+ sig do
14
+ params(
15
+ source_code_files: T::Array[SourceCodeFile],
16
+ app_name: String
17
+ ).returns(T::Array[GaugeMetric])
18
+ end
19
+ def self.get_metrics(source_code_files:, app_name:)
20
+ all_metrics = T.let([], T::Array[GaugeMetric])
21
+ app_level_tag = Tag.for('app', app_name)
22
+
23
+ source_code_files.group_by { |file| file.team_owner&.name }.each do |team_name, files_for_team|
24
+ file_tags = tags_for_team(team_name) + [app_level_tag]
25
+ all_metrics += get_file_metrics('by_team', file_tags, files_for_team)
26
+ end
27
+
28
+ file_tags = [app_level_tag]
29
+ all_metrics += get_file_metrics('totals', file_tags, source_code_files)
30
+
31
+ packages = ParsePackwerk.all
32
+ all_metrics += get_package_metrics(packages, app_name)
33
+ all_metrics += get_package_metrics_by_team(packages, app_name)
34
+
35
+ all_metrics
36
+ end
37
+
38
+ sig do
39
+ params(
40
+ datadog_client: Dogapi::Client,
41
+ # Since `gauge` buckets data points we need to use the same time for all API calls
42
+ # to ensure they fall into the same bucket.
43
+ report_time: Time,
44
+ metrics: T::Array[GaugeMetric]
45
+ ).void
46
+ end
47
+ def self.report!(datadog_client:, report_time:, metrics:)
48
+ #
49
+ # Batching the metrics sends a post request to DD
50
+ # we want to split this up into chunks of 1000 so that as we add more metrics,
51
+ # our payload is not rejected for being too large
52
+ #
53
+ metrics.each_slice(1000).each do |metric_slice|
54
+ datadog_client.batch_metrics do
55
+ metric_slice.each do |metric|
56
+ datadog_client.emit_points(metric.name, [[report_time, metric.count]], type: 'gauge', tags: metric.tags.map(&:to_s))
57
+ end
58
+ end
59
+ end
60
+ end
61
+
62
+ sig { params(package: ParsePackwerk::Package, app_name: String).returns(T::Array[Tag]) }
63
+ def self.tags_for_package(package, app_name)
64
+ [
65
+ Tag.new(key: 'package', value: humanized_package_name(package.name)),
66
+ Tag.new(key: 'app', value: app_name),
67
+ *tags_for_team(CodeOwnership.for_package(package)&.name),
68
+ ]
69
+ end
70
+
71
+ sig { params(team_name: T.nilable(String)).returns(T::Array[Tag]) }
72
+ def self.tags_for_team(team_name)
73
+ [Tag.for('team', team_name || UNKNOWN_OWNER)]
74
+ end
75
+
76
+ sig { params(team_name: T.nilable(String)).returns(T::Array[Tag]) }
77
+ def self.tags_for_to_team(team_name)
78
+ [Tag.for('to_team', team_name || UNKNOWN_OWNER)]
79
+ end
80
+
81
+ private_class_method :tags_for_package
82
+
83
+ sig do
84
+ params(
85
+ packages: T::Array[ParsePackwerk::Package],
86
+ app_name: String
87
+ ).returns(T::Array[GaugeMetric])
88
+ end
89
+ def self.get_package_metrics(packages, app_name)
90
+ all_metrics = []
91
+ app_level_tag = Tag.for('app', app_name)
92
+ package_tags = T.let([app_level_tag], T::Array[Tag])
93
+ protected_packages = packages.map { |p| PackageProtections::ProtectedPackage.from(p) }
94
+
95
+ all_metrics << GaugeMetric.for('all_packages.count', packages.count, package_tags)
96
+ all_metrics << GaugeMetric.for('all_packages.dependencies.count', packages.sum { |package| package.dependencies.count }, package_tags)
97
+ all_metrics << GaugeMetric.for('all_packages.dependency_violations.count', protected_packages.sum { |package| file_count(package.violations.select(&:dependency?)) }, package_tags)
98
+ all_metrics << GaugeMetric.for('all_packages.privacy_violations.count', protected_packages.sum { |package| file_count(package.violations.select(&:privacy?)) }, package_tags)
99
+ all_metrics << GaugeMetric.for('all_packages.enforcing_dependencies.count', packages.count(&:enforces_dependencies?), package_tags)
100
+ all_metrics << GaugeMetric.for('all_packages.enforcing_privacy.count', packages.count(&:enforces_privacy?), package_tags)
101
+
102
+ all_metrics << GaugeMetric.for('all_packages.notify_on_package_yml_changes.count', packages.count { |p| p.metadata['notify_on_package_yml_changes'] }, package_tags)
103
+ all_metrics << GaugeMetric.for('all_packages.notify_on_new_violations.count', packages.count { |p| p.metadata['notify_on_new_violations'] }, package_tags)
104
+
105
+ all_metrics << GaugeMetric.for('all_packages.with_violations.count', protected_packages.count { |package| package.violations.any? }, package_tags)
106
+ all_metrics += self.get_public_usage_metrics('all_packages', packages, package_tags)
107
+ all_metrics << GaugeMetric.for('all_packages.has_readme.count', packages.count { |package| has_readme?(package) }, package_tags)
108
+
109
+ all_metrics += self.get_protections_metrics('all_packages', protected_packages, package_tags)
110
+ all_metrics << GaugeMetric.for('all_packages.package_based_file_ownership.count', packages.count { |package| !package.metadata['owner'].nil? }, package_tags)
111
+
112
+ inbound_violations_by_package = protected_packages.flat_map(&:violations).group_by(&:to_package_name)
113
+
114
+ protected_packages.each do |protected_package|
115
+ package = protected_package.original_package
116
+ package_tags = tags_for_package(package, app_name)
117
+
118
+ #
119
+ # VIOLATIONS (implicit dependencies)
120
+ #
121
+ outbound_violations = protected_package.violations
122
+ inbound_violations = inbound_violations_by_package[package.name] || []
123
+ all_dependency_violations = (outbound_violations + inbound_violations).select(&:dependency?)
124
+ all_privacy_violations = (outbound_violations + inbound_violations).select(&:privacy?)
125
+
126
+ all_metrics << GaugeMetric.for('by_package.dependency_violations.count', file_count(all_dependency_violations), package_tags)
127
+ all_metrics << GaugeMetric.for('by_package.privacy_violations.count', file_count(all_privacy_violations), package_tags)
128
+
129
+ all_metrics << GaugeMetric.for('by_package.outbound_dependency_violations.count', file_count(outbound_violations.select(&:dependency?)), package_tags)
130
+ all_metrics << GaugeMetric.for('by_package.inbound_dependency_violations.count', file_count(inbound_violations.select(&:dependency?)), package_tags)
131
+
132
+ all_metrics << GaugeMetric.for('by_package.outbound_privacy_violations.count', file_count(outbound_violations.select(&:privacy?)), package_tags)
133
+ all_metrics << GaugeMetric.for('by_package.inbound_privacy_violations.count', file_count(inbound_violations.select(&:privacy?)), package_tags)
134
+
135
+ all_metrics += self.get_public_usage_metrics('by_package', [package], package_tags)
136
+
137
+ protected_package.violations.group_by(&:to_package_name).each do |to_package_name, violations|
138
+ to_package = ParsePackwerk.find(to_package_name)
139
+ if to_package.nil?
140
+ raise StandardError, "Could not find matching package #{to_package_name}"
141
+ end
142
+
143
+ tags = package_tags + [Tag.for('to_package', humanized_package_name(to_package_name))] + tags_for_to_team(CodeOwnership.for_package(to_package)&.name)
144
+ all_metrics << GaugeMetric.for('by_package.outbound_dependency_violations.per_package.count', file_count(violations.select(&:dependency?)), tags)
145
+ all_metrics << GaugeMetric.for('by_package.outbound_privacy_violations.per_package.count', file_count(violations.select(&:privacy?)), tags)
146
+ end
147
+ end
148
+
149
+ inbound_explicit_dependency_by_package = {}
150
+ packages.each do |package|
151
+ package.dependencies.each do |explicit_dependency|
152
+ inbound_explicit_dependency_by_package[explicit_dependency] ||= []
153
+ inbound_explicit_dependency_by_package[explicit_dependency] << package.name
154
+ end
155
+ end
156
+
157
+ packages.each do |package| # rubocop:disable Style/CombinableLoops
158
+ package_tags = tags_for_package(package, app_name)
159
+
160
+ #
161
+ # EXPLICIT DEPENDENCIES
162
+ #
163
+ package.dependencies.each do |explicit_dependency|
164
+ to_package = ParsePackwerk.find(explicit_dependency)
165
+ if to_package.nil?
166
+ raise StandardError, "Could not find matching package #{explicit_dependency}"
167
+ end
168
+
169
+ tags = package_tags + [Tag.for('to_package', humanized_package_name(explicit_dependency))] + tags_for_to_team(CodeOwnership.for_package(to_package)&.name)
170
+ all_metrics << GaugeMetric.for('by_package.outbound_explicit_dependencies.per_package.count', 1, tags)
171
+ end
172
+
173
+ all_metrics << GaugeMetric.for('by_package.outbound_explicit_dependencies.count', package.dependencies.count, package_tags)
174
+ all_metrics << GaugeMetric.for('by_package.inbound_explicit_dependencies.count', inbound_explicit_dependency_by_package[package.name]&.count || 0, package_tags)
175
+ end
176
+
177
+ all_metrics
178
+ end
179
+
180
+ sig do
181
+ params(
182
+ all_packages: T::Array[ParsePackwerk::Package],
183
+ app_name: String
184
+ ).returns(T::Array[GaugeMetric])
185
+ end
186
+ def self.get_package_metrics_by_team(all_packages, app_name)
187
+ all_metrics = T.let([], T::Array[GaugeMetric])
188
+ app_level_tag = Tag.for('app', app_name)
189
+ all_protected_packages = all_packages.map { |p| PackageProtections::ProtectedPackage.from(p) }
190
+ all_protected_packages.group_by { |protected_package| CodeOwnership.for_package(protected_package.original_package)&.name }.each do |team_name, protected_packages_by_team|
191
+ # We look at `all_packages` because we care about ALL inbound violations across all teams
192
+ inbound_violations_by_package = all_protected_packages.flat_map(&:violations).group_by(&:to_package_name)
193
+
194
+ team_tags = tags_for_team(team_name) + [app_level_tag]
195
+ all_metrics << GaugeMetric.for('by_team.all_packages.count', protected_packages_by_team.count, team_tags)
196
+ all_metrics += self.get_protections_metrics('by_team', protected_packages_by_team, team_tags)
197
+ all_metrics += self.get_public_usage_metrics('by_team', protected_packages_by_team.map(&:original_package), team_tags)
198
+
199
+ all_metrics << GaugeMetric.for('by_team.notify_on_package_yml_changes.count', protected_packages_by_team.count { |p| p.metadata['notify_on_package_yml_changes'] }, team_tags)
200
+ all_metrics << GaugeMetric.for('by_team.notify_on_new_violations.count', protected_packages_by_team.count { |p| p.metadata['notify_on_new_violations'] }, team_tags)
201
+
202
+ #
203
+ # VIOLATIONS (implicit dependencies)
204
+ #
205
+ outbound_violations = protected_packages_by_team.flat_map(&:violations)
206
+ # Here we only look at packages_by_team because we only care about inbound violations onto packages for this team
207
+ inbound_violations = protected_packages_by_team.flat_map { |package| inbound_violations_by_package[package.name] || [] }
208
+ all_dependency_violations = (outbound_violations + inbound_violations).select(&:dependency?)
209
+ all_privacy_violations = (outbound_violations + inbound_violations).select(&:privacy?)
210
+
211
+ all_metrics << GaugeMetric.for('by_team.dependency_violations.count', file_count(all_dependency_violations), team_tags)
212
+ all_metrics << GaugeMetric.for('by_team.privacy_violations.count', file_count(all_privacy_violations), team_tags)
213
+
214
+ all_metrics << GaugeMetric.for('by_team.outbound_dependency_violations.count', file_count(outbound_violations.select(&:dependency?)), team_tags)
215
+ all_metrics << GaugeMetric.for('by_team.inbound_dependency_violations.count', file_count(inbound_violations.select(&:dependency?)), team_tags)
216
+
217
+ all_metrics << GaugeMetric.for('by_team.outbound_privacy_violations.count', file_count(outbound_violations.select(&:privacy?)), team_tags)
218
+ all_metrics << GaugeMetric.for('by_team.inbound_privacy_violations.count', file_count(inbound_violations.select(&:privacy?)), team_tags)
219
+
220
+ all_metrics << GaugeMetric.for('by_team.has_readme.count', protected_packages_by_team.count { |protected_package| has_readme?(protected_package.original_package) }, team_tags)
221
+
222
+ grouped_outbound_violations = outbound_violations.group_by do |violation|
223
+ to_package = ParsePackwerk.find(violation.to_package_name)
224
+ if to_package.nil?
225
+ raise StandardError, "Could not find matching package #{violation.to_package_name}"
226
+ end
227
+
228
+ CodeOwnership.for_package(to_package)&.name
229
+ end
230
+
231
+ grouped_outbound_violations.each do |to_team_name, violations|
232
+ tags = team_tags + tags_for_to_team(to_team_name)
233
+ all_metrics << GaugeMetric.for('by_team.outbound_dependency_violations.per_team.count', file_count(violations.select(&:dependency?)), tags)
234
+ all_metrics << GaugeMetric.for('by_team.outbound_privacy_violations.per_team.count', file_count(violations.select(&:privacy?)), tags)
235
+ end
236
+ end
237
+
238
+ all_metrics
239
+ end
240
+
241
+ private_class_method :get_package_metrics
242
+
243
+ sig do
244
+ params(
245
+ metric_name_suffix: String,
246
+ tags: T::Array[Tag],
247
+ files: T::Array[SourceCodeFile]
248
+ ).returns(T::Array[GaugeMetric])
249
+ end
250
+ def self.get_file_metrics(metric_name_suffix, tags, files)
251
+ [
252
+ GaugeMetric.for("component_files.#{metric_name_suffix}", files.count(&:componentized_file?), tags),
253
+ GaugeMetric.for("packaged_files.#{metric_name_suffix}", files.count(&:packaged_file?), tags),
254
+ GaugeMetric.for("all_files.#{metric_name_suffix}", files.count, tags),
255
+ ]
256
+ end
257
+
258
+ private_class_method :get_file_metrics
259
+
260
+ sig { params(prefix: String, protected_packages: T::Array[PackageProtections::ProtectedPackage], package_tags: T::Array[Tag]).returns(T::Array[GaugeMetric]) }
261
+ def self.get_protections_metrics(prefix, protected_packages, package_tags)
262
+ PackageProtections.all.flat_map do |protection|
263
+ PackageProtections::ViolationBehavior.each_value.map do |violation_behavior|
264
+ # https://github.com/Gusto/package_protections/pull/42 changed the public API of these violation behaviors.
265
+ # To preserve our ability to understand historical trends, we map to the old values.
266
+ # This allows our dashboards to continue to operate as expected.
267
+ # Note if we ever open source mod stats, we should probably inject this behavior so that new clients can see the new keys in their metrics.
268
+ violation_behavior_map = {
269
+ PackageProtections::ViolationBehavior::FailOnAny => 'fail_the_build_on_any_instances',
270
+ PackageProtections::ViolationBehavior::FailNever => 'no',
271
+ PackageProtections::ViolationBehavior::FailOnNew => 'fail_the_build_if_new_instances_appear',
272
+ }
273
+ violation_behavior_name = violation_behavior_map[violation_behavior]
274
+ metric_name = "#{prefix}.#{protection.identifier}.#{violation_behavior_name}.count"
275
+ count_of_packages = protected_packages.count { |p| p.violation_behavior_for(protection.identifier) == violation_behavior }
276
+ GaugeMetric.for(metric_name, count_of_packages, package_tags)
277
+ end
278
+ end
279
+ end
280
+
281
+ sig { params(prefix: String, packages: T::Array[ParsePackwerk::Package], package_tags: T::Array[Tag]).returns(T::Array[GaugeMetric]) }
282
+ def self.get_public_usage_metrics(prefix, packages, package_tags)
283
+ packages_except_for_root = packages.reject { |package| package.name == ParsePackwerk::ROOT_PACKAGE_NAME }
284
+ all_files = packages_except_for_root.flat_map do |package|
285
+ package.directory.glob('**/**.rb')
286
+ end
287
+
288
+ all_public_files = T.let([], T::Array[Pathname])
289
+ is_using_public_directory = 0
290
+ packages_except_for_root.each do |package|
291
+ public_files = package.directory.glob('app/public/**/**.rb')
292
+ all_public_files += public_files
293
+ is_using_public_directory += 1 if public_files.any?
294
+ end
295
+
296
+ # In Datadog, can divide public files by all files to get the ratio.
297
+ # This is not a metric that we are targeting -- its for observability and reflection only.
298
+ [
299
+ GaugeMetric.for("#{prefix}.all_files.count", all_files.count, package_tags),
300
+ GaugeMetric.for("#{prefix}.public_files.count", all_public_files.count, package_tags),
301
+ GaugeMetric.for("#{prefix}.using_public_directory.count", is_using_public_directory, package_tags),
302
+ ]
303
+ end
304
+
305
+ sig { params(package: ParsePackwerk::Package).returns(T::Boolean) }
306
+ def self.has_readme?(package)
307
+ package.directory.join('README.md').exist?
308
+ end
309
+
310
+ sig { params(violations: T::Array[ParsePackwerk::Violation]).returns(Integer) }
311
+ def self.file_count(violations)
312
+ violations.sum { |v| v.files.count }
313
+ end
314
+
315
+ sig { params(name: String).returns(String) }
316
+ def self.humanized_package_name(name)
317
+ if name == ParsePackwerk::ROOT_PACKAGE_NAME
318
+ 'root'
319
+ else
320
+ name
321
+ end
322
+ end
323
+ end
324
+ end
325
+ end
@@ -0,0 +1,24 @@
1
+ # typed: strict
2
+
3
+ module ModularizationStatistics
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(Teams::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,8 @@
1
+ # typed: strict
2
+
3
+ module ModularizationStatistics
4
+ module Private
5
+ end
6
+
7
+ private_constant :Private
8
+ end
@@ -0,0 +1,28 @@
1
+ # typed: strict
2
+
3
+ module ModularizationStatistics
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 ModularizationStatistics
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
@@ -0,0 +1,134 @@
1
+ # typed: strict
2
+
3
+ require 'sorbet-runtime'
4
+ require 'json'
5
+ require 'yaml'
6
+ require 'benchmark'
7
+ require 'teams'
8
+ require 'code_ownership'
9
+ require 'pathname'
10
+ require 'modularization_statistics/private/source_code_file'
11
+ require 'modularization_statistics/private/datadog_reporter'
12
+ require 'parse_packwerk'
13
+ require 'package_protections'
14
+ require 'modularization_statistics/tag'
15
+ require 'modularization_statistics/tags'
16
+ require 'modularization_statistics/gauge_metric'
17
+
18
+ module ModularizationStatistics
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
+ ).void
47
+ end
48
+ def self.report_to_datadog!(
49
+ datadog_client:,
50
+ app_name:,
51
+ source_code_pathnames:,
52
+ componentized_source_code_locations: DEFAULT_COMPONENTIZED_SOURCE_CODE_LOCATIONS,
53
+ packaged_source_code_locations: DEFAULT_PACKAGED_SOURCE_CODE_LOCATIONS,
54
+ report_time: Time.now, # rubocop:disable Rails/TimeZone
55
+ verbose: false
56
+ )
57
+
58
+ all_metrics = self.get_metrics(
59
+ source_code_pathnames: source_code_pathnames,
60
+ componentized_source_code_locations: componentized_source_code_locations,
61
+ packaged_source_code_locations: packaged_source_code_locations,
62
+ app_name: app_name
63
+ )
64
+
65
+ # This helps us debug what metrics are being sent
66
+ if verbose
67
+ all_metrics.each do |metric|
68
+ puts "Sending metric: #{metric}"
69
+ end
70
+ end
71
+
72
+ Private::DatadogReporter.report!(
73
+ datadog_client: datadog_client,
74
+ report_time: report_time,
75
+ metrics: all_metrics
76
+ )
77
+ end
78
+
79
+ sig do
80
+ params(
81
+ source_code_pathnames: T::Array[Pathname],
82
+ componentized_source_code_locations: T::Array[Pathname],
83
+ packaged_source_code_locations: T::Array[Pathname],
84
+ app_name: String
85
+ ).returns(T::Array[GaugeMetric])
86
+ end
87
+ def self.get_metrics(
88
+ source_code_pathnames:,
89
+ componentized_source_code_locations:,
90
+ packaged_source_code_locations:,
91
+ app_name:
92
+ )
93
+ Private::DatadogReporter.get_metrics(
94
+ source_code_files: source_code_files(
95
+ source_code_pathnames: source_code_pathnames,
96
+ componentized_source_code_locations: componentized_source_code_locations,
97
+ packaged_source_code_locations: packaged_source_code_locations
98
+ ),
99
+ app_name: app_name
100
+ )
101
+ end
102
+
103
+ sig do
104
+ params(
105
+ source_code_pathnames: T::Array[Pathname],
106
+ componentized_source_code_locations: T::Array[Pathname],
107
+ packaged_source_code_locations: T::Array[Pathname]
108
+ ).returns(T::Array[Private::SourceCodeFile])
109
+ end
110
+ def self.source_code_files(
111
+ source_code_pathnames:,
112
+ componentized_source_code_locations:,
113
+ packaged_source_code_locations:
114
+ )
115
+
116
+ # Sorbet has the wrong signatures for `Pathname#find`, whoops!
117
+ componentized_file_set = Set.new(componentized_source_code_locations.select(&:exist?).flat_map { |pathname| T.unsafe(pathname).find.to_a })
118
+ packaged_file_set = Set.new(packaged_source_code_locations.select(&:exist?).flat_map { |pathname| T.unsafe(pathname).find.to_a })
119
+
120
+ source_code_pathnames.map do |pathname|
121
+ componentized_file = componentized_file_set.include?(pathname)
122
+ packaged_file = packaged_file_set.include?(pathname)
123
+
124
+ Private::SourceCodeFile.new(
125
+ pathname: pathname,
126
+ team_owner: CodeOwnership.for_file(pathname.to_s),
127
+ is_componentized_file: componentized_file,
128
+ is_packaged_file: packaged_file
129
+ )
130
+ end
131
+ end
132
+
133
+ private_class_method :source_code_files
134
+ end
data/sorbet/config ADDED
@@ -0,0 +1,3 @@
1
+ --dir
2
+ .
3
+ --ignore=/vendor/bundle