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
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AnalyticsOps
|
|
4
|
+
module Reports
|
|
5
|
+
# One gem-owned field returned by the GA4 metadata endpoint.
|
|
6
|
+
class Field < Resources::Value
|
|
7
|
+
fields :api_name, :ui_name, :description, :category, :kind,
|
|
8
|
+
:custom_definition, :deprecated_api_names, :data_type,
|
|
9
|
+
:expression, :blocked_reasons
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Immutable property-aware catalog of report dimensions and metrics.
|
|
13
|
+
class Metadata
|
|
14
|
+
attr_reader :property_id, :dimensions, :metrics
|
|
15
|
+
|
|
16
|
+
def initialize(property_id:, dimensions:, metrics:)
|
|
17
|
+
unless property_id.is_a?(String) && property_id.match?(/\A\d{1,50}\z/)
|
|
18
|
+
raise RemoteError, "Report metadata property ID is invalid"
|
|
19
|
+
end
|
|
20
|
+
unless dimensions.is_a?(Array) && dimensions.all?(Field) &&
|
|
21
|
+
metrics.is_a?(Array) && metrics.all?(Field)
|
|
22
|
+
raise RemoteError, "Report metadata fields are invalid"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
@property_id = property_id.dup.freeze
|
|
26
|
+
@dimensions = dimensions.dup.freeze
|
|
27
|
+
@metrics = metrics.dup.freeze
|
|
28
|
+
freeze
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def search(query = nil, kind: nil)
|
|
32
|
+
values = fields_for(kind)
|
|
33
|
+
return values.freeze if query.nil?
|
|
34
|
+
|
|
35
|
+
validate_query!(query)
|
|
36
|
+
return values.freeze if query.empty?
|
|
37
|
+
|
|
38
|
+
needle = query.downcase
|
|
39
|
+
matches = values.select do |field|
|
|
40
|
+
[field.api_name, field.ui_name, field.description, field.category].compact.any? do |value|
|
|
41
|
+
value.downcase.include?(needle)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
matches.freeze
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def to_h
|
|
48
|
+
{
|
|
49
|
+
"property_id" => property_id,
|
|
50
|
+
"dimensions" => dimensions.map(&:to_h),
|
|
51
|
+
"metrics" => metrics.map(&:to_h)
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def fields_for(kind)
|
|
58
|
+
case kind&.to_s
|
|
59
|
+
when nil then dimensions + metrics
|
|
60
|
+
when "dimension" then dimensions
|
|
61
|
+
when "metric" then metrics
|
|
62
|
+
else raise InvalidRequestError, "Field kind must be dimension or metric"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def validate_query!(value)
|
|
67
|
+
return if value.is_a?(String) && value.length <= 128 && !value.match?(/[\u0000-\u001f\u007f]/)
|
|
68
|
+
|
|
69
|
+
raise InvalidRequestError, "Field search must be a printable string of at most 128 characters"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Immutable search result with enough context for human and JSON output.
|
|
74
|
+
class FieldList
|
|
75
|
+
attr_reader :property_id, :query, :kind, :fields
|
|
76
|
+
|
|
77
|
+
def initialize(property_id:, query:, kind:, fields:)
|
|
78
|
+
@property_id = property_id.dup.freeze
|
|
79
|
+
@query = query&.dup&.freeze
|
|
80
|
+
@kind = kind&.dup&.freeze
|
|
81
|
+
@fields = fields.dup.freeze
|
|
82
|
+
freeze
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def to_h
|
|
86
|
+
{
|
|
87
|
+
"property_id" => property_id,
|
|
88
|
+
"query" => query,
|
|
89
|
+
"kind" => kind,
|
|
90
|
+
"fields" => fields.map(&:to_h)
|
|
91
|
+
}
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# One discoverable built-in or configured report recipe.
|
|
96
|
+
class Recipe < Resources::Value
|
|
97
|
+
fields :name, :kind, :source, :dimensions, :metrics
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Immutable list of discoverable report recipes.
|
|
101
|
+
class RecipeList
|
|
102
|
+
attr_reader :recipes
|
|
103
|
+
|
|
104
|
+
def initialize(recipes:)
|
|
105
|
+
raise ArgumentError, "recipes must contain report recipes" unless recipes.is_a?(Array) && recipes.all?(Recipe)
|
|
106
|
+
|
|
107
|
+
@recipes = recipes.dup.freeze
|
|
108
|
+
freeze
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def to_h
|
|
112
|
+
{ "recipes" => recipes.map(&:to_h) }
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Property-aware compatibility result for one immutable report definition.
|
|
117
|
+
class Compatibility
|
|
118
|
+
attr_reader :report_name, :compatible, :dimensions, :metrics
|
|
119
|
+
|
|
120
|
+
def initialize(report_name:, dimensions:, metrics:)
|
|
121
|
+
@report_name = validate_name(report_name)
|
|
122
|
+
@dimensions = normalize_entries(dimensions, "dimensions")
|
|
123
|
+
@metrics = normalize_entries(metrics, "metrics")
|
|
124
|
+
@compatible = (@dimensions + @metrics).all? { |entry| entry.fetch("compatibility") == "compatible" }
|
|
125
|
+
freeze
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def to_h
|
|
129
|
+
{
|
|
130
|
+
"report_name" => report_name,
|
|
131
|
+
"compatible" => compatible,
|
|
132
|
+
"dimensions" => dimensions,
|
|
133
|
+
"metrics" => metrics
|
|
134
|
+
}
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
private
|
|
138
|
+
|
|
139
|
+
def validate_name(value)
|
|
140
|
+
return value.dup.freeze if value.is_a?(String) && Definition::REPORT_NAME.match?(value)
|
|
141
|
+
|
|
142
|
+
raise RemoteError, "Compatibility report name is invalid"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def normalize_entries(values, label)
|
|
146
|
+
valid = values.is_a?(Array) && values.all? do |entry|
|
|
147
|
+
entry.is_a?(Hash) &&
|
|
148
|
+
entry.keys.sort == %w[api_name compatibility] &&
|
|
149
|
+
entry["api_name"].is_a?(String) &&
|
|
150
|
+
entry["compatibility"].is_a?(String)
|
|
151
|
+
end
|
|
152
|
+
raise RemoteError, "Compatibility #{label} are invalid" unless valid
|
|
153
|
+
|
|
154
|
+
Canonical.immutable(values)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module AnalyticsOps
|
|
6
|
+
module Reports
|
|
7
|
+
# Builds bounded GA4 date ranges for simple CLI and AI report requests.
|
|
8
|
+
module Period
|
|
9
|
+
DEFAULT_DAYS = 28
|
|
10
|
+
MAX_DAYS = 1_825
|
|
11
|
+
ISO_DATE = /\A\d{4}-\d{2}-\d{2}\z/
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def resolve(last_days: nil, start_date: nil, end_date: nil, compare: false)
|
|
16
|
+
validate_compare!(compare)
|
|
17
|
+
validate_combination!(last_days:, start_date:, end_date:)
|
|
18
|
+
return nil if last_days.nil? && start_date.nil? && !compare
|
|
19
|
+
|
|
20
|
+
if start_date
|
|
21
|
+
absolute_ranges(start_date:, end_date:, compare:)
|
|
22
|
+
else
|
|
23
|
+
relative_ranges(last_days || DEFAULT_DAYS, compare)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def relative_ranges(days, compare)
|
|
28
|
+
validate_days!(days)
|
|
29
|
+
current = {
|
|
30
|
+
"start_date" => "#{days}daysAgo",
|
|
31
|
+
"end_date" => "yesterday"
|
|
32
|
+
}
|
|
33
|
+
return Canonical.immutable([current]) unless compare
|
|
34
|
+
|
|
35
|
+
current["name"] = "current"
|
|
36
|
+
previous = {
|
|
37
|
+
"start_date" => "#{days * 2}daysAgo",
|
|
38
|
+
"end_date" => "#{days + 1}daysAgo",
|
|
39
|
+
"name" => "previous"
|
|
40
|
+
}
|
|
41
|
+
Canonical.immutable([current, previous])
|
|
42
|
+
end
|
|
43
|
+
private_class_method :relative_ranges
|
|
44
|
+
|
|
45
|
+
def absolute_ranges(start_date:, end_date:, compare:)
|
|
46
|
+
start_value = parse_date!(start_date, "--from")
|
|
47
|
+
end_value = parse_date!(end_date, "--to")
|
|
48
|
+
raise InvalidRequestError, "--from must not be after --to" if start_value > end_value
|
|
49
|
+
|
|
50
|
+
current = {
|
|
51
|
+
"start_date" => start_value.iso8601,
|
|
52
|
+
"end_date" => end_value.iso8601
|
|
53
|
+
}
|
|
54
|
+
days = (end_value - start_value).to_i + 1
|
|
55
|
+
raise InvalidRequestError, "Date range cannot exceed #{MAX_DAYS} days" if days > MAX_DAYS
|
|
56
|
+
return Canonical.immutable([current]) unless compare
|
|
57
|
+
|
|
58
|
+
previous_end = start_value - 1
|
|
59
|
+
previous_start = previous_end - (days - 1)
|
|
60
|
+
current["name"] = "current"
|
|
61
|
+
previous = {
|
|
62
|
+
"start_date" => previous_start.iso8601,
|
|
63
|
+
"end_date" => previous_end.iso8601,
|
|
64
|
+
"name" => "previous"
|
|
65
|
+
}
|
|
66
|
+
Canonical.immutable([current, previous])
|
|
67
|
+
end
|
|
68
|
+
private_class_method :absolute_ranges
|
|
69
|
+
|
|
70
|
+
def validate_combination!(last_days:, start_date:, end_date:)
|
|
71
|
+
if last_days && (start_date || end_date)
|
|
72
|
+
raise InvalidRequestError, "Use either --last or --from with --to, not both"
|
|
73
|
+
end
|
|
74
|
+
return if start_date.nil? == end_date.nil?
|
|
75
|
+
|
|
76
|
+
raise InvalidRequestError, "--from and --to must be used together"
|
|
77
|
+
end
|
|
78
|
+
private_class_method :validate_combination!
|
|
79
|
+
|
|
80
|
+
def validate_days!(value)
|
|
81
|
+
return if value.is_a?(Integer) && value.between?(1, MAX_DAYS)
|
|
82
|
+
|
|
83
|
+
raise InvalidRequestError, "--last must be between 1 and #{MAX_DAYS} days"
|
|
84
|
+
end
|
|
85
|
+
private_class_method :validate_days!
|
|
86
|
+
|
|
87
|
+
def validate_compare!(value)
|
|
88
|
+
return if [true, false].include?(value)
|
|
89
|
+
|
|
90
|
+
raise InvalidRequestError, "--compare must be true or false"
|
|
91
|
+
end
|
|
92
|
+
private_class_method :validate_compare!
|
|
93
|
+
|
|
94
|
+
def parse_date!(value, option)
|
|
95
|
+
raise InvalidRequestError, "#{option} must use YYYY-MM-DD" unless value.is_a?(String) && ISO_DATE.match?(value)
|
|
96
|
+
|
|
97
|
+
Date.iso8601(value)
|
|
98
|
+
rescue Date::Error
|
|
99
|
+
raise InvalidRequestError, "#{option} must be a real calendar date"
|
|
100
|
+
end
|
|
101
|
+
private_class_method :parse_date!
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -4,6 +4,9 @@ module AnalyticsOps
|
|
|
4
4
|
module Reports
|
|
5
5
|
# Immutable normalized Data API response.
|
|
6
6
|
class Result
|
|
7
|
+
MAX_ROWS = Definition::MAX_STANDARD_LIMIT
|
|
8
|
+
MAX_CELL_BYTES = 1_048_576
|
|
9
|
+
|
|
7
10
|
attr_reader :name, :kind, :dimension_headers, :metric_headers, :headers, :rows, :row_count, :metadata
|
|
8
11
|
|
|
9
12
|
def initialize(name:, kind:, dimension_headers:, metric_headers:, rows:, row_count:, metadata:)
|
|
@@ -59,13 +62,15 @@ module AnalyticsOps
|
|
|
59
62
|
end
|
|
60
63
|
|
|
61
64
|
def normalized_rows(values)
|
|
62
|
-
|
|
65
|
+
unless values.is_a?(Array) && values.length <= MAX_ROWS
|
|
66
|
+
raise RemoteError, "Report result rows must be a bounded array"
|
|
67
|
+
end
|
|
63
68
|
|
|
64
69
|
normalized = values.map do |row|
|
|
65
70
|
value = hash(row, "row")
|
|
66
71
|
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"
|
|
72
|
+
unless value.values.all? { |item| valid_utf8?(item) && item.bytesize <= MAX_CELL_BYTES }
|
|
73
|
+
raise RemoteError, "Report result row values must be bounded valid UTF-8 strings"
|
|
69
74
|
end
|
|
70
75
|
|
|
71
76
|
value
|
|
@@ -3,10 +3,13 @@
|
|
|
3
3
|
# Reporting public namespace.
|
|
4
4
|
|
|
5
5
|
require_relative "reports/definition"
|
|
6
|
+
require_relative "reports/metadata"
|
|
7
|
+
require_relative "reports/period"
|
|
6
8
|
require_relative "reports/catalog"
|
|
7
9
|
require_relative "reports/overview"
|
|
8
10
|
require_relative "reports/result"
|
|
9
11
|
require_relative "reports/overview_result"
|
|
12
|
+
require_relative "reports/csv_export"
|
|
10
13
|
|
|
11
14
|
module AnalyticsOps
|
|
12
15
|
# Immutable Data API definitions, recipes, and normalized results.
|