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
@@ -10,22 +10,25 @@ module AnalyticsOps
10
10
  MAX_KEY_BYTES = 64 * 1024
11
11
  READ_SCOPE = "https://www.googleapis.com/auth/analytics.readonly"
12
12
  EDIT_SCOPE = "https://www.googleapis.com/auth/analytics.edit"
13
+ BIGQUERY_SCOPE = "https://www.googleapis.com/auth/bigquery.readonly"
13
14
  ACCESS_SCOPES = {
14
15
  read: [READ_SCOPE].freeze,
15
- edit: [READ_SCOPE, EDIT_SCOPE].freeze
16
+ edit: [READ_SCOPE, EDIT_SCOPE].freeze,
17
+ bigquery: [BIGQUERY_SCOPE].freeze
16
18
  }.freeze
17
19
  REQUIRED_FIELDS = %w[type client_email private_key token_uri].freeze
18
20
  CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/
19
21
 
20
- attr_reader :path
22
+ attr_reader :path, :security_warnings
21
23
 
22
- def self.load(path: nil, store: Store.new)
23
- new(path || store.read)
24
+ def self.load(path: nil, store: Store.new, connection: nil, config: nil, profile: nil)
25
+ new(path || store.read(name: connection, config:, profile:))
24
26
  end
25
27
 
26
28
  def initialize(path)
27
29
  @path = validated_path(path).freeze
28
30
  validate_key!
31
+ @security_warnings = key_security_warnings.map { |warning| warning.dup.freeze }.freeze
29
32
  freeze
30
33
  end
31
34
 
@@ -37,7 +40,7 @@ module AnalyticsOps
37
40
  private
38
41
 
39
42
  def scopes_for(access)
40
- ACCESS_SCOPES.fetch(access) { raise ArgumentError, "access must be :read or :edit" }
43
+ ACCESS_SCOPES.fetch(access) { raise ArgumentError, "access must be :read, :edit, or :bigquery" }
41
44
  end
42
45
 
43
46
  def build_credentials(scopes)
@@ -92,11 +95,43 @@ module AnalyticsOps
92
95
  raise AuthenticationError, "The selected JSON file is not a valid Google service-account key"
93
96
  end
94
97
 
98
+ def key_security_warnings
99
+ warnings = []
100
+ mode = File.stat(path).mode & 0o777
101
+ if mode.anybits?(0o077)
102
+ warnings << "Service-account key permissions are #{format("%04o", mode)}; protect the file with `chmod 600`."
103
+ end
104
+ if inside_git_repository?
105
+ warnings << "The service-account key is stored inside a Git repository; " \
106
+ "move it outside every application repository."
107
+ end
108
+ warnings
109
+ rescue SystemCallError
110
+ ["Analytics Ops could not verify the service-account key's local file permissions."]
111
+ end
112
+
113
+ def inside_git_repository?
114
+ directory = File.dirname(path)
115
+ loop do
116
+ return true if File.exist?(File.join(directory, ".git"))
117
+
118
+ parent = File.dirname(directory)
119
+ return false if parent == directory
120
+
121
+ directory = parent
122
+ end
123
+ end
124
+
95
125
  # User-level pointer to a validated key. The key itself is never copied.
96
126
  class Store
97
- VERSION = 1
98
- MAX_BYTES = 16 * 1024
99
- FIELDS = %w[service_account_path version].freeze
127
+ VERSION = 2
128
+ LEGACY_VERSION = 1
129
+ MAX_BYTES = 64 * 1024
130
+ FIELDS = %w[configs connections version].freeze
131
+ LEGACY_FIELDS = %w[service_account_path version].freeze
132
+ CONFIG_FIELDS = %w[profile_connections selected_profile].freeze
133
+ CONNECTION_NAME = /\A[A-Za-z][A-Za-z0-9_-]{0,63}\z/
134
+ PROFILE_NAME = /\A[A-Za-z][A-Za-z0-9_]{0,63}\z/
100
135
 
101
136
  attr_reader :path
102
137
 
@@ -115,35 +150,105 @@ module AnalyticsOps
115
150
  @path = File.expand_path(candidate).freeze
116
151
  end
117
152
 
118
- def read
119
- unless File.file?(path)
120
- raise AuthenticationError,
121
- "No service account is configured; run " \
122
- "`analytics-ops setup --service-account /absolute/path/to/service-account.json`"
123
- end
124
- raise AuthenticationError, "The saved Analytics Ops connection is too large" if File.size(path) > MAX_BYTES
153
+ def read(name: nil, config: nil, profile: nil)
154
+ saved = read_document(required: true)
155
+ selected = resolve_name(saved, name:, config:, profile:)
156
+ saved.fetch("connections").fetch(selected)
157
+ end
125
158
 
126
- document = JSON.parse(File.binread(path, MAX_BYTES + 1), max_nesting: 4)
127
- valid = document.is_a?(Hash) &&
128
- document.keys.sort == FIELDS &&
129
- document["version"] == VERSION &&
130
- document["service_account_path"].is_a?(String)
131
- return document.fetch("service_account_path") if valid
159
+ def resolve_connection_name(name: nil, config: nil, profile: nil)
160
+ resolve_name(read_document(required: true), name:, config:, profile:)
161
+ end
132
162
 
133
- raise AuthenticationError, invalid_connection_message
134
- rescue AnalyticsOps::Error
135
- raise
136
- rescue JSON::ParserError, ArgumentError, SystemCallError
137
- raise AuthenticationError, invalid_connection_message
163
+ def connection_name_for(service_account_path, preferred:, config:, profile:)
164
+ normalized = ServiceAccount.new(service_account_path).path
165
+ validate_association!(config, profile)
166
+ preferred_name = validate_connection_name!(preferred)
167
+ saved = read_document(required: false)
168
+ associated = associated_connection(saved, config:, profile:)
169
+ return associated if associated
170
+
171
+ connections = saved.fetch("connections")
172
+ return preferred_name unless connections.key?(preferred_name)
173
+ return preferred_name if connections.fetch(preferred_name) == normalized
174
+
175
+ unique_connection_name(connections, preferred_name, normalized)
138
176
  end
139
177
 
140
- def write(service_account_path)
178
+ def write(service_account_path, name: "default", config: nil, profile: nil, select: true)
141
179
  normalized = ServiceAccount.new(service_account_path).path
180
+ connection_name = validate_connection_name!(name)
181
+ validate_association!(config, profile)
182
+ raise AuthenticationError, "select must be true or false" unless [true, false].include?(select)
183
+
184
+ saved = read_document(required: false)
185
+ if connection_name != "default" &&
186
+ saved.fetch("configs").empty? &&
187
+ saved.fetch("connections") == { "default" => normalized }
188
+ saved.fetch("connections").delete("default")
189
+ end
190
+ saved.fetch("connections")[connection_name] = normalized
191
+ associate!(saved, config:, profile:, connection: connection_name, select:) if config
192
+ persist(saved)
193
+ end
194
+
195
+ def select(config:, profile:, connection: nil)
196
+ validate_association!(config, profile)
197
+ saved = read_document(required: true)
198
+ selected = resolve_name(saved, name: connection, config:, profile:)
199
+ associate!(saved, config:, profile:, connection: selected, select: true)
200
+ persist(saved)
201
+ selection(config:)
202
+ end
203
+
204
+ def selection(config:)
205
+ saved = read_document(required: false)
206
+ entry = saved.fetch("configs")[config_key(config)]
207
+ return nil unless entry
208
+
209
+ profile = entry.fetch("selected_profile")
210
+ {
211
+ "profile" => profile,
212
+ "connection" => entry.fetch("profile_connections").fetch(profile)
213
+ }.freeze
214
+ end
215
+
216
+ def selected_profile(config:)
217
+ selection(config:)&.fetch("profile")
218
+ end
219
+
220
+ def profile_connection(config:, profile:)
221
+ saved = read_document(required: false)
222
+ associated = associated_connection(saved, config:, profile:)
223
+ return associated if associated
224
+
225
+ resolve_name(saved, name: nil, config:, profile:)
226
+ rescue AuthenticationError
227
+ nil
228
+ end
229
+
230
+ def summaries
231
+ saved = read_document(required: false)
232
+ selected_names = saved.fetch("configs").values.flat_map do |entry|
233
+ entry.fetch("profile_connections").values
234
+ end.uniq
235
+ saved.fetch("connections").sort.map do |name, service_account_path|
236
+ {
237
+ "name" => name,
238
+ "available" => File.file?(service_account_path),
239
+ "in_use" => selected_names.include?(name)
240
+ }.freeze
241
+ end.freeze
242
+ end
243
+
244
+ private
245
+
246
+ def persist(document)
142
247
  directory = File.dirname(path)
143
248
  FileUtils.mkdir_p(directory, mode: 0o700)
144
249
  File.chmod(0o700, directory)
145
250
  temporary = File.join(directory, ".connection.#{Process.pid}.#{SecureRandom.hex(6)}.tmp")
146
- payload = JSON.generate("version" => VERSION, "service_account_path" => normalized) << "\n"
251
+ payload = JSON.generate(ordered_document(document)) << "\n"
147
252
 
148
253
  File.open(temporary, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
149
254
  file.write(payload)
@@ -161,7 +266,194 @@ module AnalyticsOps
161
266
  File.unlink(temporary) if temporary && File.exist?(temporary)
162
267
  end
163
268
 
164
- private
269
+ def read_document(required:)
270
+ unless File.file?(path)
271
+ raise AuthenticationError, missing_connection_message if required
272
+
273
+ return empty_document
274
+ end
275
+ raise AuthenticationError, "The saved Analytics Ops connection is too large" if File.size(path) > MAX_BYTES
276
+
277
+ parsed = JSON.parse(File.binread(path, MAX_BYTES + 1), max_nesting: 8)
278
+ return migrate_legacy(parsed) if legacy_document?(parsed)
279
+
280
+ validate_document!(parsed)
281
+ parsed
282
+ rescue AnalyticsOps::Error
283
+ raise
284
+ rescue JSON::ParserError, ArgumentError, SystemCallError
285
+ raise AuthenticationError, invalid_connection_message
286
+ end
287
+
288
+ def empty_document
289
+ { "version" => VERSION, "connections" => {}, "configs" => {} }
290
+ end
291
+
292
+ def legacy_document?(document)
293
+ document.is_a?(Hash) &&
294
+ document.keys.sort == LEGACY_FIELDS &&
295
+ document["version"] == LEGACY_VERSION &&
296
+ document["service_account_path"].is_a?(String)
297
+ end
298
+
299
+ def migrate_legacy(document)
300
+ empty_document.merge(
301
+ "connections" => { "default" => document.fetch("service_account_path") }
302
+ )
303
+ end
304
+
305
+ def validate_document!(document)
306
+ valid = document.is_a?(Hash) &&
307
+ document.keys.sort == FIELDS &&
308
+ document["version"] == VERSION &&
309
+ valid_connections?(document["connections"]) &&
310
+ valid_configs?(document["configs"], document["connections"])
311
+ raise AuthenticationError, invalid_connection_message unless valid
312
+ end
313
+
314
+ def valid_connections?(connections)
315
+ connections.is_a?(Hash) && connections.all? do |name, service_account_path|
316
+ CONNECTION_NAME.match?(name.to_s) &&
317
+ service_account_path.is_a?(String) &&
318
+ !service_account_path.empty? &&
319
+ !service_account_path.match?(CONTROL_CHARACTERS)
320
+ end
321
+ end
322
+
323
+ def valid_configs?(configs, connections)
324
+ configs.is_a?(Hash) &&
325
+ configs.all? { |config, entry| valid_config_entry?(config, entry, connections) }
326
+ end
327
+
328
+ def valid_config_entry?(config, entry, connections)
329
+ return false unless valid_config_name?(config)
330
+ return false unless entry.is_a?(Hash) && entry.keys.sort == CONFIG_FIELDS
331
+
332
+ profile = entry["selected_profile"]
333
+ mappings = entry["profile_connections"]
334
+ PROFILE_NAME.match?(profile.to_s) &&
335
+ mappings.is_a?(Hash) &&
336
+ mappings.key?(profile) &&
337
+ mappings.all? do |profile_name, connection_name|
338
+ valid_profile_connection?(profile_name, connection_name, connections)
339
+ end
340
+ end
341
+
342
+ def valid_config_name?(config)
343
+ config.is_a?(String) && !config.empty? && !config.match?(CONTROL_CHARACTERS)
344
+ end
345
+
346
+ def valid_profile_connection?(profile, connection, connections)
347
+ PROFILE_NAME.match?(profile.to_s) &&
348
+ CONNECTION_NAME.match?(connection.to_s) &&
349
+ connections.key?(connection)
350
+ end
351
+
352
+ def resolve_name(document, name:, config:, profile:)
353
+ connections = document.fetch("connections")
354
+ if name
355
+ selected = validate_connection_name!(name)
356
+ return selected if connections.key?(selected)
357
+
358
+ raise AuthenticationError, "Unknown Analytics Ops connection #{selected.inspect}; " \
359
+ "available connections: #{connections.keys.sort.join(", ")}"
360
+ end
361
+
362
+ associated = associated_connection(document, config:, profile:)
363
+ return associated if associated && connections.key?(associated)
364
+ return profile if profile && connections.key?(profile)
365
+ return connections.keys.first if connections.length == 1
366
+ return "default" if connections.key?("default")
367
+
368
+ raise AuthenticationError, choose_connection_message(connections)
369
+ end
370
+
371
+ def associated_connection(document, config:, profile:)
372
+ return nil unless config
373
+
374
+ entry = document.fetch("configs")[config_key(config)]
375
+ return nil unless entry
376
+
377
+ selected_profile = profile || entry.fetch("selected_profile")
378
+ entry.fetch("profile_connections")[selected_profile]
379
+ end
380
+
381
+ def associate!(document, config:, profile:, connection:, select:)
382
+ key = config_key(config)
383
+ entry = document.fetch("configs")[key] ||= {
384
+ "selected_profile" => profile,
385
+ "profile_connections" => {}
386
+ }
387
+ entry.fetch("profile_connections")[profile] = connection
388
+ entry["selected_profile"] = profile if select
389
+ end
390
+
391
+ def validate_association!(config, profile)
392
+ return if config.nil? && profile.nil?
393
+ unless config.is_a?(String) && !config.empty? && !config.match?(CONTROL_CHARACTERS)
394
+ raise AuthenticationError, "The Analytics Ops configuration path is invalid"
395
+ end
396
+
397
+ validate_profile_name!(profile)
398
+ end
399
+
400
+ def validate_connection_name!(name)
401
+ string = name.to_s
402
+ return string if CONNECTION_NAME.match?(string)
403
+
404
+ raise AuthenticationError,
405
+ "Connection names must start with a letter and use only letters, numbers, hyphens, or underscores"
406
+ end
407
+
408
+ def validate_profile_name!(profile)
409
+ string = profile.to_s
410
+ return string if PROFILE_NAME.match?(string)
411
+
412
+ raise AuthenticationError,
413
+ "Profile names must start with a letter and use only letters, numbers, or underscores"
414
+ end
415
+
416
+ def config_key(config)
417
+ expanded = File.expand_path(config)
418
+ File.exist?(expanded) ? File.realpath(expanded) : expanded
419
+ rescue SystemCallError
420
+ File.expand_path(config)
421
+ end
422
+
423
+ def ordered_document(document)
424
+ connections = document.fetch("connections").sort.to_h
425
+ configs = document.fetch("configs").sort.to_h do |config, entry|
426
+ [
427
+ config,
428
+ {
429
+ "selected_profile" => entry.fetch("selected_profile"),
430
+ "profile_connections" => entry.fetch("profile_connections").sort.to_h
431
+ }
432
+ ]
433
+ end
434
+ { "version" => VERSION, "connections" => connections, "configs" => configs }
435
+ end
436
+
437
+ def unique_connection_name(connections, preferred, service_account_path)
438
+ 2.upto(9_999) do |number|
439
+ suffix = "_#{number}"
440
+ candidate = "#{preferred.slice(0, 64 - suffix.length)}#{suffix}"
441
+ return candidate unless connections.key?(candidate)
442
+ return candidate if connections.fetch(candidate) == service_account_path
443
+ end
444
+
445
+ raise AuthenticationError, "Cannot choose a unique Analytics Ops connection name; use --connection NAME"
446
+ end
447
+
448
+ def missing_connection_message
449
+ "No service account is configured; run " \
450
+ "`analytics-ops setup --service-account /absolute/path/to/service-account.json`"
451
+ end
452
+
453
+ def choose_connection_message(connections)
454
+ available = connections.keys.sort.join(", ")
455
+ "More than one Analytics Ops connection is saved. Use --connection NAME; available connections: #{available}"
456
+ end
165
457
 
166
458
  def invalid_connection_message
167
459
  "The saved Analytics Ops connection is invalid; rerun setup with --service-account"
@@ -5,21 +5,26 @@ module AnalyticsOps
5
5
  class Setup
6
6
  PROPERTY_ID = /\A\d{1,50}\z/
7
7
  PROFILE = /\A[A-Za-z][A-Za-z0-9_]{0,63}\z/
8
+ DISABLED_API_REASONS = %w[SERVICE_DISABLED ACCESS_NOT_CONFIGURED].freeze
8
9
 
9
10
  # Immutable setup outcome returned to the CLI and Ruby callers.
10
11
  class Result
11
- attr_reader :config_path, :profile, :property
12
+ attr_reader :config_path, :profile, :property, :warnings
12
13
 
13
- def initialize(config_path:, profile:, property:, created:)
14
+ def initialize(config_path:, profile:, property:, created:, updated: false, warnings: [])
14
15
  unless property.is_a?(Resources::Property)
15
16
  raise ArgumentError, "property must be an AnalyticsOps::Resources::Property"
16
17
  end
17
- raise ArgumentError, "created must be true or false" unless [true, false].include?(created)
18
+ unless [true, false].include?(created) && [true, false].include?(updated) && !(created && updated)
19
+ raise ArgumentError, "created and updated must be distinct booleans"
20
+ end
18
21
 
19
22
  @config_path = config_path.to_s.dup.freeze
20
23
  @profile = profile.to_s.dup.freeze
21
24
  @property = property
22
25
  @created = created
26
+ @updated = updated
27
+ @warnings = normalized_warnings(warnings)
23
28
  freeze
24
29
  end
25
30
 
@@ -27,8 +32,12 @@ module AnalyticsOps
27
32
  @created
28
33
  end
29
34
 
35
+ def updated?
36
+ @updated
37
+ end
38
+
30
39
  def status
31
- created? ? "configured" : "already_configured"
40
+ created? || updated? ? "configured" : "already_configured"
32
41
  end
33
42
 
34
43
  def to_h
@@ -36,13 +45,25 @@ module AnalyticsOps
36
45
  "status" => status,
37
46
  "config" => config_path,
38
47
  "profile" => profile,
48
+ "updated" => updated?,
49
+ "warnings" => warnings,
39
50
  "property" => property.to_h
40
51
  }
41
52
  end
53
+
54
+ private
55
+
56
+ def normalized_warnings(value)
57
+ unless value.is_a?(Array) && value.all? { |warning| warning.is_a?(String) && !warning.empty? }
58
+ raise ArgumentError, "warnings must be an array of strings"
59
+ end
60
+
61
+ value.map { |warning| Redaction.message(warning).freeze }.freeze
62
+ end
42
63
  end
43
64
 
44
65
  def initialize(connection:, config:, profile:, property_id: nil, noninteractive: false,
45
- input: $stdin, out: $stdout)
66
+ warnings: [], input: $stdin, out: $stdout)
46
67
  validate_options!(
47
68
  config:,
48
69
  profile:,
@@ -54,6 +75,7 @@ module AnalyticsOps
54
75
  @profile = profile.to_s
55
76
  @property_id = property_id
56
77
  @noninteractive = noninteractive
78
+ @warnings = warnings
57
79
  @input = input
58
80
  @out = out
59
81
  end
@@ -70,7 +92,9 @@ module AnalyticsOps
70
92
  config_path: write.path,
71
93
  profile: @profile,
72
94
  property: verification.property,
73
- created: write.created?
95
+ created: write.created?,
96
+ updated: write.updated?,
97
+ warnings: @warnings
74
98
  )
75
99
  end
76
100
 
@@ -116,7 +140,7 @@ module AnalyticsOps
116
140
  end
117
141
 
118
142
  def api_disabled?(error)
119
- error.message.match?(/SERVICE_DISABLED|API.*(?:disabled|not been used)|serviceusage/i)
143
+ DISABLED_API_REASONS.include?(error.remote_reason.to_s.upcase)
120
144
  end
121
145
 
122
146
  def raise_disabled_api!
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AnalyticsOps
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end