analytics_ops 0.2.0 → 0.3.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 +108 -0
- data/CONTRIBUTING.md +1 -0
- data/README.md +141 -13
- data/SECURITY.md +11 -2
- data/docs/ai-connections.md +192 -0
- data/docs/api-support-matrix.md +25 -8
- data/docs/architecture.md +78 -17
- data/docs/authentication.md +53 -3
- data/docs/bigquery.md +128 -0
- data/docs/commands.md +133 -12
- data/docs/configuration-schema-v1.json +608 -50
- data/docs/configuration.md +118 -5
- data/docs/google-client-compatibility.md +21 -1
- data/docs/governance.md +93 -0
- data/docs/health.md +82 -0
- data/docs/live-smoke-test.md +13 -10
- data/docs/rails.md +25 -9
- data/docs/reports.md +165 -3
- data/docs/safety.md +37 -5
- data/docs/troubleshooting.md +81 -7
- data/lib/analytics_ops/bigquery/definition.rb +173 -0
- data/lib/analytics_ops/bigquery.rb +4 -0
- data/lib/analytics_ops/cli/local_commands.rb +80 -0
- data/lib/analytics_ops/cli/options.rb +282 -9
- data/lib/analytics_ops/cli/presenter.rb +263 -28
- data/lib/analytics_ops/cli/presenter_csv.rb +92 -0
- data/lib/analytics_ops/cli/reporting_commands.rb +129 -0
- data/lib/analytics_ops/cli/setup_commands.rb +91 -0
- data/lib/analytics_ops/cli.rb +121 -44
- data/lib/analytics_ops/clients/admin.rb +28 -2
- data/lib/analytics_ops/clients/admin_governance.rb +95 -0
- data/lib/analytics_ops/clients/admin_governance_normalization.rb +90 -0
- data/lib/analytics_ops/clients/admin_resource_normalization.rb +61 -0
- data/lib/analytics_ops/clients/bigquery.rb +313 -0
- data/lib/analytics_ops/clients/data.rb +147 -54
- data/lib/analytics_ops/clients/data_result_normalization.rb +55 -0
- data/lib/analytics_ops/clients/error_translation.rb +13 -1
- data/lib/analytics_ops/configuration/schema.rb +175 -1
- data/lib/analytics_ops/configuration/validator.rb +45 -7
- data/lib/analytics_ops/configuration/validator_experimental.rb +136 -0
- data/lib/analytics_ops/configuration/validator_reporting.rb +93 -0
- data/lib/analytics_ops/configuration/writer.rb +112 -7
- data/lib/analytics_ops/desired_state.rb +28 -3
- data/lib/analytics_ops/errors.rb +33 -1
- data/lib/analytics_ops/funnels.rb +58 -0
- data/lib/analytics_ops/governance.rb +363 -0
- data/lib/analytics_ops/health.rb +310 -0
- data/lib/analytics_ops/mcp_server/custom_report_tool.rb +101 -0
- data/lib/analytics_ops/mcp_server/discovery_tools.rb +79 -0
- data/lib/analytics_ops/mcp_server/experimental_tools.rb +89 -0
- data/lib/analytics_ops/mcp_server/health_tools.rb +80 -0
- data/lib/analytics_ops/mcp_server/overview_tools.rb +49 -0
- data/lib/analytics_ops/mcp_server/standard_report_tool.rb +53 -0
- data/lib/analytics_ops/mcp_server.rb +402 -0
- data/lib/analytics_ops/portfolio.rb +216 -0
- data/lib/analytics_ops/rails/railtie.rb +19 -8
- data/lib/analytics_ops/redaction.rb +10 -6
- data/lib/analytics_ops/reports/csv_export.rb +91 -0
- data/lib/analytics_ops/reports/definition.rb +152 -14
- data/lib/analytics_ops/reports/metadata.rb +158 -0
- data/lib/analytics_ops/reports/period.rb +104 -0
- data/lib/analytics_ops/reports/result.rb +8 -3
- data/lib/analytics_ops/reports.rb +3 -0
- data/lib/analytics_ops/service_account.rb +321 -29
- data/lib/analytics_ops/setup.rb +31 -7
- data/lib/analytics_ops/version.rb +1 -1
- data/lib/analytics_ops/workspace.rb +222 -10
- data/lib/analytics_ops.rb +7 -2
- data/lib/generators/analytics_ops/templates/analytics_ops.yml +1 -1
- data/sig/analytics_ops.rbs +494 -9
- metadata +60 -1
|
@@ -7,6 +7,8 @@ require "time"
|
|
|
7
7
|
module AnalyticsOps
|
|
8
8
|
# Primary Ruby API. Loading a workspace validates local data but performs no network calls.
|
|
9
9
|
class Workspace
|
|
10
|
+
MAX_EXPORT_ROWS = 1_000_000
|
|
11
|
+
DEFAULT_PAGE_SIZE = 100_000
|
|
10
12
|
# Immutable convergence result returned by verify.
|
|
11
13
|
class Verification
|
|
12
14
|
attr_reader :converged, :plan
|
|
@@ -42,13 +44,22 @@ module AnalyticsOps
|
|
|
42
44
|
|
|
43
45
|
attr_reader :desired_state
|
|
44
46
|
|
|
45
|
-
def self.load(path, profile:, environment: ENV, admin: nil, data: nil, service_account: nil,
|
|
47
|
+
def self.load(path, profile:, environment: ENV, admin: nil, data: nil, bigquery: nil, service_account: nil,
|
|
46
48
|
transport: :grpc, timeout: nil, logger: nil)
|
|
47
49
|
document = Configuration.load(path, environment:)
|
|
48
|
-
new(
|
|
50
|
+
new(
|
|
51
|
+
desired_state: document.profile(profile),
|
|
52
|
+
admin:,
|
|
53
|
+
data:,
|
|
54
|
+
bigquery:,
|
|
55
|
+
service_account:,
|
|
56
|
+
transport:,
|
|
57
|
+
timeout:,
|
|
58
|
+
logger:
|
|
59
|
+
)
|
|
49
60
|
end
|
|
50
61
|
|
|
51
|
-
def initialize(desired_state:, admin: nil, data: nil, service_account: nil,
|
|
62
|
+
def initialize(desired_state:, admin: nil, data: nil, bigquery: nil, service_account: nil,
|
|
52
63
|
transport: :grpc, timeout: nil, logger: nil)
|
|
53
64
|
unless service_account.nil? || service_account.is_a?(ServiceAccount)
|
|
54
65
|
raise ConfigurationError, "service_account must be an AnalyticsOps::ServiceAccount"
|
|
@@ -58,6 +69,7 @@ module AnalyticsOps
|
|
|
58
69
|
@admin = admin
|
|
59
70
|
@injected_admin = admin
|
|
60
71
|
@data = data
|
|
72
|
+
@bigquery = bigquery
|
|
61
73
|
@service_account = service_account
|
|
62
74
|
@transport = transport
|
|
63
75
|
@timeout = timeout
|
|
@@ -90,21 +102,150 @@ module AnalyticsOps
|
|
|
90
102
|
Verification.new(plan:)
|
|
91
103
|
end
|
|
92
104
|
|
|
93
|
-
def report(name_or_definition)
|
|
94
|
-
definition =
|
|
105
|
+
def report(name_or_definition, date_ranges: nil)
|
|
106
|
+
definition, custom = resolved_report_definition(name_or_definition, kind: "standard")
|
|
107
|
+
definition = definition.with_date_ranges(date_ranges) if date_ranges
|
|
108
|
+
validate_report_definition(definition) if custom
|
|
95
109
|
data.run(desired_state.property_id, definition)
|
|
96
110
|
end
|
|
97
111
|
|
|
98
112
|
def realtime(name_or_definition = "realtime_events")
|
|
99
|
-
definition =
|
|
113
|
+
definition, custom = resolved_report_definition(name_or_definition, kind: "realtime")
|
|
114
|
+
validate_report_definition(definition) if custom
|
|
100
115
|
data.run(desired_state.property_id, definition)
|
|
101
116
|
end
|
|
102
117
|
|
|
103
|
-
def
|
|
104
|
-
|
|
118
|
+
def report_metadata(query: nil, kind: nil)
|
|
119
|
+
fields = data.metadata(desired_state.property_id).search(query, kind:)
|
|
120
|
+
Reports::FieldList.new(property_id: desired_state.property_id, query:, kind: kind&.to_s, fields:)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def report_recipes
|
|
124
|
+
built_in = Reports::Catalog.names.map do |name|
|
|
125
|
+
definition = Reports::Catalog.fetch(name)
|
|
126
|
+
Reports::Recipe.new(
|
|
127
|
+
name:,
|
|
128
|
+
kind: definition.kind,
|
|
129
|
+
source: "built_in",
|
|
130
|
+
dimensions: definition.dimensions,
|
|
131
|
+
metrics: definition.metrics
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
configured = desired_state.reports.map do |name, definition|
|
|
135
|
+
Reports::Recipe.new(
|
|
136
|
+
name:,
|
|
137
|
+
kind: definition.kind,
|
|
138
|
+
source: "configuration",
|
|
139
|
+
dimensions: definition.dimensions,
|
|
140
|
+
metrics: definition.metrics
|
|
141
|
+
)
|
|
142
|
+
end
|
|
143
|
+
Reports::RecipeList.new(recipes: (built_in + configured).sort_by { |recipe| [recipe.name, recipe.source] })
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def report_compatibility(name_or_definition)
|
|
147
|
+
definition, = resolved_report_definition(name_or_definition, kind: "standard")
|
|
148
|
+
validate_report_fields!(definition)
|
|
149
|
+
data.check_compatibility(desired_state.property_id, definition)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def each_report_page(name_or_definition, date_ranges: nil, page_size: DEFAULT_PAGE_SIZE,
|
|
153
|
+
max_rows: MAX_EXPORT_ROWS)
|
|
154
|
+
return enum_for(__method__, name_or_definition, date_ranges:, page_size:, max_rows:) unless block_given?
|
|
155
|
+
|
|
156
|
+
definition, custom = resolved_report_definition(name_or_definition, kind: "standard")
|
|
157
|
+
definition = definition.with_date_ranges(date_ranges) if date_ranges
|
|
158
|
+
validate_report_definition(definition) if custom
|
|
159
|
+
page_size = bounded_integer(page_size, "page_size", maximum: Reports::Definition::MAX_STANDARD_LIMIT)
|
|
160
|
+
max_rows = bounded_integer(max_rows, "max_rows", maximum: MAX_EXPORT_ROWS)
|
|
161
|
+
|
|
162
|
+
offset = definition.offset
|
|
163
|
+
ending_offset = offset + max_rows
|
|
164
|
+
while offset < ending_offset
|
|
165
|
+
requested = [page_size, ending_offset - offset].min
|
|
166
|
+
result = data.run(
|
|
167
|
+
desired_state.property_id,
|
|
168
|
+
definition.with_pagination(offset:, limit: requested)
|
|
169
|
+
)
|
|
170
|
+
yield result
|
|
171
|
+
returned = result.rows.length
|
|
172
|
+
break if returned.zero? || offset + returned >= result.row_count
|
|
173
|
+
|
|
174
|
+
offset += returned
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def export_report(name_or_definition, path:, date_ranges: nil, page_size: DEFAULT_PAGE_SIZE,
|
|
179
|
+
max_rows: MAX_EXPORT_ROWS)
|
|
180
|
+
pages = each_report_page(name_or_definition, date_ranges:, page_size:, max_rows:)
|
|
181
|
+
Reports::CSVExport.write(path, pages)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def overview(date_ranges: nil)
|
|
185
|
+
definitions = Reports::Catalog.overview
|
|
186
|
+
definitions = definitions.map { |definition| definition.with_date_ranges(date_ranges) }.freeze if date_ranges
|
|
187
|
+
reports = data.batch(desired_state.property_id, definitions)
|
|
105
188
|
Reports::OverviewResult.new(property_id: desired_state.property_id, reports:)
|
|
106
189
|
end
|
|
107
190
|
|
|
191
|
+
def health(date_ranges: nil)
|
|
192
|
+
ranges = date_ranges || Reports::Period.resolve(last_days: Reports::Period::DEFAULT_DAYS, compare: true)
|
|
193
|
+
Health.validate_date_ranges!(ranges)
|
|
194
|
+
definitions = Health::Definitions.for(ranges)
|
|
195
|
+
reports = data.batch(desired_state.property_id, definitions)
|
|
196
|
+
current_snapshot = snapshot
|
|
197
|
+
current_plan = Planner.new(desired_state:, snapshot: current_snapshot).call
|
|
198
|
+
Health::Runner.new(
|
|
199
|
+
desired_state:,
|
|
200
|
+
reports:,
|
|
201
|
+
plan: current_plan,
|
|
202
|
+
date_ranges: ranges
|
|
203
|
+
).call
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def governance
|
|
207
|
+
remote = admin.governance_snapshot(desired_state.property_id)
|
|
208
|
+
Governance::Auditor.new(snapshot: remote, settings: desired_state.governance).call
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def change_history(limit: 100, days: 30)
|
|
212
|
+
edit_admin.change_history(desired_state.property_id, limit:, days:)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def access_report(date_ranges: nil, limit: 1_000)
|
|
216
|
+
ranges = date_ranges || Reports::Period.resolve(last_days: Reports::Period::DEFAULT_DAYS, compare: false)
|
|
217
|
+
admin.access_report(desired_state.property_id, date_ranges: ranges, limit:)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def raw_report(name, date_ranges: nil, limit: 1_000)
|
|
221
|
+
settings = desired_state.bigquery
|
|
222
|
+
unless settings
|
|
223
|
+
raise ConfigurationError,
|
|
224
|
+
"BigQuery is not configured for this profile; see docs/bigquery.md"
|
|
225
|
+
end
|
|
226
|
+
ranges = date_ranges || Reports::Period.resolve(last_days: Reports::Period::DEFAULT_DAYS, compare: false)
|
|
227
|
+
definition = BigQuery::Definition.from_date_ranges(name, ranges, limit:)
|
|
228
|
+
bigquery_client.run(definition)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def funnel(name, date_ranges: nil)
|
|
232
|
+
definition = desired_state.funnels.fetch(name.to_s) do
|
|
233
|
+
available = desired_state.funnels.keys.sort.join(", ")
|
|
234
|
+
raise InvalidRequestError, "Unknown funnel #{name.inspect}; available funnels: #{available}"
|
|
235
|
+
end
|
|
236
|
+
unless desired_state.bigquery
|
|
237
|
+
raise UnsupportedCapabilityError,
|
|
238
|
+
"Funnel reports currently require the optional BigQuery configuration"
|
|
239
|
+
end
|
|
240
|
+
ranges = date_ranges || Reports::Period.resolve(last_days: Reports::Period::DEFAULT_DAYS, compare: false)
|
|
241
|
+
dates = BigQuery::Definition.from_date_ranges("event_counts", ranges, limit: 1)
|
|
242
|
+
bigquery_client.run_funnel(
|
|
243
|
+
definition,
|
|
244
|
+
start_suffix: dates.start_suffix,
|
|
245
|
+
end_suffix: dates.end_suffix
|
|
246
|
+
)
|
|
247
|
+
end
|
|
248
|
+
|
|
108
249
|
def doctor
|
|
109
250
|
remote = snapshot
|
|
110
251
|
checks = [
|
|
@@ -114,6 +255,7 @@ module AnalyticsOps
|
|
|
114
255
|
{ "name" => "admin_api", "status" => "ok", "detail" => "Admin API read succeeded" },
|
|
115
256
|
{ "name" => "property_access", "status" => "ok", "detail" => "Read properties/#{remote.property_id}" }
|
|
116
257
|
]
|
|
258
|
+
checks.concat(credential_file_checks)
|
|
117
259
|
checks.concat(compatibility_checks)
|
|
118
260
|
checks << clock_check
|
|
119
261
|
checks << edit_capability_check
|
|
@@ -167,6 +309,18 @@ module AnalyticsOps
|
|
|
167
309
|
)
|
|
168
310
|
end
|
|
169
311
|
|
|
312
|
+
def bigquery_client
|
|
313
|
+
return @bigquery if @bigquery
|
|
314
|
+
|
|
315
|
+
require_relative "bigquery"
|
|
316
|
+
@bigquery = Clients::BigQuery.new(
|
|
317
|
+
settings: desired_state.bigquery,
|
|
318
|
+
service_account:,
|
|
319
|
+
timeout: @timeout,
|
|
320
|
+
logger: @logger
|
|
321
|
+
)
|
|
322
|
+
end
|
|
323
|
+
|
|
170
324
|
def service_account
|
|
171
325
|
@service_account ||= ServiceAccount.load
|
|
172
326
|
end
|
|
@@ -182,6 +336,27 @@ module AnalyticsOps
|
|
|
182
336
|
end
|
|
183
337
|
end
|
|
184
338
|
|
|
339
|
+
def credential_file_checks
|
|
340
|
+
return [] unless @service_account
|
|
341
|
+
|
|
342
|
+
warnings = @service_account.security_warnings
|
|
343
|
+
if warnings.empty?
|
|
344
|
+
return [{
|
|
345
|
+
"name" => "credential_file_security",
|
|
346
|
+
"status" => "ok",
|
|
347
|
+
"detail" => "Service-account key permissions and storage location look safe"
|
|
348
|
+
}]
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
warnings.each_with_index.map do |warning, index|
|
|
352
|
+
{
|
|
353
|
+
"name" => "credential_file_security_#{index + 1}",
|
|
354
|
+
"status" => "warning",
|
|
355
|
+
"detail" => warning
|
|
356
|
+
}
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
|
|
185
360
|
def clock_check
|
|
186
361
|
now = Time.now.utc
|
|
187
362
|
plausible = now.year.between?(2024, 2100)
|
|
@@ -215,9 +390,14 @@ module AnalyticsOps
|
|
|
215
390
|
)
|
|
216
391
|
end
|
|
217
392
|
|
|
218
|
-
def
|
|
393
|
+
def resolved_report_definition(name_or_definition, kind:)
|
|
394
|
+
custom = false
|
|
219
395
|
definition = if name_or_definition.is_a?(Reports::Definition)
|
|
396
|
+
custom = true
|
|
220
397
|
name_or_definition
|
|
398
|
+
elsif desired_state.reports.key?(name_or_definition.to_s)
|
|
399
|
+
custom = true
|
|
400
|
+
desired_state.reports.fetch(name_or_definition.to_s)
|
|
221
401
|
else
|
|
222
402
|
Reports::Catalog.fetch(name_or_definition, kind:)
|
|
223
403
|
end
|
|
@@ -225,7 +405,39 @@ module AnalyticsOps
|
|
|
225
405
|
raise InvalidRequestError, "Report #{definition.name} is #{definition.kind}, not #{kind}"
|
|
226
406
|
end
|
|
227
407
|
|
|
228
|
-
definition
|
|
408
|
+
[definition, custom]
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def validate_report_definition(definition)
|
|
412
|
+
validate_report_fields!(definition)
|
|
413
|
+
return if definition.realtime?
|
|
414
|
+
|
|
415
|
+
compatibility = data.check_compatibility(desired_state.property_id, definition)
|
|
416
|
+
return if compatibility.compatible
|
|
417
|
+
|
|
418
|
+
incompatible = (compatibility.dimensions + compatibility.metrics).reject do |entry|
|
|
419
|
+
entry.fetch("compatibility") == "compatible"
|
|
420
|
+
end
|
|
421
|
+
names = incompatible.map { |entry| entry.fetch("api_name") }.join(", ")
|
|
422
|
+
raise InvalidRequestError, "Report fields are not compatible for this property: #{names}"
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def validate_report_fields!(definition)
|
|
426
|
+
metadata = data.metadata(desired_state.property_id)
|
|
427
|
+
available_dimensions = metadata.dimensions.map(&:api_name)
|
|
428
|
+
available_metrics = metadata.metrics.map(&:api_name)
|
|
429
|
+
missing_dimensions = definition.dimensions - available_dimensions
|
|
430
|
+
missing_metrics = definition.metrics - available_metrics
|
|
431
|
+
return if missing_dimensions.empty? && missing_metrics.empty?
|
|
432
|
+
|
|
433
|
+
missing = (missing_dimensions + missing_metrics).join(", ")
|
|
434
|
+
raise InvalidRequestError, "Unknown report fields for this property: #{missing}"
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
def bounded_integer(value, label, maximum:)
|
|
438
|
+
return value if value.is_a?(Integer) && value.between?(1, maximum)
|
|
439
|
+
|
|
440
|
+
raise InvalidRequestError, "#{label} must be between 1 and #{maximum}"
|
|
229
441
|
end
|
|
230
442
|
|
|
231
443
|
def validate_plan_target!(saved_plan)
|
data/lib/analytics_ops.rb
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "analytics_ops/version"
|
|
4
|
-
require_relative "analytics_ops/errors"
|
|
5
4
|
require_relative "analytics_ops/redaction"
|
|
5
|
+
require_relative "analytics_ops/errors"
|
|
6
6
|
require_relative "analytics_ops/service_account"
|
|
7
7
|
require_relative "analytics_ops/canonical"
|
|
8
8
|
require_relative "analytics_ops/resources"
|
|
9
|
+
require_relative "analytics_ops/reports"
|
|
10
|
+
require_relative "analytics_ops/bigquery/definition"
|
|
11
|
+
require_relative "analytics_ops/funnels"
|
|
12
|
+
require_relative "analytics_ops/governance"
|
|
9
13
|
require_relative "analytics_ops/desired_state"
|
|
10
14
|
require_relative "analytics_ops/configuration"
|
|
11
15
|
require_relative "analytics_ops/snapshot"
|
|
@@ -13,12 +17,13 @@ require_relative "analytics_ops/plan"
|
|
|
13
17
|
require_relative "analytics_ops/planner"
|
|
14
18
|
require_relative "analytics_ops/clients/error_translation"
|
|
15
19
|
require_relative "analytics_ops/clients/admin"
|
|
16
|
-
require_relative "analytics_ops/reports"
|
|
17
20
|
require_relative "analytics_ops/clients/data"
|
|
21
|
+
require_relative "analytics_ops/health"
|
|
18
22
|
require_relative "analytics_ops/connection"
|
|
19
23
|
require_relative "analytics_ops/setup"
|
|
20
24
|
require_relative "analytics_ops/applier"
|
|
21
25
|
require_relative "analytics_ops/workspace"
|
|
26
|
+
require_relative "analytics_ops/portfolio"
|
|
22
27
|
|
|
23
28
|
# Safe Google Analytics 4 configuration and reporting operations.
|
|
24
29
|
module AnalyticsOps
|