analytics_ops 0.1.0 → 0.2.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 +4 -4
- data/CHANGELOG.md +76 -0
- data/CONTRIBUTING.md +2 -1
- data/README.md +232 -41
- data/SECURITY.md +8 -8
- data/docs/api-support-matrix.md +9 -8
- data/docs/architecture.md +22 -4
- data/docs/authentication.md +112 -51
- data/docs/commands.md +71 -17
- data/docs/configuration-schema-v1.json +53 -7
- data/docs/configuration.md +33 -9
- data/docs/google-client-compatibility.md +10 -6
- data/docs/live-smoke-test.md +159 -0
- data/docs/plan-format.md +4 -0
- data/docs/plan-schema-v1.json +113 -22
- data/docs/rails.md +12 -3
- data/docs/reports.md +44 -6
- data/docs/safety.md +18 -6
- data/docs/troubleshooting.md +42 -13
- data/lib/analytics_ops/applier.rb +2 -1
- data/lib/analytics_ops/cli/options.rb +157 -0
- data/lib/analytics_ops/cli/presenter.rb +221 -0
- data/lib/analytics_ops/cli.rb +172 -201
- data/lib/analytics_ops/clients/admin.rb +130 -129
- data/lib/analytics_ops/clients/admin_normalization.rb +95 -0
- data/lib/analytics_ops/clients/data.rb +120 -63
- data/lib/analytics_ops/clients/error_translation.rb +45 -0
- data/lib/analytics_ops/configuration/document.rb +1 -1
- data/lib/analytics_ops/configuration/loader.rb +38 -5
- data/lib/analytics_ops/configuration/schema.rb +51 -8
- data/lib/analytics_ops/configuration/validator.rb +43 -8
- data/lib/analytics_ops/configuration/writer.rb +105 -0
- data/lib/analytics_ops/configuration.rb +1 -0
- data/lib/analytics_ops/connection.rb +93 -0
- data/lib/analytics_ops/desired_state.rb +2 -2
- data/lib/analytics_ops/plan.rb +94 -33
- data/lib/analytics_ops/planner.rb +10 -2
- data/lib/analytics_ops/rails/railtie.rb +13 -18
- data/lib/analytics_ops/redaction.rb +14 -9
- data/lib/analytics_ops/reports/catalog.rb +22 -5
- data/lib/analytics_ops/reports/definition.rb +74 -37
- data/lib/analytics_ops/reports/overview.rb +55 -0
- data/lib/analytics_ops/reports/overview_result.rb +54 -0
- data/lib/analytics_ops/reports/result.rb +38 -9
- data/lib/analytics_ops/reports.rb +2 -0
- data/lib/analytics_ops/resources.rb +2 -1
- data/lib/analytics_ops/service_account.rb +171 -0
- data/lib/analytics_ops/setup.rb +171 -0
- data/lib/analytics_ops/snapshot.rb +20 -5
- data/lib/analytics_ops/version.rb +1 -1
- data/lib/analytics_ops/workspace.rb +41 -12
- data/lib/analytics_ops.rb +4 -0
- data/lib/generators/analytics_ops/install_generator.rb +2 -0
- data/lib/generators/analytics_ops/templates/analytics_ops.yml +2 -15
- data/sig/analytics_ops.rbs +117 -10
- metadata +26 -1
|
@@ -10,10 +10,12 @@ module AnalyticsOps
|
|
|
10
10
|
API_NAME = /\A[a-z][a-zA-Z0-9_]*(?::[a-zA-Z][a-zA-Z0-9_]*)?\z/
|
|
11
11
|
REPORT_NAME = /\A[a-z][a-z0-9_]{0,63}\z/
|
|
12
12
|
RANGE_NAME = /\A[a-z][a-z0-9_]{0,39}\z/
|
|
13
|
+
ABSOLUTE_DATE = /\A\d{4}-\d{2}-\d{2}\z/
|
|
13
14
|
RELATIVE_DATE = /\A(\d{1,4})daysAgo\z/
|
|
14
15
|
MATCH_TYPES = %w[exact begins_with ends_with contains full_regexp partial_regexp].freeze
|
|
15
16
|
NUMERIC_OPERATIONS = %w[equal less_than less_than_or_equal greater_than greater_than_or_equal].freeze
|
|
16
17
|
MAX_LIMIT = 100_000
|
|
18
|
+
INT64_RANGE = (-(2**63))..((2**63) - 1)
|
|
17
19
|
|
|
18
20
|
attr_reader :name, :kind, :dimensions, :metrics, :date_ranges,
|
|
19
21
|
:dimension_filter, :metric_filter, :order_bys, :offset, :limit
|
|
@@ -22,8 +24,8 @@ module AnalyticsOps
|
|
|
22
24
|
metric_filter: nil, order_bys: [], offset: 0, limit: 100)
|
|
23
25
|
@name = validate_name(name)
|
|
24
26
|
@kind = validate_kind(kind)
|
|
25
|
-
@dimensions = api_names(dimensions, "dimensions", maximum: realtime_kind?(kind) ? 4 : 9)
|
|
26
|
-
@metrics = api_names(metrics, "metrics", maximum: 10)
|
|
27
|
+
@dimensions = api_names(dimensions, "dimensions", minimum: 0, maximum: realtime_kind?(kind) ? 4 : 9)
|
|
28
|
+
@metrics = api_names(metrics, "metrics", minimum: 1, maximum: 10)
|
|
27
29
|
@date_ranges = ranges(date_ranges)
|
|
28
30
|
@dimension_filter = string_filter(dimension_filter)
|
|
29
31
|
@metric_filter = numeric_filter(metric_filter)
|
|
@@ -71,10 +73,11 @@ module AnalyticsOps
|
|
|
71
73
|
value == "realtime"
|
|
72
74
|
end
|
|
73
75
|
|
|
74
|
-
def api_names(values, label, maximum:)
|
|
75
|
-
unless values.is_a?(Array) && values.
|
|
76
|
+
def api_names(values, label, minimum:, maximum:)
|
|
77
|
+
unless values.is_a?(Array) && values.length.between?(minimum, maximum) &&
|
|
76
78
|
values.all? { |value| valid_api_name?(value) }
|
|
77
|
-
raise InvalidRequestError,
|
|
79
|
+
raise InvalidRequestError,
|
|
80
|
+
"Report #{label} must contain #{minimum} to #{maximum} valid Data API names"
|
|
78
81
|
end
|
|
79
82
|
unless values.uniq.length == values.length
|
|
80
83
|
raise InvalidRequestError,
|
|
@@ -92,23 +95,28 @@ module AnalyticsOps
|
|
|
92
95
|
raise InvalidRequestError, "date_ranges must be an array" unless values.is_a?(Array)
|
|
93
96
|
raise InvalidRequestError, "A report can contain at most four date ranges" if values.length > 4
|
|
94
97
|
|
|
95
|
-
normalized = values.map
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
validate_date_order!(start_date, end_date)
|
|
100
|
-
|
|
101
|
-
result = { "start_date" => start_date, "end_date" => end_date }
|
|
102
|
-
if hash.key?("name")
|
|
103
|
-
range_name = hash.fetch("name")
|
|
104
|
-
invalid!("Date range name is invalid") unless range_name.is_a?(String) && RANGE_NAME.match?(range_name)
|
|
105
|
-
result["name"] = range_name
|
|
106
|
-
end
|
|
107
|
-
result
|
|
108
|
-
end
|
|
98
|
+
normalized = values.map { |range| normalize_range(range) }
|
|
99
|
+
names = normalized.filter_map { |range| range["name"] }
|
|
100
|
+
invalid!("Date range names must be unique") unless names.uniq.length == names.length
|
|
101
|
+
|
|
109
102
|
Canonical.immutable(normalized)
|
|
110
103
|
end
|
|
111
104
|
|
|
105
|
+
def normalize_range(range)
|
|
106
|
+
hash = string_hash(range, "date range", %w[start_date end_date name])
|
|
107
|
+
start_date = date(hash.fetch("start_date") { invalid!("Missing start_date") })
|
|
108
|
+
end_date = date(hash.fetch("end_date") { invalid!("Missing end_date") })
|
|
109
|
+
validate_date_order!(start_date, end_date)
|
|
110
|
+
result = { "start_date" => start_date, "end_date" => end_date }
|
|
111
|
+
return result unless hash.key?("name")
|
|
112
|
+
|
|
113
|
+
range_name = hash.fetch("name")
|
|
114
|
+
valid = range_name.is_a?(String) && RANGE_NAME.match?(range_name) &&
|
|
115
|
+
!range_name.start_with?("date_range_")
|
|
116
|
+
invalid!("Date range name is invalid or reserved") unless valid
|
|
117
|
+
result.merge("name" => range_name)
|
|
118
|
+
end
|
|
119
|
+
|
|
112
120
|
def date(value)
|
|
113
121
|
invalid!("Invalid report date") unless value.is_a?(String)
|
|
114
122
|
return value if %w[today yesterday].include?(value)
|
|
@@ -116,6 +124,8 @@ module AnalyticsOps
|
|
|
116
124
|
relative = RELATIVE_DATE.match(value)
|
|
117
125
|
return value if relative && relative[1].to_i <= 3650
|
|
118
126
|
|
|
127
|
+
invalid!("Invalid report date") unless ABSOLUTE_DATE.match?(value)
|
|
128
|
+
|
|
119
129
|
Date.iso8601(value)
|
|
120
130
|
value
|
|
121
131
|
rescue Date::Error
|
|
@@ -123,12 +133,25 @@ module AnalyticsOps
|
|
|
123
133
|
end
|
|
124
134
|
|
|
125
135
|
def validate_date_order!(start_date, end_date)
|
|
126
|
-
|
|
127
|
-
|
|
136
|
+
if ABSOLUTE_DATE.match?(start_date) && ABSOLUTE_DATE.match?(end_date)
|
|
137
|
+
return if Date.iso8601(start_date) <= Date.iso8601(end_date)
|
|
138
|
+
else
|
|
139
|
+
start_offset = relative_date_offset(start_date)
|
|
140
|
+
end_offset = relative_date_offset(end_date)
|
|
141
|
+
return unless start_offset && end_offset
|
|
142
|
+
return if start_offset >= end_offset
|
|
143
|
+
end
|
|
128
144
|
|
|
129
145
|
invalid!("Report start_date must not be after end_date")
|
|
130
146
|
end
|
|
131
147
|
|
|
148
|
+
def relative_date_offset(value)
|
|
149
|
+
return 0 if value == "today"
|
|
150
|
+
return 1 if value == "yesterday"
|
|
151
|
+
|
|
152
|
+
RELATIVE_DATE.match(value)&.[](1)&.to_i
|
|
153
|
+
end
|
|
154
|
+
|
|
132
155
|
def string_filter(value)
|
|
133
156
|
return nil if value.nil?
|
|
134
157
|
|
|
@@ -157,35 +180,49 @@ module AnalyticsOps
|
|
|
157
180
|
field = hash.fetch("field") { invalid!("Missing metric filter field") }
|
|
158
181
|
operation = hash.fetch("operation") { invalid!("Missing metric filter operation") }
|
|
159
182
|
number = hash.fetch("value") { invalid!("Missing metric filter value") }
|
|
160
|
-
valid = valid_api_name?(field) && metrics.include?(field) &&
|
|
161
|
-
NUMERIC_OPERATIONS.include?(operation) && number.is_a?(Numeric)
|
|
183
|
+
valid = valid_api_name?(field) && metrics.include?(field) && NUMERIC_OPERATIONS.include?(operation)
|
|
162
184
|
invalid!("Metric filter is invalid or references an unselected metric") unless valid
|
|
163
|
-
|
|
185
|
+
unless valid_numeric_filter_value?(number)
|
|
186
|
+
invalid!("Metric filter value must be a finite int64 Integer or Float")
|
|
187
|
+
end
|
|
164
188
|
|
|
165
189
|
Canonical.immutable("field" => field, "operation" => operation, "value" => number)
|
|
166
190
|
end
|
|
167
191
|
|
|
192
|
+
def valid_numeric_filter_value?(number)
|
|
193
|
+
(number.is_a?(Integer) && INT64_RANGE.cover?(number)) || (number.is_a?(Float) && number.finite?)
|
|
194
|
+
end
|
|
195
|
+
|
|
168
196
|
def orders(values)
|
|
169
197
|
raise InvalidRequestError, "order_bys must be an array" unless values.is_a?(Array)
|
|
170
198
|
raise InvalidRequestError, "A report can contain at most ten order clauses" if values.length > 10
|
|
171
199
|
|
|
172
|
-
normalized = values.map
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
invalid!("Report order requires exactly one metric or dimension") unless selectors.length == 1
|
|
176
|
-
|
|
177
|
-
selector = selectors.first
|
|
178
|
-
api_name = hash.fetch(selector)
|
|
179
|
-
selected = selector == "metric" ? metrics : dimensions
|
|
180
|
-
unless valid_api_name?(api_name) && selected.include?(api_name)
|
|
181
|
-
invalid!("Report order references an unselected #{selector}")
|
|
182
|
-
end
|
|
200
|
+
normalized = values.map { |order| normalize_order(order) }
|
|
201
|
+
identities = normalized.map { |order| order_identity(order) }
|
|
202
|
+
invalid!("Report order clauses must reference unique fields") unless identities.uniq.length == identities.length
|
|
183
203
|
|
|
184
|
-
{ selector => api_name, "desc" => hash.key?("desc") ? boolean(hash.fetch("desc"), "desc") : false }
|
|
185
|
-
end
|
|
186
204
|
Canonical.immutable(normalized)
|
|
187
205
|
end
|
|
188
206
|
|
|
207
|
+
def normalize_order(order)
|
|
208
|
+
hash = string_hash(order, "report order", %w[metric dimension desc])
|
|
209
|
+
selectors = %w[metric dimension].select { |key| hash.key?(key) }
|
|
210
|
+
invalid!("Report order requires exactly one metric or dimension") unless selectors.length == 1
|
|
211
|
+
|
|
212
|
+
selector = selectors.first
|
|
213
|
+
api_name = hash.fetch(selector)
|
|
214
|
+
selected = selector == "metric" ? metrics : dimensions
|
|
215
|
+
valid = valid_api_name?(api_name) && selected.include?(api_name)
|
|
216
|
+
invalid!("Report order references an unselected #{selector}") unless valid
|
|
217
|
+
|
|
218
|
+
{ selector => api_name, "desc" => hash.key?("desc") ? boolean(hash.fetch("desc"), "desc") : false }
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def order_identity(order)
|
|
222
|
+
selector = order.key?("metric") ? "metric" : "dimension"
|
|
223
|
+
[selector, order.fetch(selector)]
|
|
224
|
+
end
|
|
225
|
+
|
|
189
226
|
def validate_shape!
|
|
190
227
|
invalid!("Realtime reports cannot include date ranges") if realtime? && !date_ranges.empty?
|
|
191
228
|
invalid!("Realtime reports do not support offset") if realtime? && offset.positive?
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AnalyticsOps
|
|
4
|
+
module Reports
|
|
5
|
+
# Internal catalog for the small reports shown by Workspace#overview.
|
|
6
|
+
module Overview
|
|
7
|
+
DEFINITIONS = [
|
|
8
|
+
Definition.new(
|
|
9
|
+
name: "overview_totals",
|
|
10
|
+
kind: "standard",
|
|
11
|
+
dimensions: [],
|
|
12
|
+
metrics: %w[activeUsers sessions keyEvents],
|
|
13
|
+
date_ranges: Catalog::STANDARD_DATE_RANGE,
|
|
14
|
+
limit: 1
|
|
15
|
+
),
|
|
16
|
+
Definition.new(
|
|
17
|
+
name: "overview_trend",
|
|
18
|
+
kind: "standard",
|
|
19
|
+
dimensions: %w[date],
|
|
20
|
+
metrics: %w[activeUsers sessions keyEvents],
|
|
21
|
+
date_ranges: Catalog::STANDARD_DATE_RANGE,
|
|
22
|
+
order_bys: [{ "dimension" => "date", "desc" => false }],
|
|
23
|
+
limit: 31
|
|
24
|
+
),
|
|
25
|
+
Definition.new(
|
|
26
|
+
name: "overview_acquisition",
|
|
27
|
+
kind: "standard",
|
|
28
|
+
dimensions: %w[sessionDefaultChannelGroup],
|
|
29
|
+
metrics: %w[sessions activeUsers keyEvents],
|
|
30
|
+
date_ranges: Catalog::STANDARD_DATE_RANGE,
|
|
31
|
+
order_bys: [{ "metric" => "sessions", "desc" => true }],
|
|
32
|
+
limit: 10
|
|
33
|
+
),
|
|
34
|
+
Definition.new(
|
|
35
|
+
name: "overview_landing_pages",
|
|
36
|
+
kind: "standard",
|
|
37
|
+
dimensions: %w[landingPagePlusQueryString],
|
|
38
|
+
metrics: %w[sessions activeUsers keyEvents],
|
|
39
|
+
date_ranges: Catalog::STANDARD_DATE_RANGE,
|
|
40
|
+
order_bys: [{ "metric" => "sessions", "desc" => true }],
|
|
41
|
+
limit: 10
|
|
42
|
+
),
|
|
43
|
+
Definition.new(
|
|
44
|
+
name: "overview_devices",
|
|
45
|
+
kind: "standard",
|
|
46
|
+
dimensions: %w[deviceCategory],
|
|
47
|
+
metrics: %w[activeUsers sessions keyEvents],
|
|
48
|
+
date_ranges: Catalog::STANDARD_DATE_RANGE,
|
|
49
|
+
order_bys: [{ "metric" => "activeUsers", "desc" => true }],
|
|
50
|
+
limit: 10
|
|
51
|
+
)
|
|
52
|
+
].freeze
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AnalyticsOps
|
|
4
|
+
module Reports
|
|
5
|
+
# Immutable collection of the bounded reports that make up an overview.
|
|
6
|
+
class OverviewResult
|
|
7
|
+
MAX_REPORTS = 5
|
|
8
|
+
PROPERTY_ID = /\A\d{1,50}\z/
|
|
9
|
+
|
|
10
|
+
attr_reader :property_id, :reports, :property_quota
|
|
11
|
+
|
|
12
|
+
def initialize(property_id:, reports:)
|
|
13
|
+
unless property_id.is_a?(String) && PROPERTY_ID.match?(property_id)
|
|
14
|
+
raise RemoteError, "Overview property ID is invalid"
|
|
15
|
+
end
|
|
16
|
+
unless reports.is_a?(Array) && reports.length.between?(1, MAX_REPORTS) &&
|
|
17
|
+
reports.all? { |report| report.is_a?(Result) && report.kind == "standard" }
|
|
18
|
+
raise RemoteError, "Overview must contain 1 to #{MAX_REPORTS} standard report results"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
names = reports.map(&:name)
|
|
22
|
+
raise RemoteError, "Overview report names must be unique" unless names.uniq.length == names.length
|
|
23
|
+
|
|
24
|
+
@property_id = property_id.dup.freeze
|
|
25
|
+
@reports = reports.dup.freeze
|
|
26
|
+
@property_quota = Canonical.immutable(latest_quota)
|
|
27
|
+
freeze
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def report(name)
|
|
31
|
+
reports.find { |result| result.name == name.to_s } ||
|
|
32
|
+
raise(KeyError, "Unknown overview report #{name.inspect}")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def to_h
|
|
36
|
+
{
|
|
37
|
+
"property_id" => property_id,
|
|
38
|
+
"reports" => reports.map(&:to_h),
|
|
39
|
+
"property_quota" => property_quota
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def latest_quota
|
|
46
|
+
reports.reverse_each do |result|
|
|
47
|
+
quota = result.metadata["property_quota"]
|
|
48
|
+
return quota if quota
|
|
49
|
+
end
|
|
50
|
+
{}
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -4,24 +4,21 @@ module AnalyticsOps
|
|
|
4
4
|
module Reports
|
|
5
5
|
# Immutable normalized Data API response.
|
|
6
6
|
class Result
|
|
7
|
-
attr_reader :name, :kind, :dimension_headers, :metric_headers, :rows, :row_count, :metadata
|
|
7
|
+
attr_reader :name, :kind, :dimension_headers, :metric_headers, :headers, :rows, :row_count, :metadata
|
|
8
8
|
|
|
9
9
|
def initialize(name:, kind:, dimension_headers:, metric_headers:, rows:, row_count:, metadata:)
|
|
10
10
|
@name = string(name, "name")
|
|
11
11
|
@kind = kind_value(kind)
|
|
12
12
|
@dimension_headers = normalize_headers(dimension_headers, "dimension_headers")
|
|
13
13
|
@metric_headers = normalize_headers(metric_headers, "metric_headers")
|
|
14
|
+
@headers = Canonical.immutable(@dimension_headers + @metric_headers)
|
|
14
15
|
validate_header_uniqueness!
|
|
15
16
|
@rows = normalized_rows(rows)
|
|
16
17
|
@row_count = count(row_count)
|
|
17
|
-
@metadata =
|
|
18
|
+
@metadata = normalized_metadata(metadata)
|
|
18
19
|
freeze
|
|
19
20
|
end
|
|
20
21
|
|
|
21
|
-
def headers
|
|
22
|
-
dimension_headers + metric_headers
|
|
23
|
-
end
|
|
24
|
-
|
|
25
22
|
def to_h
|
|
26
23
|
{
|
|
27
24
|
"name" => name,
|
|
@@ -37,7 +34,7 @@ module AnalyticsOps
|
|
|
37
34
|
private
|
|
38
35
|
|
|
39
36
|
def string(value, label)
|
|
40
|
-
return value.dup.freeze if
|
|
37
|
+
return value.dup.freeze if valid_utf8?(value) && !value.empty?
|
|
41
38
|
|
|
42
39
|
raise RemoteError, "Report result #{label} is invalid"
|
|
43
40
|
end
|
|
@@ -49,9 +46,10 @@ module AnalyticsOps
|
|
|
49
46
|
end
|
|
50
47
|
|
|
51
48
|
def normalize_headers(values, label)
|
|
52
|
-
|
|
53
|
-
|
|
49
|
+
valid = values.is_a?(Array) && values.all? do |value|
|
|
50
|
+
valid_utf8?(value) && !value.empty? && value.length <= 128 && !value.match?(/[\u0000-\u001f\u007f]/)
|
|
54
51
|
end
|
|
52
|
+
raise RemoteError, "Report result #{label} is invalid" unless valid
|
|
55
53
|
|
|
56
54
|
Canonical.immutable(values)
|
|
57
55
|
end
|
|
@@ -66,6 +64,9 @@ module AnalyticsOps
|
|
|
66
64
|
normalized = values.map do |row|
|
|
67
65
|
value = hash(row, "row")
|
|
68
66
|
raise RemoteError, "Report result row fields do not match headers" unless value.keys.sort == headers.sort
|
|
67
|
+
unless value.values.all? { |item| valid_utf8?(item) }
|
|
68
|
+
raise RemoteError, "Report result row values must be valid UTF-8 strings"
|
|
69
|
+
end
|
|
69
70
|
|
|
70
71
|
value
|
|
71
72
|
end
|
|
@@ -85,6 +86,34 @@ module AnalyticsOps
|
|
|
85
86
|
|
|
86
87
|
Canonical.immutable(value)
|
|
87
88
|
end
|
|
89
|
+
|
|
90
|
+
def normalized_metadata(value)
|
|
91
|
+
raise RemoteError, "Report result metadata must be an object" unless value.is_a?(Hash)
|
|
92
|
+
|
|
93
|
+
validate_metadata_value!(value, "metadata")
|
|
94
|
+
Canonical.immutable(value)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def validate_metadata_value!(value, path)
|
|
98
|
+
case value
|
|
99
|
+
when Hash
|
|
100
|
+
raise RemoteError, "Report result #{path} keys must be strings" unless value.keys.all?(String)
|
|
101
|
+
|
|
102
|
+
value.each { |key, child| validate_metadata_value!(child, "#{path}.#{key}") }
|
|
103
|
+
when Array
|
|
104
|
+
value.each.with_index { |child, index| validate_metadata_value!(child, "#{path}[#{index}]") }
|
|
105
|
+
when String, Integer, TrueClass, FalseClass, NilClass
|
|
106
|
+
raise RemoteError, "Report result #{path} must be valid UTF-8" if value.is_a?(String) && !valid_utf8?(value)
|
|
107
|
+
when Float
|
|
108
|
+
raise RemoteError, "Report result #{path} must be finite" unless value.finite?
|
|
109
|
+
else
|
|
110
|
+
raise RemoteError, "Report result #{path} has an unsupported value"
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def valid_utf8?(value)
|
|
115
|
+
value.is_a?(String) && value.encoding == Encoding::UTF_8 && value.valid_encoding?
|
|
116
|
+
end
|
|
88
117
|
end
|
|
89
118
|
end
|
|
90
119
|
end
|
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
|
|
5
5
|
require_relative "reports/definition"
|
|
6
6
|
require_relative "reports/catalog"
|
|
7
|
+
require_relative "reports/overview"
|
|
7
8
|
require_relative "reports/result"
|
|
9
|
+
require_relative "reports/overview_result"
|
|
8
10
|
|
|
9
11
|
module AnalyticsOps
|
|
10
12
|
# Immutable Data API definitions, recipes, and normalized results.
|
|
@@ -73,7 +73,8 @@ module AnalyticsOps
|
|
|
73
73
|
|
|
74
74
|
# Registered custom metric.
|
|
75
75
|
class CustomMetric < Value
|
|
76
|
-
fields :name, :parameter_name, :display_name, :description, :scope, :measurement_unit
|
|
76
|
+
fields :name, :parameter_name, :display_name, :description, :scope, :measurement_unit,
|
|
77
|
+
:restricted_metric_types
|
|
77
78
|
end
|
|
78
79
|
end
|
|
79
80
|
end
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
|
|
7
|
+
module AnalyticsOps
|
|
8
|
+
# The single supported Google authentication source.
|
|
9
|
+
class ServiceAccount
|
|
10
|
+
MAX_KEY_BYTES = 64 * 1024
|
|
11
|
+
READ_SCOPE = "https://www.googleapis.com/auth/analytics.readonly"
|
|
12
|
+
EDIT_SCOPE = "https://www.googleapis.com/auth/analytics.edit"
|
|
13
|
+
ACCESS_SCOPES = {
|
|
14
|
+
read: [READ_SCOPE].freeze,
|
|
15
|
+
edit: [READ_SCOPE, EDIT_SCOPE].freeze
|
|
16
|
+
}.freeze
|
|
17
|
+
REQUIRED_FIELDS = %w[type client_email private_key token_uri].freeze
|
|
18
|
+
CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/
|
|
19
|
+
|
|
20
|
+
attr_reader :path
|
|
21
|
+
|
|
22
|
+
def self.load(path: nil, store: Store.new)
|
|
23
|
+
new(path || store.read)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def initialize(path)
|
|
27
|
+
@path = validated_path(path).freeze
|
|
28
|
+
validate_key!
|
|
29
|
+
freeze
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def credentials(access: :read)
|
|
33
|
+
build_credentials(scopes_for(access))
|
|
34
|
+
end
|
|
35
|
+
private :credentials
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def scopes_for(access)
|
|
40
|
+
ACCESS_SCOPES.fetch(access) { raise ArgumentError, "access must be :read or :edit" }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def build_credentials(scopes)
|
|
44
|
+
require "googleauth"
|
|
45
|
+
File.open(path, "rb") do |key|
|
|
46
|
+
Google::Auth::ServiceAccountCredentials.make_creds(json_key_io: key, scope: scopes)
|
|
47
|
+
end
|
|
48
|
+
rescue LoadError => error
|
|
49
|
+
raise UnsupportedCapabilityError,
|
|
50
|
+
"Google authentication support is unavailable: #{Redaction.message(error.message)}"
|
|
51
|
+
rescue AnalyticsOps::Error
|
|
52
|
+
raise
|
|
53
|
+
rescue StandardError
|
|
54
|
+
raise AuthenticationError, "The configured service-account key could not be loaded"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def validated_path(value)
|
|
58
|
+
unless value.is_a?(String) && !value.empty? && !value.match?(CONTROL_CHARACTERS)
|
|
59
|
+
raise AuthenticationError, "A valid service-account key path is required"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
expanded = File.expand_path(value)
|
|
63
|
+
unless File.file?(expanded)
|
|
64
|
+
raise AuthenticationError,
|
|
65
|
+
"The configured service-account key is unavailable; run " \
|
|
66
|
+
"`analytics-ops setup --service-account /absolute/path/to/service-account.json`"
|
|
67
|
+
end
|
|
68
|
+
if File.size(expanded) > MAX_KEY_BYTES
|
|
69
|
+
raise AuthenticationError, "The configured service-account key is too large"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
File.realpath(expanded)
|
|
73
|
+
rescue AnalyticsOps::Error
|
|
74
|
+
raise
|
|
75
|
+
rescue SystemCallError
|
|
76
|
+
raise AuthenticationError,
|
|
77
|
+
"The configured service-account key is unavailable; run " \
|
|
78
|
+
"`analytics-ops setup --service-account /absolute/path/to/service-account.json`"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def validate_key!
|
|
82
|
+
document = JSON.parse(File.binread(path, MAX_KEY_BYTES + 1), max_nesting: 8)
|
|
83
|
+
valid = document.is_a?(Hash) &&
|
|
84
|
+
document["type"] == "service_account" &&
|
|
85
|
+
REQUIRED_FIELDS.all? { |field| document[field].is_a?(String) && !document[field].empty? }
|
|
86
|
+
return if valid
|
|
87
|
+
|
|
88
|
+
raise AuthenticationError, "The selected JSON file is not a Google service-account key"
|
|
89
|
+
rescue AnalyticsOps::Error
|
|
90
|
+
raise
|
|
91
|
+
rescue JSON::ParserError, ArgumentError, SystemCallError
|
|
92
|
+
raise AuthenticationError, "The selected JSON file is not a valid Google service-account key"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# User-level pointer to a validated key. The key itself is never copied.
|
|
96
|
+
class Store
|
|
97
|
+
VERSION = 1
|
|
98
|
+
MAX_BYTES = 16 * 1024
|
|
99
|
+
FIELDS = %w[service_account_path version].freeze
|
|
100
|
+
|
|
101
|
+
attr_reader :path
|
|
102
|
+
|
|
103
|
+
def self.default_path
|
|
104
|
+
File.join(Dir.home, ".config", "analytics_ops", "connection.json")
|
|
105
|
+
rescue ArgumentError
|
|
106
|
+
raise AuthenticationError, "Cannot determine the user configuration directory"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def initialize(path: nil)
|
|
110
|
+
candidate = path || self.class.default_path
|
|
111
|
+
unless candidate.is_a?(String) && !candidate.empty? && !candidate.match?(CONTROL_CHARACTERS)
|
|
112
|
+
raise AuthenticationError, "The Analytics Ops connection path is invalid"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
@path = File.expand_path(candidate).freeze
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def read
|
|
119
|
+
unless File.file?(path)
|
|
120
|
+
raise AuthenticationError,
|
|
121
|
+
"No service account is configured; run " \
|
|
122
|
+
"`analytics-ops setup --service-account /absolute/path/to/service-account.json`"
|
|
123
|
+
end
|
|
124
|
+
raise AuthenticationError, "The saved Analytics Ops connection is too large" if File.size(path) > MAX_BYTES
|
|
125
|
+
|
|
126
|
+
document = JSON.parse(File.binread(path, MAX_BYTES + 1), max_nesting: 4)
|
|
127
|
+
valid = document.is_a?(Hash) &&
|
|
128
|
+
document.keys.sort == FIELDS &&
|
|
129
|
+
document["version"] == VERSION &&
|
|
130
|
+
document["service_account_path"].is_a?(String)
|
|
131
|
+
return document.fetch("service_account_path") if valid
|
|
132
|
+
|
|
133
|
+
raise AuthenticationError, invalid_connection_message
|
|
134
|
+
rescue AnalyticsOps::Error
|
|
135
|
+
raise
|
|
136
|
+
rescue JSON::ParserError, ArgumentError, SystemCallError
|
|
137
|
+
raise AuthenticationError, invalid_connection_message
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def write(service_account_path)
|
|
141
|
+
normalized = ServiceAccount.new(service_account_path).path
|
|
142
|
+
directory = File.dirname(path)
|
|
143
|
+
FileUtils.mkdir_p(directory, mode: 0o700)
|
|
144
|
+
File.chmod(0o700, directory)
|
|
145
|
+
temporary = File.join(directory, ".connection.#{Process.pid}.#{SecureRandom.hex(6)}.tmp")
|
|
146
|
+
payload = JSON.generate("version" => VERSION, "service_account_path" => normalized) << "\n"
|
|
147
|
+
|
|
148
|
+
File.open(temporary, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
|
|
149
|
+
file.write(payload)
|
|
150
|
+
file.flush
|
|
151
|
+
file.fsync
|
|
152
|
+
end
|
|
153
|
+
File.rename(temporary, path)
|
|
154
|
+
File.chmod(0o600, path)
|
|
155
|
+
path
|
|
156
|
+
rescue AnalyticsOps::Error
|
|
157
|
+
raise
|
|
158
|
+
rescue SystemCallError
|
|
159
|
+
raise AuthenticationError, "Cannot save the Analytics Ops service-account connection"
|
|
160
|
+
ensure
|
|
161
|
+
File.unlink(temporary) if temporary && File.exist?(temporary)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
private
|
|
165
|
+
|
|
166
|
+
def invalid_connection_message
|
|
167
|
+
"The saved Analytics Ops connection is invalid; rerun setup with --service-account"
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|