modularization_statistics 1.33.0

Sign up to get free protection for your applications and to get access to all the features.
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