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.
Files changed (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +108 -0
  3. data/CONTRIBUTING.md +1 -0
  4. data/README.md +141 -13
  5. data/SECURITY.md +11 -2
  6. data/docs/ai-connections.md +192 -0
  7. data/docs/api-support-matrix.md +25 -8
  8. data/docs/architecture.md +78 -17
  9. data/docs/authentication.md +53 -3
  10. data/docs/bigquery.md +128 -0
  11. data/docs/commands.md +133 -12
  12. data/docs/configuration-schema-v1.json +608 -50
  13. data/docs/configuration.md +118 -5
  14. data/docs/google-client-compatibility.md +21 -1
  15. data/docs/governance.md +93 -0
  16. data/docs/health.md +82 -0
  17. data/docs/live-smoke-test.md +13 -10
  18. data/docs/rails.md +25 -9
  19. data/docs/reports.md +165 -3
  20. data/docs/safety.md +37 -5
  21. data/docs/troubleshooting.md +81 -7
  22. data/lib/analytics_ops/bigquery/definition.rb +173 -0
  23. data/lib/analytics_ops/bigquery.rb +4 -0
  24. data/lib/analytics_ops/cli/local_commands.rb +80 -0
  25. data/lib/analytics_ops/cli/options.rb +282 -9
  26. data/lib/analytics_ops/cli/presenter.rb +263 -28
  27. data/lib/analytics_ops/cli/presenter_csv.rb +92 -0
  28. data/lib/analytics_ops/cli/reporting_commands.rb +129 -0
  29. data/lib/analytics_ops/cli/setup_commands.rb +91 -0
  30. data/lib/analytics_ops/cli.rb +121 -44
  31. data/lib/analytics_ops/clients/admin.rb +28 -2
  32. data/lib/analytics_ops/clients/admin_governance.rb +95 -0
  33. data/lib/analytics_ops/clients/admin_governance_normalization.rb +90 -0
  34. data/lib/analytics_ops/clients/admin_resource_normalization.rb +61 -0
  35. data/lib/analytics_ops/clients/bigquery.rb +313 -0
  36. data/lib/analytics_ops/clients/data.rb +147 -54
  37. data/lib/analytics_ops/clients/data_result_normalization.rb +55 -0
  38. data/lib/analytics_ops/clients/error_translation.rb +13 -1
  39. data/lib/analytics_ops/configuration/schema.rb +175 -1
  40. data/lib/analytics_ops/configuration/validator.rb +45 -7
  41. data/lib/analytics_ops/configuration/validator_experimental.rb +136 -0
  42. data/lib/analytics_ops/configuration/validator_reporting.rb +93 -0
  43. data/lib/analytics_ops/configuration/writer.rb +112 -7
  44. data/lib/analytics_ops/desired_state.rb +28 -3
  45. data/lib/analytics_ops/errors.rb +33 -1
  46. data/lib/analytics_ops/funnels.rb +58 -0
  47. data/lib/analytics_ops/governance.rb +363 -0
  48. data/lib/analytics_ops/health.rb +310 -0
  49. data/lib/analytics_ops/mcp_server/custom_report_tool.rb +101 -0
  50. data/lib/analytics_ops/mcp_server/discovery_tools.rb +79 -0
  51. data/lib/analytics_ops/mcp_server/experimental_tools.rb +89 -0
  52. data/lib/analytics_ops/mcp_server/health_tools.rb +80 -0
  53. data/lib/analytics_ops/mcp_server/overview_tools.rb +49 -0
  54. data/lib/analytics_ops/mcp_server/standard_report_tool.rb +53 -0
  55. data/lib/analytics_ops/mcp_server.rb +402 -0
  56. data/lib/analytics_ops/portfolio.rb +216 -0
  57. data/lib/analytics_ops/rails/railtie.rb +19 -8
  58. data/lib/analytics_ops/redaction.rb +10 -6
  59. data/lib/analytics_ops/reports/csv_export.rb +91 -0
  60. data/lib/analytics_ops/reports/definition.rb +152 -14
  61. data/lib/analytics_ops/reports/metadata.rb +158 -0
  62. data/lib/analytics_ops/reports/period.rb +104 -0
  63. data/lib/analytics_ops/reports/result.rb +8 -3
  64. data/lib/analytics_ops/reports.rb +3 -0
  65. data/lib/analytics_ops/service_account.rb +321 -29
  66. data/lib/analytics_ops/setup.rb +31 -7
  67. data/lib/analytics_ops/version.rb +1 -1
  68. data/lib/analytics_ops/workspace.rb +222 -10
  69. data/lib/analytics_ops.rb +7 -2
  70. data/lib/generators/analytics_ops/templates/analytics_ops.yml +1 -1
  71. data/sig/analytics_ops.rbs +494 -9
  72. 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
- raise RemoteError, "Report result rows must be an array" unless values.is_a?(Array)
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.