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,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
|