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,310 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+
5
+ module AnalyticsOps
6
+ # Deterministic, read-only analytics quality checks.
7
+ module Health
8
+ DEFAULTS = Canonical.deep_freeze(
9
+ "expected_events" => [],
10
+ "minimum_sessions" => 1,
11
+ "traffic_drop_percent" => 50.0,
12
+ "key_event_drop_percent" => 50.0,
13
+ "unassigned_percent" => 20.0,
14
+ "not_set_percent" => 20.0
15
+ )
16
+
17
+ module_function
18
+
19
+ def validate_date_ranges!(value)
20
+ valid = value.is_a?(Array) &&
21
+ value.length == 2 &&
22
+ value.all? { |range| range.is_a?(Hash) && range.keys.all?(String) } &&
23
+ value.map { |range| range["name"] } == %w[current previous]
24
+ return value if valid
25
+
26
+ raise InvalidRequestError, "Health requires current and previous comparison date ranges"
27
+ end
28
+
29
+ # One deterministic analytics health observation.
30
+ class Finding < Resources::Value
31
+ fields :severity, :code, :message, :actual, :expected
32
+ end
33
+
34
+ # Immutable health summary and findings for one profile.
35
+ class Result
36
+ attr_reader :profile, :property_id, :date_ranges, :findings, :summary
37
+
38
+ def initialize(profile:, property_id:, date_ranges:, findings:, summary:)
39
+ @profile = Canonical.immutable(profile)
40
+ @property_id = Canonical.immutable(property_id)
41
+ @date_ranges = Canonical.immutable(date_ranges)
42
+ @findings = findings.dup.freeze
43
+ @summary = Canonical.immutable(summary)
44
+ freeze
45
+ end
46
+
47
+ def healthy?
48
+ findings.none? { |finding| %w[warning error].include?(finding.severity) }
49
+ end
50
+
51
+ def to_h
52
+ {
53
+ "healthy" => healthy?,
54
+ "profile" => profile,
55
+ "property_id" => property_id,
56
+ "date_ranges" => date_ranges,
57
+ "summary" => summary,
58
+ "findings" => findings.map(&:to_h)
59
+ }
60
+ end
61
+ end
62
+
63
+ # Internal report definitions used to calculate health.
64
+ module Definitions
65
+ module_function
66
+
67
+ def for(date_ranges)
68
+ [
69
+ Reports::Definition.new(
70
+ name: "health_totals",
71
+ kind: "standard",
72
+ dimensions: [],
73
+ metrics: %w[activeUsers sessions keyEvents],
74
+ date_ranges:,
75
+ limit: 2
76
+ ),
77
+ Reports::Definition.new(
78
+ name: "health_acquisition",
79
+ kind: "standard",
80
+ dimensions: ["sessionDefaultChannelGroup"],
81
+ metrics: ["sessions"],
82
+ date_ranges:,
83
+ limit: 250
84
+ ),
85
+ Reports::Definition.new(
86
+ name: "health_events",
87
+ kind: "standard",
88
+ dimensions: ["eventName"],
89
+ metrics: ["eventCount"],
90
+ date_ranges:,
91
+ limit: 10_000
92
+ )
93
+ ].freeze
94
+ end
95
+ end
96
+
97
+ # Evaluates bounded report results and configuration drift.
98
+ class Runner
99
+ def initialize(desired_state:, reports:, plan:, date_ranges:)
100
+ @desired_state = desired_state
101
+ @reports = reports
102
+ @plan = plan
103
+ @date_ranges = date_ranges
104
+ @settings = DEFAULTS.merge(desired_state.health || {})
105
+ end
106
+
107
+ def call
108
+ validate_inputs!
109
+ findings = []
110
+ totals = period_totals
111
+ findings.concat(traffic_findings(totals))
112
+ findings.concat(acquisition_findings(totals))
113
+ findings.concat(expected_event_findings)
114
+ findings.concat(metadata_findings)
115
+ findings.concat(drift_findings)
116
+ if findings.empty?
117
+ findings << Finding.new(
118
+ severity: "ok",
119
+ code: "checks_passed",
120
+ message: "No configured analytics health problem was detected",
121
+ actual: nil,
122
+ expected: nil
123
+ )
124
+ end
125
+
126
+ Result.new(
127
+ profile: @desired_state.profile,
128
+ property_id: @desired_state.property_id,
129
+ date_ranges: @date_ranges,
130
+ findings:,
131
+ summary: {
132
+ "current" => totals.fetch("current"),
133
+ "previous" => totals.fetch("previous")
134
+ }
135
+ )
136
+ end
137
+
138
+ private
139
+
140
+ def validate_inputs!
141
+ Health.validate_date_ranges!(@date_ranges)
142
+ expected_names = %w[health_totals health_acquisition health_events]
143
+ unless @reports.is_a?(Array) && @reports.map(&:name) == expected_names
144
+ raise RemoteError, "Health reports returned an unexpected shape"
145
+ end
146
+ raise RemoteError, "Health audit requires a plan" unless @plan.is_a?(Plan)
147
+ end
148
+
149
+ def period_totals
150
+ rows = report("health_totals").rows
151
+ indexed = rows.to_h { |row| [row.fetch("dateRange", "current"), row] }
152
+ zero = { "activeUsers" => "0", "sessions" => "0", "keyEvents" => "0" }
153
+ {
154
+ "current" => zero.merge(indexed.fetch("current", {})),
155
+ "previous" => zero.merge(indexed.fetch("previous", {}))
156
+ }
157
+ end
158
+
159
+ def traffic_findings(totals)
160
+ current = totals.fetch("current")
161
+ previous = totals.fetch("previous")
162
+ findings = []
163
+ sessions = number(current.fetch("sessions"))
164
+ if sessions < @settings.fetch("minimum_sessions")
165
+ findings << finding(
166
+ "warning", "low_traffic", "Traffic is below the configured minimum",
167
+ sessions, "at least #{@settings.fetch("minimum_sessions")} sessions"
168
+ )
169
+ end
170
+ findings.concat(drop_finding("traffic", sessions, number(previous.fetch("sessions")),
171
+ @settings.fetch("traffic_drop_percent")))
172
+ findings.concat(drop_finding("key_events", number(current.fetch("keyEvents")),
173
+ number(previous.fetch("keyEvents")),
174
+ @settings.fetch("key_event_drop_percent")))
175
+ findings
176
+ end
177
+
178
+ def drop_finding(name, current, previous, threshold)
179
+ return [] unless previous.positive?
180
+
181
+ drop = ((previous - current) / previous) * 100
182
+ return [] unless drop.positive?
183
+ return [] if drop < threshold
184
+
185
+ [finding(
186
+ "warning",
187
+ "#{name}_drop",
188
+ "#{name.tr("_", " ").capitalize} fell compared with the preceding period",
189
+ "#{rounded(drop)}% drop",
190
+ "less than #{rounded(threshold)}% drop"
191
+ )]
192
+ end
193
+
194
+ def acquisition_findings(totals)
195
+ total = number(totals.fetch("current").fetch("sessions"))
196
+ return [] unless total.positive?
197
+
198
+ current_rows = report("health_acquisition").rows.select do |row|
199
+ row.fetch("dateRange", "current") == "current"
200
+ end
201
+ {
202
+ "unassigned" => ["Unassigned", @settings.fetch("unassigned_percent")],
203
+ "not_set" => ["(not set)", @settings.fetch("not_set_percent")]
204
+ }.filter_map do |code, (label, threshold)|
205
+ matching_rows = current_rows.select { |row| row.fetch("sessionDefaultChannelGroup") == label }
206
+ sessions = matching_rows.sum { |row| number(row.fetch("sessions")) }
207
+ percent = (sessions / total) * 100
208
+ next unless percent.positive?
209
+ next if percent < threshold
210
+
211
+ finding(
212
+ "warning",
213
+ "high_#{code}",
214
+ "#{label} traffic is above the configured threshold",
215
+ "#{rounded(percent)}%",
216
+ "less than #{rounded(threshold)}%"
217
+ )
218
+ end
219
+ end
220
+
221
+ def expected_event_findings
222
+ observed_rows = report("health_events").rows.select do |row|
223
+ row.fetch("dateRange", "current") == "current" && number(row.fetch("eventCount")).positive?
224
+ end
225
+ observed = observed_rows.map { |row| row.fetch("eventName") }
226
+ @settings.fetch("expected_events").filter_map do |name|
227
+ next if observed.include?(name)
228
+
229
+ finding(
230
+ "warning", "missing_expected_event", "An expected event did not appear in the current period",
231
+ "not observed: #{name}", "event #{name}"
232
+ )
233
+ end
234
+ end
235
+
236
+ def metadata_findings
237
+ findings = @reports.flat_map { |result| report_metadata_findings(result) }
238
+ findings.uniq { |item| [item.code, item.actual] }
239
+ end
240
+
241
+ def report_metadata_findings(result)
242
+ findings = threshold_findings(result)
243
+ tokens = (result.metadata["property_quota"] || {})["tokens_per_day"]
244
+ findings.concat(quota_findings(tokens))
245
+ end
246
+
247
+ def threshold_findings(result)
248
+ findings = []
249
+ if result.metadata["subject_to_thresholding"]
250
+ findings << finding(
251
+ "warning", "thresholded_data", "Google reports that privacy thresholding affected this result",
252
+ result.name, "unthresholded result"
253
+ )
254
+ end
255
+ unless Array(result.metadata["sampling"]).empty?
256
+ findings << finding(
257
+ "warning", "sampled_data", "Google reports that sampling affected this result",
258
+ result.name, "unsampled result"
259
+ )
260
+ end
261
+ findings
262
+ end
263
+
264
+ def quota_findings(tokens)
265
+ return [] unless tokens
266
+
267
+ total = tokens.fetch("consumed") + tokens.fetch("remaining")
268
+ return [] unless total.positive? && tokens.fetch("remaining").fdiv(total) < 0.1
269
+
270
+ [finding(
271
+ "warning", "low_daily_quota", "Fewer than 10% of daily Data API tokens remain",
272
+ tokens.fetch("remaining"), "at least 10% remaining"
273
+ )]
274
+ end
275
+
276
+ def drift_findings
277
+ return [] unless @plan.drift?
278
+
279
+ [finding(
280
+ "warning",
281
+ "configuration_drift",
282
+ "Managed GA4 configuration differs from the saved configuration",
283
+ "#{@plan.changes.length} changes and #{@plan.findings.length} findings",
284
+ "no drift"
285
+ )]
286
+ end
287
+
288
+ def report(name)
289
+ @reports.find { |value| value.name == name } ||
290
+ raise(RemoteError, "Missing health report #{name}")
291
+ end
292
+
293
+ def number(value)
294
+ return BigDecimal(value) if value.is_a?(String) && value.match?(/\A-?\d+(?:\.\d+)?\z/)
295
+
296
+ raise RemoteError, "Health report contained a nonnumeric metric"
297
+ rescue ArgumentError
298
+ raise RemoteError, "Health report contained a nonnumeric metric"
299
+ end
300
+
301
+ def rounded(value)
302
+ format("%.1f", value)
303
+ end
304
+
305
+ def finding(severity, code, message, actual, expected)
306
+ Finding.new(severity:, code:, message:, actual: actual&.to_s, expected: expected&.to_s)
307
+ end
308
+ end
309
+ end
310
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnalyticsOps
4
+ # Reopens the read-only MCP server to add custom reporting tools.
5
+ class MCPServer
6
+ # Bounded custom-report MCP tool with property-aware validation.
7
+ module CustomReportTool
8
+ FIELD_PATTERN = "^[a-z][a-zA-Z0-9_]*(?::[a-zA-Z][a-zA-Z0-9_]*)?$"
9
+
10
+ private
11
+
12
+ def define_custom_report_tool
13
+ owner = self
14
+ server.define_tool(
15
+ name: "analytics_run_custom_report",
16
+ title: "Run Custom Analytics Report",
17
+ description: "Run a bounded, property-validated custom GA4 report. This tool is read-only.",
18
+ input_schema: custom_report_schema,
19
+ output_schema: OBJECT_OUTPUT,
20
+ annotations: READ_ONLY_ANNOTATIONS
21
+ ) do |metrics:, profile: nil, dimensions: [], filters: [], limit: 100,
22
+ last_days: nil, start_date: nil, end_date: nil, compare: false, server_context: nil|
23
+ owner.__send__(:respond, server_context) do
24
+ ranges = owner.__send__(:date_ranges, last_days:, start_date:, end_date:, compare:) ||
25
+ Reports::Catalog::STANDARD_DATE_RANGE
26
+ definition = owner.__send__(
27
+ :mcp_custom_definition,
28
+ dimensions:,
29
+ metrics:,
30
+ filters:,
31
+ date_ranges: ranges,
32
+ limit:
33
+ )
34
+ owner.__send__(:workspace, profile).report(definition)
35
+ end
36
+ end
37
+ end
38
+
39
+ def custom_report_schema
40
+ {
41
+ type: "object",
42
+ additionalProperties: false,
43
+ required: ["metrics"],
44
+ properties: PROFILE_INPUT.fetch(:properties).merge(PERIOD_PROPERTIES).merge(
45
+ dimensions: custom_field_array(9, minimum: 0),
46
+ metrics: custom_field_array(10, minimum: 1),
47
+ filters: custom_filter_schema,
48
+ limit: { type: "integer", minimum: 1, maximum: 1_000 }
49
+ )
50
+ }
51
+ end
52
+
53
+ def custom_field_array(maximum, minimum:)
54
+ {
55
+ type: "array",
56
+ minItems: minimum,
57
+ maxItems: maximum,
58
+ uniqueItems: true,
59
+ items: { type: "string", pattern: FIELD_PATTERN }
60
+ }
61
+ end
62
+
63
+ def custom_filter_schema
64
+ {
65
+ type: "array",
66
+ maxItems: 20,
67
+ items: {
68
+ type: "object",
69
+ additionalProperties: false,
70
+ required: %w[field value],
71
+ properties: {
72
+ field: { type: "string", pattern: FIELD_PATTERN },
73
+ value: { type: "string", minLength: 1, maxLength: 1_024 }
74
+ }
75
+ }
76
+ }
77
+ end
78
+
79
+ def mcp_custom_definition(dimensions:, metrics:, filters:, date_ranges:, limit:)
80
+ expressions = filters.map do |filter|
81
+ {
82
+ "field" => filter.fetch(:field, filter["field"]),
83
+ "match_type" => "exact",
84
+ "value" => filter.fetch(:value, filter["value"])
85
+ }
86
+ end
87
+ Reports::Definition.new(
88
+ name: "ai_custom_report",
89
+ kind: "standard",
90
+ dimensions:,
91
+ metrics:,
92
+ date_ranges:,
93
+ dimension_filter: expressions.length > 1 ? { "and" => expressions } : expressions.first,
94
+ limit:
95
+ )
96
+ end
97
+ end
98
+
99
+ include CustomReportTool
100
+ end
101
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnalyticsOps
4
+ # Reopens the read-only MCP server to add report discovery tools.
5
+ class MCPServer
6
+ # Report catalog, field metadata, and compatibility MCP tools.
7
+ module DiscoveryTools
8
+ private
9
+
10
+ def define_report_discovery_tools
11
+ define_report_list_tool
12
+ define_field_list_tool
13
+ define_compatibility_tool
14
+ end
15
+
16
+ def define_report_list_tool
17
+ owner = self
18
+ server.define_tool(
19
+ name: "analytics_list_reports",
20
+ title: "List Analytics Reports",
21
+ description: "List safe built-in and locally configured report recipes.",
22
+ input_schema: PROFILE_INPUT,
23
+ output_schema: OBJECT_OUTPUT,
24
+ annotations: READ_ONLY_ANNOTATIONS
25
+ ) do |profile: nil, server_context: nil|
26
+ owner.__send__(:respond, server_context) { owner.__send__(:workspace, profile).report_recipes }
27
+ end
28
+ end
29
+
30
+ def define_field_list_tool
31
+ owner = self
32
+ server.define_tool(
33
+ name: "analytics_list_report_fields",
34
+ title: "Find Analytics Report Fields",
35
+ description: "Search property-aware GA4 dimensions and metrics before constructing a report.",
36
+ input_schema: {
37
+ type: "object",
38
+ additionalProperties: false,
39
+ properties: PROFILE_INPUT.fetch(:properties).merge(
40
+ query: { type: "string", minLength: 1, maxLength: 128 },
41
+ kind: { type: "string", enum: %w[dimension metric] }
42
+ )
43
+ },
44
+ output_schema: OBJECT_OUTPUT,
45
+ annotations: READ_ONLY_ANNOTATIONS
46
+ ) do |profile: nil, query: nil, kind: nil, server_context: nil|
47
+ owner.__send__(:respond, server_context) do
48
+ owner.__send__(:workspace, profile).report_metadata(query:, kind:)
49
+ end
50
+ end
51
+ end
52
+
53
+ def define_compatibility_tool
54
+ owner = self
55
+ server.define_tool(
56
+ name: "analytics_check_report",
57
+ title: "Check Analytics Report Compatibility",
58
+ description: "Check whether a built-in or configured report's fields work together for this property.",
59
+ input_schema: {
60
+ type: "object",
61
+ additionalProperties: false,
62
+ required: ["report"],
63
+ properties: PROFILE_INPUT.fetch(:properties).merge(
64
+ report: { type: "string", pattern: "^[a-z][a-z0-9_-]{0,63}$" }
65
+ )
66
+ },
67
+ output_schema: OBJECT_OUTPUT,
68
+ annotations: READ_ONLY_ANNOTATIONS
69
+ ) do |report:, profile: nil, server_context: nil|
70
+ owner.__send__(:respond, server_context) do
71
+ owner.__send__(:workspace, profile).report_compatibility(report)
72
+ end
73
+ end
74
+ end
75
+ end
76
+
77
+ include DiscoveryTools
78
+ end
79
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnalyticsOps
4
+ # Reopens the read-only MCP server to add experimental inspection tools.
5
+ class MCPServer
6
+ # Experimental read-only BigQuery and governance MCP tools.
7
+ module ExperimentalTools
8
+ private
9
+
10
+ def define_experimental_read_tools
11
+ define_profile_tool(
12
+ "analytics_governance",
13
+ "Audit Analytics Governance",
14
+ "Read broader GA4 governance settings. This is experimental and cannot change them.",
15
+ OBJECT_OUTPUT
16
+ ) { |profile| workspace(profile).governance }
17
+ define_profile_tool(
18
+ "analytics_access_report",
19
+ "Read Aggregate Analytics API Usage",
20
+ "Read aggregate Data API quota usage by hour without user-email or IP fields. This is experimental.",
21
+ OBJECT_OUTPUT
22
+ ) { |profile| workspace(profile).access_report }
23
+ define_bigquery_report_tool
24
+ define_funnel_tool
25
+ end
26
+
27
+ def define_bigquery_report_tool
28
+ owner = self
29
+ server.define_tool(
30
+ name: "analytics_run_bigquery_report",
31
+ title: "Run Cost-Capped BigQuery Report",
32
+ description: "Run one fixed, read-only BigQuery recipe. No arbitrary SQL is accepted.",
33
+ input_schema: experimental_report_schema(
34
+ "report",
35
+ { type: "string", enum: BigQuery::RECIPE_NAMES },
36
+ limit: { type: "integer", minimum: 1, maximum: 1_000 }
37
+ ),
38
+ output_schema: OBJECT_OUTPUT,
39
+ annotations: READ_ONLY_ANNOTATIONS
40
+ ) do |report:, profile: nil, limit: 100, last_days: nil, start_date: nil, end_date: nil,
41
+ compare: false, server_context: nil|
42
+ owner.__send__(:respond, server_context) do
43
+ raise InvalidRequestError, "BigQuery reports do not support comparison ranges" if compare
44
+
45
+ ranges = owner.__send__(:date_ranges, last_days:, start_date:, end_date:, compare:)
46
+ owner.__send__(:workspace, profile).raw_report(report, date_ranges: ranges, limit:)
47
+ end
48
+ end
49
+ end
50
+
51
+ def define_funnel_tool
52
+ owner = self
53
+ server.define_tool(
54
+ name: "analytics_run_funnel",
55
+ title: "Run Aggregate Analytics Funnel",
56
+ description: "Run one configured aggregate funnel through the cost-capped BigQuery connection.",
57
+ input_schema: experimental_report_schema(
58
+ "funnel",
59
+ { type: "string", pattern: "^[A-Za-z][A-Za-z0-9_]{0,63}$" }
60
+ ),
61
+ output_schema: OBJECT_OUTPUT,
62
+ annotations: READ_ONLY_ANNOTATIONS
63
+ ) do |funnel:, profile: nil, last_days: nil, start_date: nil, end_date: nil,
64
+ compare: false, server_context: nil|
65
+ owner.__send__(:respond, server_context) do
66
+ raise InvalidRequestError, "Funnel reports do not support comparison ranges" if compare
67
+
68
+ ranges = owner.__send__(:date_ranges, last_days:, start_date:, end_date:, compare:)
69
+ owner.__send__(:workspace, profile).funnel(funnel, date_ranges: ranges)
70
+ end
71
+ end
72
+ end
73
+
74
+ def experimental_report_schema(name, definition, extra = {})
75
+ {
76
+ type: "object",
77
+ additionalProperties: false,
78
+ required: [name],
79
+ properties: PROFILE_INPUT.fetch(:properties)
80
+ .merge(PERIOD_PROPERTIES)
81
+ .merge(name.to_sym => definition)
82
+ .merge(extra)
83
+ }
84
+ end
85
+ end
86
+
87
+ include ExperimentalTools
88
+ end
89
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnalyticsOps
4
+ # Reopens the read-only MCP server to add health tools.
5
+ class MCPServer
6
+ # Single-profile and portfolio health/report MCP tools.
7
+ module HealthTools
8
+ private
9
+
10
+ def define_health_tools
11
+ define_profile_health_tool
12
+ define_portfolio_report_tool
13
+ define_portfolio_health_tool
14
+ end
15
+
16
+ def define_profile_health_tool
17
+ owner = self
18
+ server.define_tool(
19
+ name: "analytics_health",
20
+ title: "Check Analytics Health",
21
+ description: "Check traffic, expected events, acquisition quality, quota, and configuration drift.",
22
+ input_schema: health_period_schema(profile: true),
23
+ output_schema: OBJECT_OUTPUT,
24
+ annotations: READ_ONLY_ANNOTATIONS
25
+ ) do |profile: nil, last_days: nil, start_date: nil, end_date: nil, server_context: nil|
26
+ owner.__send__(:respond, server_context) do
27
+ ranges = owner.__send__(:date_ranges, last_days:, start_date:, end_date:, compare: true)
28
+ target = owner.__send__(:workspace, profile)
29
+ ranges ? target.health(date_ranges: ranges) : target.health
30
+ end
31
+ end
32
+ end
33
+
34
+ def define_portfolio_report_tool
35
+ owner = self
36
+ server.define_tool(
37
+ name: "analytics_portfolio_report",
38
+ title: "Run Report Across Analytics Properties",
39
+ description: "Run one safe report across every configured profile with isolated per-property failures.",
40
+ input_schema: {
41
+ type: "object",
42
+ additionalProperties: false,
43
+ required: ["report"],
44
+ properties: PERIOD_PROPERTIES.merge(
45
+ report: { type: "string", pattern: "^[a-z][a-z0-9_-]{0,63}$" }
46
+ )
47
+ },
48
+ output_schema: OBJECT_OUTPUT,
49
+ annotations: READ_ONLY_ANNOTATIONS
50
+ ) do |report:, last_days: nil, start_date: nil, end_date: nil, compare: false, server_context: nil|
51
+ owner.__send__(:respond, server_context) do
52
+ ranges = owner.__send__(:date_ranges, last_days:, start_date:, end_date:, compare:)
53
+ target = owner.__send__(:portfolio)
54
+ ranges ? target.report(report, date_ranges: ranges) : target.report(report)
55
+ end
56
+ end
57
+ end
58
+
59
+ def define_portfolio_health_tool
60
+ owner = self
61
+ server.define_tool(
62
+ name: "analytics_portfolio_health",
63
+ title: "Check Every Analytics Property",
64
+ description: "Run deterministic health checks across every configured profile.",
65
+ input_schema: health_period_schema(profile: false),
66
+ output_schema: OBJECT_OUTPUT,
67
+ annotations: READ_ONLY_ANNOTATIONS
68
+ ) do |last_days: nil, start_date: nil, end_date: nil, server_context: nil|
69
+ owner.__send__(:respond, server_context) do
70
+ ranges = owner.__send__(:date_ranges, last_days:, start_date:, end_date:, compare: true)
71
+ target = owner.__send__(:portfolio)
72
+ ranges ? target.health(date_ranges: ranges) : target.health
73
+ end
74
+ end
75
+ end
76
+ end
77
+
78
+ include HealthTools
79
+ end
80
+ end