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,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnalyticsOps
4
+ # Reopens the read-only MCP server to add overview tools.
5
+ class MCPServer
6
+ # Overview MCP tools for one profile or a configured portfolio.
7
+ module OverviewTools
8
+ private
9
+
10
+ def define_overview_tool
11
+ owner = self
12
+ server.define_tool(
13
+ name: "analytics_overview",
14
+ title: "Show Analytics Overview",
15
+ description: "Use this for users, sessions, trends, acquisition, landing pages, and devices.",
16
+ input_schema: period_schema(profile: true),
17
+ output_schema: OBJECT_OUTPUT,
18
+ annotations: READ_ONLY_ANNOTATIONS
19
+ ) do |profile: nil, last_days: nil, start_date: nil, end_date: nil, compare: false, server_context: nil|
20
+ owner.__send__(:respond, server_context) do
21
+ ranges = owner.__send__(:date_ranges, last_days:, start_date:, end_date:, compare:)
22
+ target = owner.__send__(:workspace, profile)
23
+ ranges ? target.overview(date_ranges: ranges) : target.overview
24
+ end
25
+ end
26
+ end
27
+
28
+ def define_portfolio_tool
29
+ owner = self
30
+ server.define_tool(
31
+ name: "analytics_portfolio",
32
+ title: "Compare All Analytics Properties",
33
+ description: "Use this for users, sessions, and key events across every configured property.",
34
+ input_schema: period_schema(profile: false),
35
+ output_schema: OBJECT_OUTPUT,
36
+ annotations: READ_ONLY_ANNOTATIONS
37
+ ) do |last_days: nil, start_date: nil, end_date: nil, compare: false, server_context: nil|
38
+ owner.__send__(:respond, server_context) do
39
+ ranges = owner.__send__(:date_ranges, last_days:, start_date:, end_date:, compare:)
40
+ target = owner.__send__(:portfolio)
41
+ ranges ? target.overview(date_ranges: ranges) : target.overview
42
+ end
43
+ end
44
+ end
45
+ end
46
+
47
+ include OverviewTools
48
+ end
49
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnalyticsOps
4
+ # Strictly read-only MCP bridge for ChatGPT, Codex, Claude, and other MCP clients.
5
+ class MCPServer
6
+ # Defines the built-in standard-report MCP tool and its strict input schema.
7
+ module StandardReportTool
8
+ private
9
+
10
+ def define_standard_report_tool
11
+ owner = self
12
+ server.define_tool(
13
+ name: "analytics_run_report",
14
+ title: "Run Analytics Report",
15
+ description: "Use this to run one safe built-in or configured GA4 standard report for the selected profile.",
16
+ input_schema: standard_report_schema,
17
+ output_schema: OBJECT_OUTPUT,
18
+ annotations: READ_ONLY_ANNOTATIONS
19
+ ) do |report:, profile: nil, last_days: nil, start_date: nil, end_date: nil, compare: false,
20
+ server_context: nil|
21
+ owner.__send__(:respond, server_context) do
22
+ ranges = owner.__send__(
23
+ :date_ranges,
24
+ last_days:,
25
+ start_date:,
26
+ end_date:,
27
+ compare:
28
+ )
29
+ target = owner.__send__(:workspace, profile)
30
+ ranges ? target.report(report, date_ranges: ranges) : target.report(report)
31
+ end
32
+ end
33
+ end
34
+
35
+ def standard_report_schema
36
+ {
37
+ type: "object",
38
+ additionalProperties: false,
39
+ required: ["report"],
40
+ properties: PROFILE_INPUT.fetch(:properties).merge(PERIOD_PROPERTIES).merge(
41
+ report: {
42
+ type: "string",
43
+ pattern: "^[a-z][a-z0-9_-]{0,63}$",
44
+ description: "Built-in or configured report name."
45
+ }
46
+ )
47
+ }
48
+ end
49
+ end
50
+
51
+ include StandardReportTool
52
+ end
53
+ end
@@ -0,0 +1,402 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "mcp"
5
+ require_relative "../analytics_ops"
6
+
7
+ module AnalyticsOps
8
+ # Strictly read-only MCP bridge for ChatGPT, Codex, Claude, and other MCP clients.
9
+ class MCPServer
10
+ PROFILE = /\A[A-Za-z][A-Za-z0-9_]{0,63}\z/
11
+ CONNECTION = /\A[A-Za-z][A-Za-z0-9_-]{0,63}\z/
12
+ CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/
13
+ PROFILE_JSON_PATTERN = "^[A-Za-z][A-Za-z0-9_]{0,63}$"
14
+ CONNECTION_JSON_PATTERN = "^[A-Za-z][A-Za-z0-9_-]{0,63}$"
15
+ READ_ONLY_ANNOTATIONS = {
16
+ title: "Read Google Analytics",
17
+ read_only_hint: true,
18
+ destructive_hint: false,
19
+ idempotent_hint: true,
20
+ open_world_hint: true
21
+ }.freeze
22
+ PROFILE_INPUT = {
23
+ type: "object",
24
+ additionalProperties: false,
25
+ properties: {
26
+ profile: {
27
+ type: "string",
28
+ pattern: PROFILE_JSON_PATTERN,
29
+ description: "Optional Analytics Ops profile. Uses the locally selected profile when omitted."
30
+ }
31
+ }
32
+ }.freeze
33
+ PERIOD_PROPERTIES = {
34
+ last_days: {
35
+ type: "integer",
36
+ minimum: 1,
37
+ maximum: Reports::Period::MAX_DAYS,
38
+ description: "Optional number of complete days ending yesterday."
39
+ },
40
+ start_date: {
41
+ type: "string",
42
+ pattern: "^\\d{4}-\\d{2}-\\d{2}$",
43
+ description: "Optional YYYY-MM-DD start date; requires end_date."
44
+ },
45
+ end_date: {
46
+ type: "string",
47
+ pattern: "^\\d{4}-\\d{2}-\\d{2}$",
48
+ description: "Optional YYYY-MM-DD end date; requires start_date."
49
+ },
50
+ compare: {
51
+ type: "boolean",
52
+ description: "Also include the equally long preceding period."
53
+ }
54
+ }.freeze
55
+ OBJECT_OUTPUT = { type: "object" }.freeze
56
+ ARRAY_OUTPUT = { type: "array", items: { type: "object" } }.freeze
57
+ STANDARD_REPORTS = (
58
+ Reports::Catalog.names.select { |name| Reports::Catalog.fetch(name).kind == "standard" } +
59
+ Reports::Catalog.aliases.keys
60
+ ).sort.freeze
61
+
62
+ attr_reader :server
63
+
64
+ def initialize(config: "config/analytics_ops.yml", profile: nil, connection: nil,
65
+ store: ServiceAccount::Store.new, workspace_loader: nil, connection_loader: nil,
66
+ service_account_loader: nil, portfolio_loader: nil,
67
+ transport: :grpc, timeout: nil, logger: nil)
68
+ @config = config
69
+ @profile = profile
70
+ @connection = connection
71
+ @store = store
72
+ @workspace_loader = workspace_loader || method(:load_workspace)
73
+ @connection_loader = connection_loader || method(:load_connection)
74
+ @service_account_loader = service_account_loader || ServiceAccount.method(:load)
75
+ @portfolio_loader = portfolio_loader || method(:load_portfolio)
76
+ @transport = transport
77
+ @timeout = timeout
78
+ @logger = logger
79
+ validate_defaults!
80
+ @server = build_server
81
+ define_tools
82
+ end
83
+
84
+ def start
85
+ ::MCP::Server::Transports::StdioTransport.new(server).open
86
+ end
87
+
88
+ private
89
+
90
+ def build_server
91
+ configuration = ::MCP::Configuration.new(
92
+ validate_tool_call_arguments: true,
93
+ validate_tool_call_results: true
94
+ )
95
+ ::MCP::Server.new(
96
+ name: "analytics_ops",
97
+ title: "Analytics Ops",
98
+ version: AnalyticsOps::VERSION,
99
+ description: "Read Google Analytics 4 configuration and reports safely.",
100
+ website_url: "https://github.com/nelsonchaves/analytics_ops",
101
+ instructions: "Every Analytics Ops tool is strictly read-only. Use these tools to inspect GA4 properties, " \
102
+ "configuration drift, reports, and realtime events. No tool can create a plan, apply a change, " \
103
+ "or modify Google Analytics. Never claim that a tool changed GA4.",
104
+ configuration:
105
+ )
106
+ end
107
+
108
+ def define_tools
109
+ define_local_tools
110
+ define_google_tools
111
+ define_reporting_tools
112
+ end
113
+
114
+ def define_local_tools
115
+ owner = self
116
+ server.define_tool(
117
+ name: "analytics_list_profiles",
118
+ title: "List Analytics Profiles",
119
+ description: "Use this to list locally configured GA4 property profiles and see which one is selected.",
120
+ input_schema: { type: "object", additionalProperties: false },
121
+ output_schema: ARRAY_OUTPUT,
122
+ annotations: READ_ONLY_ANNOTATIONS
123
+ ) do |server_context: nil|
124
+ owner.__send__(:respond, server_context) { owner.__send__(:profile_rows) }
125
+ end
126
+
127
+ server.define_tool(
128
+ name: "analytics_list_connections",
129
+ title: "List Analytics Connections",
130
+ description: "Use this to list saved Google connection names and availability. " \
131
+ "Credential paths are never returned.",
132
+ input_schema: { type: "object", additionalProperties: false },
133
+ output_schema: ARRAY_OUTPUT,
134
+ annotations: READ_ONLY_ANNOTATIONS
135
+ ) do |server_context: nil|
136
+ owner.__send__(:respond, server_context) { owner.__send__(:connection_rows) }
137
+ end
138
+ end
139
+
140
+ def define_google_tools
141
+ define_properties_tool
142
+ define_profile_tool(
143
+ "analytics_doctor",
144
+ "Check Analytics Setup",
145
+ "Use this to verify credentials, API access, property access, and client compatibility.",
146
+ OBJECT_OUTPUT
147
+ ) { |profile| workspace(profile).doctor }
148
+ define_snapshot_tool
149
+ define_profile_tool(
150
+ "analytics_audit",
151
+ "Audit GA4 Configuration",
152
+ "Use this to compare desired and remote GA4 configuration. It reports drift but cannot save or apply a plan.",
153
+ OBJECT_OUTPUT
154
+ ) { |profile| workspace(profile).audit }
155
+ end
156
+
157
+ def define_properties_tool
158
+ owner = self
159
+ server.define_tool(
160
+ name: "analytics_list_properties",
161
+ title: "List Accessible GA4 Properties",
162
+ description: "Use this to discover GA4 accounts and properties accessible through a saved Google connection.",
163
+ input_schema: {
164
+ type: "object",
165
+ additionalProperties: false,
166
+ properties: {
167
+ connection: {
168
+ type: "string",
169
+ pattern: CONNECTION_JSON_PATTERN,
170
+ description: "Optional saved connection name."
171
+ }
172
+ }
173
+ },
174
+ output_schema: ARRAY_OUTPUT,
175
+ annotations: READ_ONLY_ANNOTATIONS
176
+ ) do |connection: nil, server_context: nil|
177
+ owner.__send__(:respond, server_context) do
178
+ owner.__send__(:google_connection, connection).properties
179
+ end
180
+ end
181
+ end
182
+
183
+ def define_snapshot_tool
184
+ define_profile_tool(
185
+ "analytics_snapshot",
186
+ "Read GA4 Configuration",
187
+ "Use this to read the selected property's normalized GA4 configuration.",
188
+ OBJECT_OUTPUT
189
+ ) do |profile|
190
+ snapshot = workspace(profile).snapshot
191
+ { "fingerprint" => snapshot.fingerprint, "snapshot" => snapshot.to_h }
192
+ end
193
+ end
194
+
195
+ def define_reporting_tools
196
+ define_overview_tool
197
+ define_standard_report_tool
198
+ define_portfolio_tool
199
+ define_report_discovery_tools
200
+ define_custom_report_tool
201
+ define_health_tools
202
+ define_experimental_read_tools
203
+ define_profile_tool(
204
+ "analytics_realtime",
205
+ "Read Realtime Analytics",
206
+ "Use this to read current GA4 event counts from the safe realtime_events recipe.",
207
+ OBJECT_OUTPUT
208
+ ) { |profile| workspace(profile).realtime }
209
+ end
210
+
211
+ def define_profile_tool(name, title, description, output_schema, &operation)
212
+ owner = self
213
+ server.define_tool(
214
+ name:,
215
+ title:,
216
+ description:,
217
+ input_schema: PROFILE_INPUT,
218
+ output_schema:,
219
+ annotations: READ_ONLY_ANNOTATIONS
220
+ ) do |profile: nil, server_context: nil|
221
+ owner.__send__(:respond, server_context) do
222
+ owner.instance_exec(profile, &operation)
223
+ end
224
+ end
225
+ end
226
+
227
+ def period_schema(profile:)
228
+ properties = profile ? PROFILE_INPUT.fetch(:properties) : {}
229
+ {
230
+ type: "object",
231
+ additionalProperties: false,
232
+ properties: properties.merge(PERIOD_PROPERTIES)
233
+ }
234
+ end
235
+
236
+ def health_period_schema(profile:)
237
+ properties = profile ? PROFILE_INPUT.fetch(:properties) : {}
238
+ health_period = PERIOD_PROPERTIES.except(:compare)
239
+ {
240
+ type: "object",
241
+ additionalProperties: false,
242
+ properties: properties.merge(health_period)
243
+ }
244
+ end
245
+
246
+ def respond(server_context)
247
+ server_context&.raise_if_cancelled!
248
+ payload = serializable(yield)
249
+ server_context&.raise_if_cancelled!
250
+ ::MCP::Tool::Response.new(
251
+ [{ type: "text", text: JSON.generate(payload) }],
252
+ structured_content: payload
253
+ )
254
+ rescue AnalyticsOps::Error => error
255
+ error_response(error.class.name.split("::").last, error.message)
256
+ rescue StandardError => error
257
+ SafeLogging.write(@logger, :error, "mcp_tool_error", "type" => error.class.name)
258
+ error_response("InternalError", "Analytics Ops could not complete this read-only request")
259
+ end
260
+
261
+ def error_response(type, message)
262
+ payload = {
263
+ "error" => {
264
+ "type" => Redaction.message(type),
265
+ "message" => Redaction.message(message)
266
+ }
267
+ }
268
+ ::MCP::Tool::Response.new(
269
+ [{ type: "text", text: JSON.generate(payload) }],
270
+ structured_content: payload,
271
+ error: true
272
+ )
273
+ end
274
+
275
+ def profile_rows
276
+ selected = @store.selection(config: @config)
277
+ selected_profile = selected&.fetch("profile") || profile_name(nil)
278
+ Configuration.load(@config).profiles.sort.map do |name, desired_state|
279
+ {
280
+ "name" => name,
281
+ "property_id" => desired_state.property_id,
282
+ "connection" => @store.profile_connection(config: @config, profile: name),
283
+ "selected" => selected_profile == name
284
+ }
285
+ end
286
+ end
287
+
288
+ def connection_rows
289
+ @store.summaries
290
+ end
291
+
292
+ def workspace(requested_profile)
293
+ profile = profile_name(requested_profile)
294
+ service_account = load_service_account(profile:, connection: @connection)
295
+ @workspace_loader.call(
296
+ config: @config,
297
+ profile:,
298
+ service_account:,
299
+ transport: @transport,
300
+ timeout: @timeout,
301
+ logger: @logger
302
+ )
303
+ end
304
+
305
+ def google_connection(requested_connection)
306
+ profile = profile_name(nil)
307
+ service_account = load_service_account(profile:, connection: requested_connection || @connection)
308
+ @connection_loader.call(
309
+ service_account:,
310
+ transport: @transport,
311
+ timeout: @timeout,
312
+ logger: @logger
313
+ )
314
+ end
315
+
316
+ def load_service_account(profile:, connection:)
317
+ @service_account_loader.call(
318
+ store: @store,
319
+ connection:,
320
+ config: @config,
321
+ profile:
322
+ )
323
+ end
324
+
325
+ def load_workspace(**options)
326
+ Workspace.load(
327
+ options.fetch(:config),
328
+ profile: options.fetch(:profile),
329
+ service_account: options.fetch(:service_account),
330
+ transport: options.fetch(:transport),
331
+ timeout: options[:timeout],
332
+ logger: options[:logger]
333
+ )
334
+ end
335
+
336
+ def load_connection(**)
337
+ Connection.new(**)
338
+ end
339
+
340
+ def portfolio
341
+ @portfolio_loader.call(
342
+ config: @config,
343
+ store: @store,
344
+ workspace_loader: @workspace_loader,
345
+ service_account_loader: @service_account_loader,
346
+ transport: @transport,
347
+ timeout: @timeout,
348
+ logger: @logger
349
+ )
350
+ end
351
+
352
+ def load_portfolio(**)
353
+ Portfolio.new(**)
354
+ end
355
+
356
+ def date_ranges(last_days:, start_date:, end_date:, compare:)
357
+ Reports::Period.resolve(last_days:, start_date:, end_date:, compare:)
358
+ end
359
+
360
+ def profile_name(requested)
361
+ selected = requested || @profile || @store.selected_profile(config: @config) || "production"
362
+ raise ConfigurationError, "Invalid Analytics Ops profile" unless PROFILE.match?(selected.to_s)
363
+
364
+ selected.to_s
365
+ end
366
+
367
+ def validate_defaults!
368
+ valid_config = @config.is_a?(String) &&
369
+ @config.length.between?(1, 4_096) &&
370
+ !@config.match?(CONTROL_CHARACTERS)
371
+ raise ConfigurationError, "MCP configuration path is invalid" unless valid_config
372
+ raise ConfigurationError, "MCP profile is invalid" if @profile && !valid_profile?(@profile)
373
+ raise ConfigurationError, "MCP connection is invalid" if @connection && !valid_connection?(@connection)
374
+ end
375
+
376
+ def valid_profile?(value)
377
+ value.is_a?(String) && PROFILE.match?(value)
378
+ end
379
+
380
+ def valid_connection?(value)
381
+ value.is_a?(String) && CONNECTION.match?(value)
382
+ end
383
+
384
+ def serializable(value)
385
+ case value
386
+ when Array
387
+ value.map { |item| serializable(item) }
388
+ when Hash
389
+ value.to_h { |key, item| [key.to_s, serializable(item)] }
390
+ else
391
+ value.respond_to?(:to_h) ? serializable(value.to_h) : value
392
+ end
393
+ end
394
+ end
395
+ end
396
+
397
+ require_relative "mcp_server/experimental_tools"
398
+ require_relative "mcp_server/discovery_tools"
399
+ require_relative "mcp_server/custom_report_tool"
400
+ require_relative "mcp_server/health_tools"
401
+ require_relative "mcp_server/overview_tools"
402
+ require_relative "mcp_server/standard_report_tool"