analytics_ops 0.1.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 +7 -0
- data/CHANGELOG.md +47 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/CONTRIBUTING.md +67 -0
- data/LICENSE.txt +21 -0
- data/README.md +188 -0
- data/SECURITY.md +56 -0
- data/docs/api-support-matrix.md +50 -0
- data/docs/architecture.md +89 -0
- data/docs/authentication.md +86 -0
- data/docs/commands.md +112 -0
- data/docs/configuration-schema-v1.json +129 -0
- data/docs/configuration.md +167 -0
- data/docs/google-client-compatibility.md +59 -0
- data/docs/plan-format.md +89 -0
- data/docs/plan-schema-v1.json +224 -0
- data/docs/rails.md +73 -0
- data/docs/reports.md +124 -0
- data/docs/safety.md +93 -0
- data/docs/troubleshooting.md +103 -0
- data/exe/analytics-ops +6 -0
- data/lib/analytics_ops/applier.rb +52 -0
- data/lib/analytics_ops/canonical.rb +65 -0
- data/lib/analytics_ops/cli.rb +427 -0
- data/lib/analytics_ops/clients/admin.rb +446 -0
- data/lib/analytics_ops/clients/data.rb +314 -0
- data/lib/analytics_ops/configuration/document.rb +25 -0
- data/lib/analytics_ops/configuration/loader.rb +76 -0
- data/lib/analytics_ops/configuration/schema.rb +107 -0
- data/lib/analytics_ops/configuration/validator.rb +344 -0
- data/lib/analytics_ops/configuration.rb +19 -0
- data/lib/analytics_ops/desired_state.rb +40 -0
- data/lib/analytics_ops/errors.rb +31 -0
- data/lib/analytics_ops/plan.rb +551 -0
- data/lib/analytics_ops/planner.rb +233 -0
- data/lib/analytics_ops/rails/railtie.rb +65 -0
- data/lib/analytics_ops/rails.rb +5 -0
- data/lib/analytics_ops/redaction.rb +46 -0
- data/lib/analytics_ops/reports/catalog.rb +100 -0
- data/lib/analytics_ops/reports/definition.rb +226 -0
- data/lib/analytics_ops/reports/result.rb +90 -0
- data/lib/analytics_ops/reports.rb +13 -0
- data/lib/analytics_ops/resources.rb +79 -0
- data/lib/analytics_ops/snapshot.rb +45 -0
- data/lib/analytics_ops/version.rb +5 -0
- data/lib/analytics_ops/workspace.rb +212 -0
- data/lib/analytics_ops.rb +21 -0
- data/lib/generators/analytics_ops/install_generator.rb +23 -0
- data/lib/generators/analytics_ops/templates/analytics-ops +9 -0
- data/lib/generators/analytics_ops/templates/analytics_ops.yml +23 -0
- data/sig/analytics_ops.rbs +395 -0
- metadata +131 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Deterministic read-only planning.
|
|
4
|
+
|
|
5
|
+
module AnalyticsOps
|
|
6
|
+
# Pure desired-vs-remote comparison. It never owns or contacts a client.
|
|
7
|
+
class Planner
|
|
8
|
+
def initialize(desired_state:, snapshot:)
|
|
9
|
+
@desired = desired_state
|
|
10
|
+
@snapshot = snapshot
|
|
11
|
+
@changes = []
|
|
12
|
+
@findings = []
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call
|
|
16
|
+
unless @desired.property_id == @snapshot.property_id
|
|
17
|
+
raise ConflictError,
|
|
18
|
+
"Configured property #{@desired.property_id} does not match snapshot #{@snapshot.property_id}"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
plan_streams
|
|
22
|
+
plan_retention
|
|
23
|
+
plan_key_events
|
|
24
|
+
plan_custom_dimensions
|
|
25
|
+
plan_custom_metrics
|
|
26
|
+
add_manual_findings
|
|
27
|
+
add_experimental_findings
|
|
28
|
+
|
|
29
|
+
Plan.new(
|
|
30
|
+
profile: @desired.profile,
|
|
31
|
+
property_id: @desired.property_id,
|
|
32
|
+
snapshot_fingerprint: @snapshot.fingerprint,
|
|
33
|
+
changes: @changes,
|
|
34
|
+
findings: @findings
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def plan_streams
|
|
41
|
+
remote = unique_index(@snapshot.streams, "data stream", &:id)
|
|
42
|
+
@desired.streams.each { |desired| plan_stream(remote, desired) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def plan_stream(remote, desired)
|
|
46
|
+
stream = remote[desired.fetch("stream_id")]
|
|
47
|
+
identity = "stream:#{desired.fetch("stream_id")}"
|
|
48
|
+
unless stream
|
|
49
|
+
finding("drift", "missing_stream", identity,
|
|
50
|
+
"Configured data stream is not accessible; stream creation is not automatic")
|
|
51
|
+
return
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
plan_stream_uri(stream, desired, identity)
|
|
55
|
+
return unless desired["enhanced_measurement"]
|
|
56
|
+
|
|
57
|
+
finding(
|
|
58
|
+
"experimental",
|
|
59
|
+
"enhanced_measurement_not_managed",
|
|
60
|
+
identity,
|
|
61
|
+
"Enhanced Measurement is declared but remains an experimental, non-applicable capability"
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def plan_stream_uri(stream, desired, identity)
|
|
66
|
+
return unless desired["default_uri"] && stream.default_uri != desired["default_uri"]
|
|
67
|
+
|
|
68
|
+
if stream.type == "web"
|
|
69
|
+
update(
|
|
70
|
+
"data_stream",
|
|
71
|
+
identity,
|
|
72
|
+
stream.to_h,
|
|
73
|
+
stream.to_h.merge("default_uri" => desired.fetch("default_uri")),
|
|
74
|
+
rollback: "Restore the previous web stream default URI"
|
|
75
|
+
)
|
|
76
|
+
else
|
|
77
|
+
finding("drift", "non_web_default_uri", identity, "Only web streams have a configurable default URI")
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def plan_retention
|
|
82
|
+
desired = @desired.retention
|
|
83
|
+
return unless desired
|
|
84
|
+
|
|
85
|
+
remote = @snapshot.retention
|
|
86
|
+
unless remote
|
|
87
|
+
finding("drift", "retention_unavailable", "property:#{@desired.property_id}:retention",
|
|
88
|
+
"Data retention settings could not be discovered")
|
|
89
|
+
return
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
current = remote.to_h
|
|
93
|
+
after = current.merge(desired)
|
|
94
|
+
return if equivalent?(current, after, %w[event_data user_data reset_on_new_activity])
|
|
95
|
+
|
|
96
|
+
update(
|
|
97
|
+
"retention",
|
|
98
|
+
"property:#{@desired.property_id}:retention",
|
|
99
|
+
current,
|
|
100
|
+
after,
|
|
101
|
+
rollback: "Restore the previous event and user retention durations and reset behavior"
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def plan_key_events
|
|
106
|
+
remote = unique_index(@snapshot.key_events, "key event", &:event_name)
|
|
107
|
+
@desired.key_events.each do |event_name|
|
|
108
|
+
next if remote.key?(event_name)
|
|
109
|
+
|
|
110
|
+
create(
|
|
111
|
+
"key_event",
|
|
112
|
+
"event:#{event_name}",
|
|
113
|
+
{ "event_name" => event_name, "counting_method" => "once_per_event" },
|
|
114
|
+
rollback: "Delete the newly created key event in Google Analytics if rollback is required"
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def plan_custom_dimensions
|
|
120
|
+
remote = unique_index(@snapshot.custom_dimensions, "custom dimension") do |dimension|
|
|
121
|
+
[dimension.scope, dimension.parameter_name]
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
@desired.custom_dimensions.each do |desired|
|
|
125
|
+
identity_key = [desired.fetch("scope"), desired.fetch("parameter_name")]
|
|
126
|
+
identity = identity_key.join(":").to_s
|
|
127
|
+
current = remote[identity_key]
|
|
128
|
+
unless current
|
|
129
|
+
create("custom_dimension", identity, desired, rollback: "Archive the newly created custom dimension")
|
|
130
|
+
next
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
before = current.to_h
|
|
134
|
+
mutable = %w[display_name description]
|
|
135
|
+
mutable << "disallow_ads_personalization" if desired.fetch("scope") == "user"
|
|
136
|
+
after = before.merge(desired.slice(*mutable))
|
|
137
|
+
next if equivalent?(before, after, mutable)
|
|
138
|
+
|
|
139
|
+
update("custom_dimension", identity, before, after, rollback: "Restore the previous custom dimension metadata")
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def plan_custom_metrics
|
|
144
|
+
remote = unique_index(@snapshot.custom_metrics, "custom metric", &:parameter_name)
|
|
145
|
+
|
|
146
|
+
@desired.custom_metrics.each do |desired|
|
|
147
|
+
identity = desired.fetch("parameter_name")
|
|
148
|
+
current = remote[identity]
|
|
149
|
+
unless current
|
|
150
|
+
create("custom_metric", identity, desired, rollback: "Archive the newly created custom metric")
|
|
151
|
+
next
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
before = current.to_h
|
|
155
|
+
if before.fetch("scope") != desired.fetch("scope") ||
|
|
156
|
+
before.fetch("measurement_unit") != desired.fetch("measurement_unit")
|
|
157
|
+
finding("drift", "immutable_metric_conflict", "metric:#{identity}",
|
|
158
|
+
"Custom metric scope or measurement unit differs and will not be recreated automatically")
|
|
159
|
+
next
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
mutable = %w[display_name description]
|
|
163
|
+
after = before.merge(desired.slice(*mutable))
|
|
164
|
+
next if equivalent?(before, after, mutable)
|
|
165
|
+
|
|
166
|
+
update("custom_metric", identity, before, after, rollback: "Restore the previous custom metric metadata")
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def add_manual_findings
|
|
171
|
+
@desired.manual_requirements.each do |requirement|
|
|
172
|
+
finding("manual", "manual_requirement", requirement, "Verify #{requirement.tr("_", " ")} in Google Analytics")
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def add_experimental_findings
|
|
177
|
+
return unless @desired.google_signals
|
|
178
|
+
|
|
179
|
+
finding(
|
|
180
|
+
"experimental",
|
|
181
|
+
"google_signals_not_managed",
|
|
182
|
+
"property:#{@desired.property_id}:google_signals",
|
|
183
|
+
"Google Signals is declared but remains an experimental, non-applicable capability"
|
|
184
|
+
)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def create(resource_type, identity, after, rollback:)
|
|
188
|
+
@changes << Plan::Change.new(
|
|
189
|
+
resource_type:,
|
|
190
|
+
resource_identity: identity,
|
|
191
|
+
operation: "create",
|
|
192
|
+
api_maturity: "beta",
|
|
193
|
+
before: nil,
|
|
194
|
+
after: Canonical.normalize(after),
|
|
195
|
+
reversible: true,
|
|
196
|
+
rollback:
|
|
197
|
+
)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def update(resource_type, identity, before, after, rollback:)
|
|
201
|
+
@changes << Plan::Change.new(
|
|
202
|
+
resource_type:,
|
|
203
|
+
resource_identity: identity,
|
|
204
|
+
operation: "update",
|
|
205
|
+
api_maturity: "beta",
|
|
206
|
+
before: Canonical.normalize(before),
|
|
207
|
+
after: Canonical.normalize(after),
|
|
208
|
+
reversible: true,
|
|
209
|
+
rollback:
|
|
210
|
+
)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def finding(severity, code, identity, message)
|
|
214
|
+
@findings << Plan::Finding.new(severity:, code:, resource_identity: identity, message:)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def unique_index(values, resource)
|
|
218
|
+
values.each_with_object({}) do |value, result|
|
|
219
|
+
identity = yield(value)
|
|
220
|
+
if result.key?(identity)
|
|
221
|
+
raise ConflictError,
|
|
222
|
+
"Ambiguous remote #{resource} identity #{Array(identity).join(":")}"
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
result[identity] = value
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def equivalent?(before, after, keys)
|
|
230
|
+
keys.all? { |key| before[key] == after[key] }
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AnalyticsOps
|
|
4
|
+
# Helpers used only when an operator explicitly invokes a Rails Rake task.
|
|
5
|
+
module RailsTasks
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def run(command, report_name: nil)
|
|
9
|
+
arguments = [
|
|
10
|
+
command,
|
|
11
|
+
"--config", ENV.fetch("ANALYTICS_OPS_CONFIG", Rails.root.join("config/analytics_ops.yml").to_s),
|
|
12
|
+
"--profile", ENV.fetch("ANALYTICS_OPS_PROFILE", Rails.env.to_s)
|
|
13
|
+
]
|
|
14
|
+
if command == "plan"
|
|
15
|
+
arguments.push("--output",
|
|
16
|
+
ENV.fetch("ANALYTICS_OPS_PLAN", Rails.root.join("tmp/analytics_ops-plan.json").to_s))
|
|
17
|
+
end
|
|
18
|
+
arguments << report_name if report_name
|
|
19
|
+
|
|
20
|
+
status = CLI.start(arguments)
|
|
21
|
+
return if status == CLI::SUCCESS
|
|
22
|
+
|
|
23
|
+
raise Error, "analytics-ops #{command} exited with status #{status}"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Optional Rails hooks. Defining this class performs no Google API calls.
|
|
28
|
+
class Railtie < Rails::Railtie
|
|
29
|
+
generators do
|
|
30
|
+
require_relative "../../generators/analytics_ops/install_generator"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
rake_tasks do
|
|
34
|
+
namespace :analytics do
|
|
35
|
+
desc "Validate Analytics Ops configuration, credentials, and API access"
|
|
36
|
+
task doctor: :environment do
|
|
37
|
+
RailsTasks.run("doctor")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
desc "Audit configured Google Analytics state without changing it"
|
|
41
|
+
task audit: :environment do
|
|
42
|
+
RailsTasks.run("audit")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
desc "Write a deterministic Analytics Ops plan"
|
|
46
|
+
task plan: :environment do
|
|
47
|
+
RailsTasks.run("plan")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
desc "Verify that managed Google Analytics state converges"
|
|
51
|
+
task verify: :environment do
|
|
52
|
+
RailsTasks.run("verify")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
desc "Run a built-in report: rake analytics:report[NAME]"
|
|
56
|
+
task :report, [:name] => :environment do |_task, arguments|
|
|
57
|
+
name = arguments[:name]
|
|
58
|
+
raise Error, "analytics:report requires NAME" if name.to_s.empty?
|
|
59
|
+
|
|
60
|
+
RailsTasks.run("report", report_name: name)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module AnalyticsOps
|
|
6
|
+
# Removes credential-shaped material before text reaches user-visible output.
|
|
7
|
+
module Redaction
|
|
8
|
+
BEARER = /\bBearer\s+[^\s,;]+/i
|
|
9
|
+
AUTHORIZATION = /\bAuthorization\s*[:=]\s*[^\r\n,;]+/i
|
|
10
|
+
PRIVATE_KEY = /-----BEGIN[^-]*PRIVATE KEY-----.*?-----END[^-]*PRIVATE KEY-----/mi
|
|
11
|
+
SECRET_ASSIGNMENT = /
|
|
12
|
+
(
|
|
13
|
+
access[_ -]?token|refresh[_ -]?token|client[_ -]?(?:id|secret)|
|
|
14
|
+
private[_ -]?key|password|api[_ -]?key|credentials?
|
|
15
|
+
)
|
|
16
|
+
\s*([:=]\s*|=>\s*)["']?[^\s,"';}]+
|
|
17
|
+
/ix
|
|
18
|
+
CONTROL_CHARACTERS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
def message(value)
|
|
23
|
+
value.to_s
|
|
24
|
+
.gsub(PRIVATE_KEY, "[REDACTED]")
|
|
25
|
+
.gsub(AUTHORIZATION, "Authorization: [REDACTED]")
|
|
26
|
+
.gsub(BEARER, "Bearer [REDACTED]")
|
|
27
|
+
.gsub(SECRET_ASSIGNMENT) { "#{Regexp.last_match(1)}=[REDACTED]" }
|
|
28
|
+
.gsub(CONTROL_CHARACTERS, "?")
|
|
29
|
+
.slice(0, 1_000)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Writes small structured operational events without requests, responses, or credentials.
|
|
34
|
+
module SafeLogging
|
|
35
|
+
module_function
|
|
36
|
+
|
|
37
|
+
def write(logger, level, event, details = {})
|
|
38
|
+
return unless logger.respond_to?(level)
|
|
39
|
+
|
|
40
|
+
payload = { "source" => "analytics_ops", "event" => event }.merge(details)
|
|
41
|
+
logger.public_send(level, Redaction.message(JSON.generate(payload)))
|
|
42
|
+
rescue StandardError
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AnalyticsOps
|
|
4
|
+
module Reports
|
|
5
|
+
# Small built-in catalog focused on acquisition and calculator outcomes.
|
|
6
|
+
module Catalog
|
|
7
|
+
STANDARD_DATE_RANGE = [{ "start_date" => "28daysAgo", "end_date" => "yesterday" }].freeze
|
|
8
|
+
CALCULATOR = "customEvent:calculator_slug"
|
|
9
|
+
EVENT_FILTER = lambda do |value, match_type = "exact"|
|
|
10
|
+
{ "field" => "eventName", "match_type" => match_type, "value" => value }
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
DEFINITIONS = [
|
|
14
|
+
Definition.new(
|
|
15
|
+
name: "traffic_acquisition",
|
|
16
|
+
kind: "standard",
|
|
17
|
+
dimensions: %w[sessionDefaultChannelGroup sessionSource sessionMedium],
|
|
18
|
+
metrics: %w[sessions totalUsers keyEvents],
|
|
19
|
+
date_ranges: STANDARD_DATE_RANGE,
|
|
20
|
+
order_bys: [{ "metric" => "sessions", "desc" => true }],
|
|
21
|
+
limit: 250
|
|
22
|
+
),
|
|
23
|
+
Definition.new(
|
|
24
|
+
name: "landing_pages",
|
|
25
|
+
kind: "standard",
|
|
26
|
+
dimensions: %w[landingPagePlusQueryString],
|
|
27
|
+
metrics: %w[sessions totalUsers keyEvents],
|
|
28
|
+
date_ranges: STANDARD_DATE_RANGE,
|
|
29
|
+
order_bys: [{ "metric" => "sessions", "desc" => true }],
|
|
30
|
+
limit: 500
|
|
31
|
+
),
|
|
32
|
+
Definition.new(
|
|
33
|
+
name: "calculator_completions",
|
|
34
|
+
kind: "standard",
|
|
35
|
+
dimensions: ["eventName", CALCULATOR],
|
|
36
|
+
metrics: %w[eventCount totalUsers],
|
|
37
|
+
date_ranges: STANDARD_DATE_RANGE,
|
|
38
|
+
dimension_filter: EVENT_FILTER.call("calculation_completed"),
|
|
39
|
+
order_bys: [{ "metric" => "eventCount", "desc" => true }],
|
|
40
|
+
limit: 500
|
|
41
|
+
),
|
|
42
|
+
Definition.new(
|
|
43
|
+
name: "shares_and_prints",
|
|
44
|
+
kind: "standard",
|
|
45
|
+
dimensions: ["eventName", CALCULATOR],
|
|
46
|
+
metrics: %w[eventCount totalUsers],
|
|
47
|
+
date_ranges: STANDARD_DATE_RANGE,
|
|
48
|
+
dimension_filter: EVENT_FILTER.call("^(result_shared|result_printed)$", "full_regexp"),
|
|
49
|
+
order_bys: [{ "metric" => "eventCount", "desc" => true }],
|
|
50
|
+
limit: 500
|
|
51
|
+
),
|
|
52
|
+
Definition.new(
|
|
53
|
+
name: "related_calculator_navigation",
|
|
54
|
+
kind: "standard",
|
|
55
|
+
dimensions: ["eventName", CALCULATOR, "customEvent:related_calculator_slug"],
|
|
56
|
+
metrics: %w[eventCount totalUsers],
|
|
57
|
+
date_ranges: STANDARD_DATE_RANGE,
|
|
58
|
+
dimension_filter: EVENT_FILTER.call("related_calculator_clicked"),
|
|
59
|
+
order_bys: [{ "metric" => "eventCount", "desc" => true }],
|
|
60
|
+
limit: 500
|
|
61
|
+
),
|
|
62
|
+
Definition.new(
|
|
63
|
+
name: "commercial_outbound_clicks",
|
|
64
|
+
kind: "standard",
|
|
65
|
+
dimensions: ["eventName", CALCULATOR, "customEvent:outbound_destination"],
|
|
66
|
+
metrics: %w[eventCount totalUsers],
|
|
67
|
+
date_ranges: STANDARD_DATE_RANGE,
|
|
68
|
+
dimension_filter: EVENT_FILTER.call("commercial_outbound_clicked"),
|
|
69
|
+
order_bys: [{ "metric" => "eventCount", "desc" => true }],
|
|
70
|
+
limit: 500
|
|
71
|
+
),
|
|
72
|
+
Definition.new(
|
|
73
|
+
name: "realtime_events",
|
|
74
|
+
kind: "realtime",
|
|
75
|
+
dimensions: %w[eventName],
|
|
76
|
+
metrics: %w[eventCount activeUsers],
|
|
77
|
+
order_bys: [{ "metric" => "eventCount", "desc" => true }],
|
|
78
|
+
limit: 100
|
|
79
|
+
)
|
|
80
|
+
].to_h { |definition| [definition.name, definition] }.freeze
|
|
81
|
+
|
|
82
|
+
module_function
|
|
83
|
+
|
|
84
|
+
def fetch(name, kind: nil)
|
|
85
|
+
definition = DEFINITIONS.fetch(name.to_s) do
|
|
86
|
+
raise InvalidRequestError, "Unknown report #{name.inspect}; available reports: #{names.join(", ")}"
|
|
87
|
+
end
|
|
88
|
+
if kind && definition.kind != kind.to_s
|
|
89
|
+
raise InvalidRequestError, "Report #{name} is #{definition.kind}, not #{kind}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
definition
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def names
|
|
96
|
+
DEFINITIONS.keys.sort
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module AnalyticsOps
|
|
6
|
+
module Reports
|
|
7
|
+
# Immutable, gem-owned description of one Data API query.
|
|
8
|
+
class Definition
|
|
9
|
+
KINDS = %w[standard realtime].freeze
|
|
10
|
+
API_NAME = /\A[a-z][a-zA-Z0-9_]*(?::[a-zA-Z][a-zA-Z0-9_]*)?\z/
|
|
11
|
+
REPORT_NAME = /\A[a-z][a-z0-9_]{0,63}\z/
|
|
12
|
+
RANGE_NAME = /\A[a-z][a-z0-9_]{0,39}\z/
|
|
13
|
+
RELATIVE_DATE = /\A(\d{1,4})daysAgo\z/
|
|
14
|
+
MATCH_TYPES = %w[exact begins_with ends_with contains full_regexp partial_regexp].freeze
|
|
15
|
+
NUMERIC_OPERATIONS = %w[equal less_than less_than_or_equal greater_than greater_than_or_equal].freeze
|
|
16
|
+
MAX_LIMIT = 100_000
|
|
17
|
+
|
|
18
|
+
attr_reader :name, :kind, :dimensions, :metrics, :date_ranges,
|
|
19
|
+
:dimension_filter, :metric_filter, :order_bys, :offset, :limit
|
|
20
|
+
|
|
21
|
+
def initialize(name:, kind:, dimensions:, metrics:, date_ranges: [], dimension_filter: nil,
|
|
22
|
+
metric_filter: nil, order_bys: [], offset: 0, limit: 100)
|
|
23
|
+
@name = validate_name(name)
|
|
24
|
+
@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
|
+
@date_ranges = ranges(date_ranges)
|
|
28
|
+
@dimension_filter = string_filter(dimension_filter)
|
|
29
|
+
@metric_filter = numeric_filter(metric_filter)
|
|
30
|
+
@order_bys = orders(order_bys)
|
|
31
|
+
@offset = integer(offset, "offset", minimum: 0, maximum: 1_000_000_000)
|
|
32
|
+
@limit = integer(limit, "limit", minimum: 1, maximum: MAX_LIMIT)
|
|
33
|
+
validate_shape!
|
|
34
|
+
freeze
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def realtime?
|
|
38
|
+
kind == "realtime"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def to_h
|
|
42
|
+
{
|
|
43
|
+
"name" => name,
|
|
44
|
+
"kind" => kind,
|
|
45
|
+
"dimensions" => dimensions,
|
|
46
|
+
"metrics" => metrics,
|
|
47
|
+
"date_ranges" => date_ranges,
|
|
48
|
+
"dimension_filter" => dimension_filter,
|
|
49
|
+
"metric_filter" => metric_filter,
|
|
50
|
+
"order_bys" => order_bys,
|
|
51
|
+
"offset" => offset,
|
|
52
|
+
"limit" => limit
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def validate_name(value)
|
|
59
|
+
return value.dup.freeze if value.is_a?(String) && REPORT_NAME.match?(value)
|
|
60
|
+
|
|
61
|
+
raise InvalidRequestError, "Report name is invalid"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def validate_kind(value)
|
|
65
|
+
return value.dup.freeze if value.is_a?(String) && KINDS.include?(value)
|
|
66
|
+
|
|
67
|
+
raise InvalidRequestError, "Report kind must be standard or realtime"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def realtime_kind?(value)
|
|
71
|
+
value == "realtime"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def api_names(values, label, maximum:)
|
|
75
|
+
unless values.is_a?(Array) && values.any? && values.length <= maximum &&
|
|
76
|
+
values.all? { |value| valid_api_name?(value) }
|
|
77
|
+
raise InvalidRequestError, "Report #{label} must contain 1 to #{maximum} valid Data API names"
|
|
78
|
+
end
|
|
79
|
+
unless values.uniq.length == values.length
|
|
80
|
+
raise InvalidRequestError,
|
|
81
|
+
"Report #{label} must not contain duplicates"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
Canonical.immutable(values)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def valid_api_name?(value)
|
|
88
|
+
value.is_a?(String) && value.length <= 128 && API_NAME.match?(value)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def ranges(values)
|
|
92
|
+
raise InvalidRequestError, "date_ranges must be an array" unless values.is_a?(Array)
|
|
93
|
+
raise InvalidRequestError, "A report can contain at most four date ranges" if values.length > 4
|
|
94
|
+
|
|
95
|
+
normalized = values.map do |range|
|
|
96
|
+
hash = string_hash(range, "date range", %w[start_date end_date name])
|
|
97
|
+
start_date = date(hash.fetch("start_date") { invalid!("Missing start_date") })
|
|
98
|
+
end_date = date(hash.fetch("end_date") { invalid!("Missing end_date") })
|
|
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
|
|
109
|
+
Canonical.immutable(normalized)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def date(value)
|
|
113
|
+
invalid!("Invalid report date") unless value.is_a?(String)
|
|
114
|
+
return value if %w[today yesterday].include?(value)
|
|
115
|
+
|
|
116
|
+
relative = RELATIVE_DATE.match(value)
|
|
117
|
+
return value if relative && relative[1].to_i <= 3650
|
|
118
|
+
|
|
119
|
+
Date.iso8601(value)
|
|
120
|
+
value
|
|
121
|
+
rescue Date::Error
|
|
122
|
+
invalid!("Invalid report date")
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def validate_date_order!(start_date, end_date)
|
|
126
|
+
return unless start_date.match?(/\A\d{4}-/) && end_date.match?(/\A\d{4}-/)
|
|
127
|
+
return if Date.iso8601(start_date) <= Date.iso8601(end_date)
|
|
128
|
+
|
|
129
|
+
invalid!("Report start_date must not be after end_date")
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def string_filter(value)
|
|
133
|
+
return nil if value.nil?
|
|
134
|
+
|
|
135
|
+
hash = string_hash(value, "dimension filter", %w[field match_type value case_sensitive])
|
|
136
|
+
field = hash.fetch("field") { invalid!("Missing filter field") }
|
|
137
|
+
match_type = hash.fetch("match_type") { invalid!("Missing filter match_type") }
|
|
138
|
+
filter_value = hash.fetch("value") { invalid!("Missing filter value") }
|
|
139
|
+
unless valid_api_name?(field) && dimensions.include?(field) && MATCH_TYPES.include?(match_type) &&
|
|
140
|
+
printable_string?(filter_value, maximum: 1_024)
|
|
141
|
+
invalid!("Dimension filter is invalid or references an unselected dimension")
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
case_sensitive = hash.key?("case_sensitive") ? boolean(hash.fetch("case_sensitive"), "case_sensitive") : false
|
|
145
|
+
Canonical.immutable(
|
|
146
|
+
"field" => field,
|
|
147
|
+
"match_type" => match_type,
|
|
148
|
+
"value" => filter_value,
|
|
149
|
+
"case_sensitive" => case_sensitive
|
|
150
|
+
)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def numeric_filter(value)
|
|
154
|
+
return nil if value.nil?
|
|
155
|
+
|
|
156
|
+
hash = string_hash(value, "metric filter", %w[field operation value])
|
|
157
|
+
field = hash.fetch("field") { invalid!("Missing metric filter field") }
|
|
158
|
+
operation = hash.fetch("operation") { invalid!("Missing metric filter operation") }
|
|
159
|
+
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)
|
|
162
|
+
invalid!("Metric filter is invalid or references an unselected metric") unless valid
|
|
163
|
+
invalid!("Metric filter value must be finite") unless number.finite?
|
|
164
|
+
|
|
165
|
+
Canonical.immutable("field" => field, "operation" => operation, "value" => number)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def orders(values)
|
|
169
|
+
raise InvalidRequestError, "order_bys must be an array" unless values.is_a?(Array)
|
|
170
|
+
raise InvalidRequestError, "A report can contain at most ten order clauses" if values.length > 10
|
|
171
|
+
|
|
172
|
+
normalized = values.map do |order|
|
|
173
|
+
hash = string_hash(order, "report order", %w[metric dimension desc])
|
|
174
|
+
selectors = %w[metric dimension].select { |key| hash.key?(key) }
|
|
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
|
|
183
|
+
|
|
184
|
+
{ selector => api_name, "desc" => hash.key?("desc") ? boolean(hash.fetch("desc"), "desc") : false }
|
|
185
|
+
end
|
|
186
|
+
Canonical.immutable(normalized)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def validate_shape!
|
|
190
|
+
invalid!("Realtime reports cannot include date ranges") if realtime? && !date_ranges.empty?
|
|
191
|
+
invalid!("Realtime reports do not support offset") if realtime? && offset.positive?
|
|
192
|
+
invalid!("Standard reports require a date range") if !realtime? && date_ranges.empty?
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def string_hash(value, label, allowed)
|
|
196
|
+
raise InvalidRequestError, "#{label} must be an object" unless value.is_a?(Hash)
|
|
197
|
+
raise InvalidRequestError, "#{label} keys must be strings" unless value.keys.all?(String)
|
|
198
|
+
|
|
199
|
+
unknown = value.keys - allowed
|
|
200
|
+
raise InvalidRequestError, "Unknown #{label} field #{unknown.first}" unless unknown.empty?
|
|
201
|
+
|
|
202
|
+
value
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def printable_string?(value, maximum:)
|
|
206
|
+
value.is_a?(String) && !value.empty? && value.length <= maximum && !value.match?(/[\u0000-\u001f\u007f]/)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def boolean(value, label)
|
|
210
|
+
return value if [true, false].include?(value)
|
|
211
|
+
|
|
212
|
+
raise InvalidRequestError, "#{label} must be true or false"
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def integer(value, label, minimum:, maximum:)
|
|
216
|
+
return value if value.is_a?(Integer) && value.between?(minimum, maximum)
|
|
217
|
+
|
|
218
|
+
raise InvalidRequestError, "Report #{label} must be between #{minimum} and #{maximum}"
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def invalid!(message)
|
|
222
|
+
raise InvalidRequestError, message
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|