analytics_ops 0.1.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 +7 -0
- data/CHANGELOG.md +47 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/CONTRIBUTING.md +67 -0
- data/LICENSE.txt +21 -0
- data/README.md +188 -0
- data/SECURITY.md +56 -0
- data/docs/api-support-matrix.md +50 -0
- data/docs/architecture.md +89 -0
- data/docs/authentication.md +86 -0
- data/docs/commands.md +112 -0
- data/docs/configuration-schema-v1.json +129 -0
- data/docs/configuration.md +167 -0
- data/docs/google-client-compatibility.md +59 -0
- data/docs/plan-format.md +89 -0
- data/docs/plan-schema-v1.json +224 -0
- data/docs/rails.md +73 -0
- data/docs/reports.md +124 -0
- data/docs/safety.md +93 -0
- data/docs/troubleshooting.md +103 -0
- data/exe/analytics-ops +6 -0
- data/lib/analytics_ops/applier.rb +52 -0
- data/lib/analytics_ops/canonical.rb +65 -0
- data/lib/analytics_ops/cli.rb +427 -0
- data/lib/analytics_ops/clients/admin.rb +446 -0
- data/lib/analytics_ops/clients/data.rb +314 -0
- data/lib/analytics_ops/configuration/document.rb +25 -0
- data/lib/analytics_ops/configuration/loader.rb +76 -0
- data/lib/analytics_ops/configuration/schema.rb +107 -0
- data/lib/analytics_ops/configuration/validator.rb +344 -0
- data/lib/analytics_ops/configuration.rb +19 -0
- data/lib/analytics_ops/desired_state.rb +40 -0
- data/lib/analytics_ops/errors.rb +31 -0
- data/lib/analytics_ops/plan.rb +551 -0
- data/lib/analytics_ops/planner.rb +233 -0
- data/lib/analytics_ops/rails/railtie.rb +65 -0
- data/lib/analytics_ops/rails.rb +5 -0
- data/lib/analytics_ops/redaction.rb +46 -0
- data/lib/analytics_ops/reports/catalog.rb +100 -0
- data/lib/analytics_ops/reports/definition.rb +226 -0
- data/lib/analytics_ops/reports/result.rb +90 -0
- data/lib/analytics_ops/reports.rb +13 -0
- data/lib/analytics_ops/resources.rb +79 -0
- data/lib/analytics_ops/snapshot.rb +45 -0
- data/lib/analytics_ops/version.rb +5 -0
- data/lib/analytics_ops/workspace.rb +212 -0
- data/lib/analytics_ops.rb +21 -0
- data/lib/generators/analytics_ops/install_generator.rb +23 -0
- data/lib/generators/analytics_ops/templates/analytics-ops +9 -0
- data/lib/generators/analytics_ops/templates/analytics_ops.yml +23 -0
- data/sig/analytics_ops.rbs +395 -0
- metadata +131 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AnalyticsOps
|
|
4
|
+
module Reports
|
|
5
|
+
# Immutable normalized Data API response.
|
|
6
|
+
class Result
|
|
7
|
+
attr_reader :name, :kind, :dimension_headers, :metric_headers, :rows, :row_count, :metadata
|
|
8
|
+
|
|
9
|
+
def initialize(name:, kind:, dimension_headers:, metric_headers:, rows:, row_count:, metadata:)
|
|
10
|
+
@name = string(name, "name")
|
|
11
|
+
@kind = kind_value(kind)
|
|
12
|
+
@dimension_headers = normalize_headers(dimension_headers, "dimension_headers")
|
|
13
|
+
@metric_headers = normalize_headers(metric_headers, "metric_headers")
|
|
14
|
+
validate_header_uniqueness!
|
|
15
|
+
@rows = normalized_rows(rows)
|
|
16
|
+
@row_count = count(row_count)
|
|
17
|
+
@metadata = hash(metadata, "metadata")
|
|
18
|
+
freeze
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def headers
|
|
22
|
+
dimension_headers + metric_headers
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def to_h
|
|
26
|
+
{
|
|
27
|
+
"name" => name,
|
|
28
|
+
"kind" => kind,
|
|
29
|
+
"dimension_headers" => dimension_headers,
|
|
30
|
+
"metric_headers" => metric_headers,
|
|
31
|
+
"rows" => rows,
|
|
32
|
+
"row_count" => row_count,
|
|
33
|
+
"metadata" => metadata
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def string(value, label)
|
|
40
|
+
return value.dup.freeze if value.is_a?(String) && !value.empty?
|
|
41
|
+
|
|
42
|
+
raise RemoteError, "Report result #{label} is invalid"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def kind_value(value)
|
|
46
|
+
return value.dup.freeze if Definition::KINDS.include?(value)
|
|
47
|
+
|
|
48
|
+
raise RemoteError, "Report result kind is invalid"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def normalize_headers(values, label)
|
|
52
|
+
unless values.is_a?(Array) && values.all? { |value| value.is_a?(String) && !value.empty? }
|
|
53
|
+
raise RemoteError, "Report result #{label} is invalid"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
Canonical.immutable(values)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def validate_header_uniqueness!
|
|
60
|
+
raise RemoteError, "Report result headers are not unique" unless headers.uniq.length == headers.length
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def normalized_rows(values)
|
|
64
|
+
raise RemoteError, "Report result rows must be an array" unless values.is_a?(Array)
|
|
65
|
+
|
|
66
|
+
normalized = values.map do |row|
|
|
67
|
+
value = hash(row, "row")
|
|
68
|
+
raise RemoteError, "Report result row fields do not match headers" unless value.keys.sort == headers.sort
|
|
69
|
+
|
|
70
|
+
value
|
|
71
|
+
end
|
|
72
|
+
Canonical.immutable(normalized)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def count(value)
|
|
76
|
+
return value if value.is_a?(Integer) && value >= rows.length
|
|
77
|
+
|
|
78
|
+
raise RemoteError, "Report result row_count is invalid"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def hash(value, label)
|
|
82
|
+
unless value.is_a?(Hash) && value.keys.all?(String)
|
|
83
|
+
raise RemoteError, "Report result #{label} must be an object with string keys"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
Canonical.immutable(value)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Reporting public namespace.
|
|
4
|
+
|
|
5
|
+
require_relative "reports/definition"
|
|
6
|
+
require_relative "reports/catalog"
|
|
7
|
+
require_relative "reports/result"
|
|
8
|
+
|
|
9
|
+
module AnalyticsOps
|
|
10
|
+
# Immutable Data API definitions, recipes, and normalized results.
|
|
11
|
+
module Reports
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Gem-owned values returned at public API boundaries.
|
|
4
|
+
|
|
5
|
+
module AnalyticsOps
|
|
6
|
+
module Resources
|
|
7
|
+
# Immutable base class for gem-owned values at the Google client boundary.
|
|
8
|
+
class Value
|
|
9
|
+
class << self
|
|
10
|
+
attr_reader :field_names
|
|
11
|
+
|
|
12
|
+
def fields(*names)
|
|
13
|
+
@field_names = names.freeze
|
|
14
|
+
attr_reader(*names)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(**values)
|
|
19
|
+
unknown = values.keys - self.class.field_names
|
|
20
|
+
missing = self.class.field_names - values.keys
|
|
21
|
+
raise ArgumentError, "Unknown fields: #{unknown.join(", ")}" unless unknown.empty?
|
|
22
|
+
raise ArgumentError, "Missing fields: #{missing.join(", ")}" unless missing.empty?
|
|
23
|
+
|
|
24
|
+
values.each do |name, value|
|
|
25
|
+
instance_variable_set("@#{name}", Canonical.immutable(value))
|
|
26
|
+
end
|
|
27
|
+
freeze
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def to_h
|
|
31
|
+
self.class.field_names.to_h { |name| [name.to_s, public_send(name)] }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def ==(other)
|
|
35
|
+
other.instance_of?(self.class) && other.to_h == to_h
|
|
36
|
+
end
|
|
37
|
+
alias eql? ==
|
|
38
|
+
|
|
39
|
+
def hash
|
|
40
|
+
[self.class, to_h].hash
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Accessible Analytics account summary.
|
|
45
|
+
class Account < Value
|
|
46
|
+
fields :id, :name, :display_name, :properties
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Normalized GA4 property.
|
|
50
|
+
class Property < Value
|
|
51
|
+
fields :id, :name, :display_name, :parent, :property_type, :can_edit
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Normalized web or application data stream.
|
|
55
|
+
class DataStream < Value
|
|
56
|
+
fields :id, :name, :display_name, :type, :default_uri, :measurement_id
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Property retention settings.
|
|
60
|
+
class Retention < Value
|
|
61
|
+
fields :name, :event_data, :user_data, :reset_on_new_activity
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Registered key event.
|
|
65
|
+
class KeyEvent < Value
|
|
66
|
+
fields :name, :event_name, :counting_method
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Registered custom dimension.
|
|
70
|
+
class CustomDimension < Value
|
|
71
|
+
fields :name, :parameter_name, :display_name, :description, :scope, :disallow_ads_personalization
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Registered custom metric.
|
|
75
|
+
class CustomMetric < Value
|
|
76
|
+
fields :name, :parameter_name, :display_name, :description, :scope, :measurement_unit
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Normalized remote state.
|
|
4
|
+
|
|
5
|
+
module AnalyticsOps
|
|
6
|
+
# Normalized remote state. No generated Google objects escape into this value.
|
|
7
|
+
class Snapshot
|
|
8
|
+
attr_reader :property, :streams, :retention, :key_events, :custom_dimensions, :custom_metrics
|
|
9
|
+
|
|
10
|
+
def initialize(property:, streams:, retention:, key_events:, custom_dimensions:, custom_metrics:)
|
|
11
|
+
@property = property
|
|
12
|
+
@streams = sort(streams, &:id)
|
|
13
|
+
@retention = retention
|
|
14
|
+
@key_events = sort(key_events, &:event_name)
|
|
15
|
+
@custom_dimensions = sort(custom_dimensions) { |dimension| [dimension.scope, dimension.parameter_name] }
|
|
16
|
+
@custom_metrics = sort(custom_metrics, &:parameter_name)
|
|
17
|
+
freeze
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def property_id
|
|
21
|
+
property.id
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def to_h
|
|
25
|
+
{
|
|
26
|
+
"property" => property.to_h,
|
|
27
|
+
"streams" => streams.map(&:to_h),
|
|
28
|
+
"retention" => retention&.to_h,
|
|
29
|
+
"key_events" => key_events.map(&:to_h),
|
|
30
|
+
"custom_dimensions" => custom_dimensions.map(&:to_h),
|
|
31
|
+
"custom_metrics" => custom_metrics.map(&:to_h)
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def fingerprint
|
|
36
|
+
Canonical.fingerprint(to_h)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def sort(values, &)
|
|
42
|
+
Canonical.deep_freeze(values.sort_by(&))
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Primary plain-Ruby API.
|
|
4
|
+
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module AnalyticsOps
|
|
8
|
+
# Primary Ruby API. Loading a workspace validates local data but performs no network calls.
|
|
9
|
+
class Workspace
|
|
10
|
+
# Immutable convergence result returned by verify.
|
|
11
|
+
class Verification
|
|
12
|
+
attr_reader :converged, :plan
|
|
13
|
+
|
|
14
|
+
def initialize(plan:)
|
|
15
|
+
@plan = plan
|
|
16
|
+
@converged = !plan.drift?
|
|
17
|
+
freeze
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def to_h
|
|
21
|
+
{ "converged" => converged, "plan" => plan.to_h }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Immutable health-check collection returned by doctor.
|
|
26
|
+
class DoctorResult
|
|
27
|
+
attr_reader :checks
|
|
28
|
+
|
|
29
|
+
def initialize(checks:)
|
|
30
|
+
@checks = Canonical.deep_freeze(checks)
|
|
31
|
+
freeze
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def success?
|
|
35
|
+
checks.none? { |check| check.fetch("status") == "error" }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def to_h
|
|
39
|
+
{ "success" => success?, "checks" => checks }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
attr_reader :desired_state
|
|
44
|
+
|
|
45
|
+
def self.load(path, profile:, environment: ENV, admin: nil, data: nil, credentials: nil,
|
|
46
|
+
transport: :grpc, timeout: nil, logger: nil)
|
|
47
|
+
document = Configuration.load(path, environment:)
|
|
48
|
+
new(desired_state: document.profile(profile), admin:, data:, credentials:, transport:, timeout:, logger:)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def initialize(desired_state:, admin: nil, data: nil, credentials: nil, transport: :grpc, timeout: nil, logger: nil)
|
|
52
|
+
@desired_state = desired_state
|
|
53
|
+
@admin = admin
|
|
54
|
+
@data = data
|
|
55
|
+
@credentials = credentials
|
|
56
|
+
@transport = transport
|
|
57
|
+
@timeout = timeout
|
|
58
|
+
@logger = logger
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def discover
|
|
62
|
+
admin.discover
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def snapshot
|
|
66
|
+
admin.snapshot(desired_state.property_id)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def audit
|
|
70
|
+
plan
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def plan
|
|
74
|
+
Planner.new(desired_state:, snapshot:).call
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def apply(plan_or_path, confirm: false)
|
|
78
|
+
saved_plan = plan_or_path.is_a?(Plan) ? plan_or_path : Plan.load(plan_or_path)
|
|
79
|
+
validate_plan_target!(saved_plan)
|
|
80
|
+
Applier.new(admin:).call(saved_plan, confirm:)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def verify
|
|
84
|
+
Verification.new(plan:)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def report(name_or_definition)
|
|
88
|
+
definition = report_definition(name_or_definition, kind: "standard")
|
|
89
|
+
data.run(desired_state.property_id, definition)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def realtime(name_or_definition = "realtime_events")
|
|
93
|
+
definition = report_definition(name_or_definition, kind: "realtime")
|
|
94
|
+
data.run(desired_state.property_id, definition)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def doctor
|
|
98
|
+
remote = snapshot
|
|
99
|
+
checks = [
|
|
100
|
+
{ "name" => "configuration", "status" => "ok", "detail" => "Profile #{desired_state.profile} is valid" },
|
|
101
|
+
{ "name" => "credentials", "status" => "ok", "detail" => "Google accepted discovered or injected credentials" },
|
|
102
|
+
{ "name" => "admin_api", "status" => "ok", "detail" => "Admin API read succeeded" },
|
|
103
|
+
{ "name" => "property_access", "status" => "ok", "detail" => "Read properties/#{remote.property_id}" }
|
|
104
|
+
]
|
|
105
|
+
checks.concat(compatibility_checks)
|
|
106
|
+
checks << clock_check
|
|
107
|
+
checks << edit_capability_check
|
|
108
|
+
data.run(desired_state.property_id, doctor_report_definition)
|
|
109
|
+
checks << { "name" => "data_api", "status" => "ok", "detail" => "Data API read succeeded" }
|
|
110
|
+
checks << {
|
|
111
|
+
"name" => "oauth_scopes",
|
|
112
|
+
"status" => "unknown",
|
|
113
|
+
"detail" => "Installed Google clients do not expose a reliable token-scope inspection contract"
|
|
114
|
+
}
|
|
115
|
+
admin.capabilities.each do |name, available|
|
|
116
|
+
checks << {
|
|
117
|
+
"name" => name,
|
|
118
|
+
"status" => available ? "ok" : "unsupported",
|
|
119
|
+
"detail" => available ? "Installed client exposes this capability" : "Installed client lacks this capability"
|
|
120
|
+
}
|
|
121
|
+
end
|
|
122
|
+
DoctorResult.new(checks:)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
private
|
|
126
|
+
|
|
127
|
+
def admin
|
|
128
|
+
@admin ||= Clients::Admin.new(
|
|
129
|
+
credentials: @credentials,
|
|
130
|
+
transport: @transport,
|
|
131
|
+
timeout: @timeout,
|
|
132
|
+
logger: @logger
|
|
133
|
+
)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def data
|
|
137
|
+
@data ||= Clients::Data.new(
|
|
138
|
+
credentials: @credentials,
|
|
139
|
+
transport: @transport,
|
|
140
|
+
timeout: @timeout,
|
|
141
|
+
logger: @logger
|
|
142
|
+
)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def compatibility_checks
|
|
146
|
+
[admin.compatibility, data.compatibility].map do |details|
|
|
147
|
+
{
|
|
148
|
+
"name" => details.fetch("package"),
|
|
149
|
+
"status" => details.fetch("supported") ? "ok" : "unsupported",
|
|
150
|
+
"detail" => "Installed #{details.fetch("version")}; supported #{details.fetch("requirement")}; " \
|
|
151
|
+
"transport #{details.fetch("transport")}"
|
|
152
|
+
}
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def clock_check
|
|
157
|
+
now = Time.now.utc
|
|
158
|
+
plausible = now.year.between?(2024, 2100)
|
|
159
|
+
{
|
|
160
|
+
"name" => "local_clock",
|
|
161
|
+
"status" => plausible ? "ok" : "error",
|
|
162
|
+
"detail" => plausible ? "Local UTC clock is plausible: #{now.iso8601}" : "Local UTC clock is implausible"
|
|
163
|
+
}
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def edit_capability_check
|
|
167
|
+
access = admin.property_access(desired_state.property_id)
|
|
168
|
+
case access.can_edit
|
|
169
|
+
when true
|
|
170
|
+
{ "name" => "edit_capability", "status" => "ok", "detail" => "Account summary reports edit access" }
|
|
171
|
+
when false
|
|
172
|
+
{ "name" => "edit_capability", "status" => "warning", "detail" => "Account summary reports read-only access" }
|
|
173
|
+
else
|
|
174
|
+
{ "name" => "edit_capability", "status" => "unknown", "detail" => "Google did not report edit capability" }
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def doctor_report_definition
|
|
179
|
+
Reports::Definition.new(
|
|
180
|
+
name: "doctor_connectivity",
|
|
181
|
+
kind: "standard",
|
|
182
|
+
dimensions: ["eventName"],
|
|
183
|
+
metrics: ["eventCount"],
|
|
184
|
+
date_ranges: [{ "start_date" => "yesterday", "end_date" => "yesterday" }],
|
|
185
|
+
limit: 1
|
|
186
|
+
)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def report_definition(name_or_definition, kind:)
|
|
190
|
+
definition = if name_or_definition.is_a?(Reports::Definition)
|
|
191
|
+
name_or_definition
|
|
192
|
+
else
|
|
193
|
+
Reports::Catalog.fetch(name_or_definition, kind:)
|
|
194
|
+
end
|
|
195
|
+
unless definition.kind == kind
|
|
196
|
+
raise InvalidRequestError, "Report #{definition.name} is #{definition.kind}, not #{kind}"
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
definition
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def validate_plan_target!(saved_plan)
|
|
203
|
+
unless saved_plan.profile == desired_state.profile
|
|
204
|
+
raise InvalidPlanError,
|
|
205
|
+
"Plan profile #{saved_plan.profile.inspect} does not match #{desired_state.profile.inspect}"
|
|
206
|
+
end
|
|
207
|
+
return if saved_plan.property_id == desired_state.property_id
|
|
208
|
+
|
|
209
|
+
raise InvalidPlanError, "Plan property #{saved_plan.property_id} does not match configured property"
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "analytics_ops/version"
|
|
4
|
+
require_relative "analytics_ops/errors"
|
|
5
|
+
require_relative "analytics_ops/redaction"
|
|
6
|
+
require_relative "analytics_ops/canonical"
|
|
7
|
+
require_relative "analytics_ops/resources"
|
|
8
|
+
require_relative "analytics_ops/desired_state"
|
|
9
|
+
require_relative "analytics_ops/configuration"
|
|
10
|
+
require_relative "analytics_ops/snapshot"
|
|
11
|
+
require_relative "analytics_ops/plan"
|
|
12
|
+
require_relative "analytics_ops/planner"
|
|
13
|
+
require_relative "analytics_ops/clients/admin"
|
|
14
|
+
require_relative "analytics_ops/reports"
|
|
15
|
+
require_relative "analytics_ops/clients/data"
|
|
16
|
+
require_relative "analytics_ops/applier"
|
|
17
|
+
require_relative "analytics_ops/workspace"
|
|
18
|
+
|
|
19
|
+
# Safe Google Analytics 4 configuration and reporting operations.
|
|
20
|
+
module AnalyticsOps
|
|
21
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
|
|
5
|
+
module AnalyticsOps
|
|
6
|
+
module Generators
|
|
7
|
+
# Installs only configuration and a project-local CLI launcher.
|
|
8
|
+
class InstallGenerator < Rails::Generators::Base
|
|
9
|
+
source_root File.expand_path("templates", __dir__)
|
|
10
|
+
|
|
11
|
+
desc "Create config/analytics_ops.yml and bin/analytics-ops"
|
|
12
|
+
|
|
13
|
+
def copy_configuration
|
|
14
|
+
template "analytics_ops.yml", "config/analytics_ops.yml"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def copy_executable
|
|
18
|
+
copy_file "analytics-ops", "bin/analytics-ops"
|
|
19
|
+
File.chmod(0o755, File.join(destination_root, "bin/analytics-ops"))
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Analytics Ops contains desired GA4 state only. Never place credentials here.
|
|
2
|
+
version: 1
|
|
3
|
+
|
|
4
|
+
profiles:
|
|
5
|
+
development:
|
|
6
|
+
# GA4 property ID (numeric), not an account ID, stream ID, or measurement ID.
|
|
7
|
+
property_id: "${GA4_PROPERTY_ID}"
|
|
8
|
+
|
|
9
|
+
streams:
|
|
10
|
+
web:
|
|
11
|
+
# Numeric stream ID from Admin > Data streams, not the G- measurement ID.
|
|
12
|
+
stream_id: "${GA4_STREAM_ID}"
|
|
13
|
+
default_uri: "https://example.test"
|
|
14
|
+
|
|
15
|
+
retention:
|
|
16
|
+
event_data: 14_months
|
|
17
|
+
user_data: 14_months
|
|
18
|
+
reset_on_new_activity: false
|
|
19
|
+
|
|
20
|
+
key_events: []
|
|
21
|
+
custom_dimensions: []
|
|
22
|
+
custom_metrics: []
|
|
23
|
+
manual_requirements: []
|