modularization_statistics 1.33.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/README.md +67 -0
- data/lib/modularization_statistics/gauge_metric.rb +39 -0
- data/lib/modularization_statistics/private/datadog_reporter.rb +325 -0
- data/lib/modularization_statistics/private/source_code_file.rb +24 -0
- data/lib/modularization_statistics/private.rb +8 -0
- data/lib/modularization_statistics/tag.rb +28 -0
- data/lib/modularization_statistics/tags.rb +20 -0
- data/lib/modularization_statistics.rb +134 -0
- data/sorbet/config +3 -0
- data/sorbet/rbi/gems/bigrails-teams@0.1.0.rbi +120 -0
- data/sorbet/rbi/gems/code_ownership@1.23.0.rbi +336 -0
- data/sorbet/rbi/gems/dogapi@1.45.0.rbi +551 -0
- data/sorbet/rbi/gems/manual.rbi +4 -0
- data/sorbet/rbi/gems/package_protections@0.64.0.rbi +632 -0
- data/sorbet/rbi/gems/parse_packwerk@0.10.0.rbi +140 -0
- data/sorbet/rbi/gems/rspec@3.10.0.rbi +13 -0
- data/sorbet/rbi/todo.rbi +5 -0
- metadata +220 -0
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,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