yes-core 1.0.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 +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +69 -0
- data/lib/yes/core/active_job_serializers/command_group_serializer.rb +29 -0
- data/lib/yes/core/active_job_serializers/dry_struct_serializer.rb +57 -0
- data/lib/yes/core/aggregate/draftable.rb +205 -0
- data/lib/yes/core/aggregate/dsl/attribute_data.rb +37 -0
- data/lib/yes/core/aggregate/dsl/attribute_definer.rb +54 -0
- data/lib/yes/core/aggregate/dsl/attribute_definers/aggregate.rb +36 -0
- data/lib/yes/core/aggregate/dsl/attribute_definers/standard.rb +36 -0
- data/lib/yes/core/aggregate/dsl/class_name_convention.rb +80 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/authorizer.rb +132 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/base.rb +80 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/command/authorizer.rb +30 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/command/authorizer_factory.rb +34 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/command/base.rb +38 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/command/cerbos_authorizer.rb +114 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/command/command.rb +70 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/command/event.rb +88 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/command/guard_evaluator.rb +84 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/command/simple_authorizer.rb +50 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/command/state_updater.rb +46 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/read_model.rb +75 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/read_model_filter.rb +88 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/read_model_serializer.rb +76 -0
- data/lib/yes/core/aggregate/dsl/command_data.rb +54 -0
- data/lib/yes/core/aggregate/dsl/command_definer.rb +263 -0
- data/lib/yes/core/aggregate/dsl/command_shortcut_expander.rb +233 -0
- data/lib/yes/core/aggregate/dsl/constant_resolver.rb +67 -0
- data/lib/yes/core/aggregate/dsl/method_definers/attribute/accessor.rb +28 -0
- data/lib/yes/core/aggregate/dsl/method_definers/attribute/aggregate_accessor.rb +36 -0
- data/lib/yes/core/aggregate/dsl/method_definers/attribute/base.rb +42 -0
- data/lib/yes/core/aggregate/dsl/method_definers/command/base.rb +42 -0
- data/lib/yes/core/aggregate/dsl/method_definers/command/can_command.rb +41 -0
- data/lib/yes/core/aggregate/dsl/method_definers/command/command.rb +50 -0
- data/lib/yes/core/aggregate/has_authorizer.rb +86 -0
- data/lib/yes/core/aggregate/has_read_model.rb +169 -0
- data/lib/yes/core/aggregate/read_model_rebuilder.rb +40 -0
- data/lib/yes/core/aggregate/shared_read_model_rebuilder.rb +158 -0
- data/lib/yes/core/aggregate.rb +404 -0
- data/lib/yes/core/authentication_error.rb +8 -0
- data/lib/yes/core/authorization/cerbos_client_provider.rb +27 -0
- data/lib/yes/core/authorization/command_authorizer.rb +40 -0
- data/lib/yes/core/authorization/command_cerbos_authorizer.rb +182 -0
- data/lib/yes/core/authorization/read_model_authorizer.rb +22 -0
- data/lib/yes/core/authorization/read_models_authorizer.rb +49 -0
- data/lib/yes/core/authorization/read_request_authorizer.rb +32 -0
- data/lib/yes/core/authorization/read_request_cerbos_authorizer.rb +112 -0
- data/lib/yes/core/command.rb +35 -0
- data/lib/yes/core/command_handling/aggregate_tracker.rb +33 -0
- data/lib/yes/core/command_handling/command_executor.rb +171 -0
- data/lib/yes/core/command_handling/command_handler.rb +124 -0
- data/lib/yes/core/command_handling/event_publisher.rb +189 -0
- data/lib/yes/core/command_handling/guard_evaluator.rb +165 -0
- data/lib/yes/core/command_handling/guard_runner.rb +76 -0
- data/lib/yes/core/command_handling/payload_proxy.rb +159 -0
- data/lib/yes/core/command_handling/read_model_recovery_service.rb +264 -0
- data/lib/yes/core/command_handling/read_model_revision_guard.rb +198 -0
- data/lib/yes/core/command_handling/read_model_updater.rb +103 -0
- data/lib/yes/core/command_handling/state_updater.rb +113 -0
- data/lib/yes/core/commands/bus.rb +46 -0
- data/lib/yes/core/commands/group.rb +135 -0
- data/lib/yes/core/commands/group_response.rb +13 -0
- data/lib/yes/core/commands/helper.rb +126 -0
- data/lib/yes/core/commands/notifier.rb +65 -0
- data/lib/yes/core/commands/processor.rb +137 -0
- data/lib/yes/core/commands/response.rb +63 -0
- data/lib/yes/core/commands/stateless/group_handler.rb +186 -0
- data/lib/yes/core/commands/stateless/group_response.rb +15 -0
- data/lib/yes/core/commands/stateless/handler.rb +292 -0
- data/lib/yes/core/commands/stateless/handler_helpers.rb +321 -0
- data/lib/yes/core/commands/stateless/response.rb +14 -0
- data/lib/yes/core/commands/stateless/subject.rb +41 -0
- data/lib/yes/core/commands/validator.rb +28 -0
- data/lib/yes/core/configuration.rb +432 -0
- data/lib/yes/core/data_decryptor.rb +59 -0
- data/lib/yes/core/data_encryptor.rb +60 -0
- data/lib/yes/core/encryption_metadata.rb +33 -0
- data/lib/yes/core/error.rb +14 -0
- data/lib/yes/core/error_messages.rb +37 -0
- data/lib/yes/core/event.rb +222 -0
- data/lib/yes/core/event_class_resolver.rb +40 -0
- data/lib/yes/core/generators/read_models/add_pending_update_tracking_generator.rb +43 -0
- data/lib/yes/core/generators/read_models/templates/add_pending_update_tracking.rb.erb +122 -0
- data/lib/yes/core/generators/read_models/templates/migration.rb.erb +9 -0
- data/lib/yes/core/generators/read_models/update_generator.rb +147 -0
- data/lib/yes/core/jobs/read_model_recovery_job.rb +219 -0
- data/lib/yes/core/middlewares/encryptor.rb +48 -0
- data/lib/yes/core/middlewares/timestamp.rb +29 -0
- data/lib/yes/core/middlewares/with_indifferent_access.rb +22 -0
- data/lib/yes/core/models/application_record.rb +9 -0
- data/lib/yes/core/open_telemetry/otl_span.rb +110 -0
- data/lib/yes/core/open_telemetry/trackable.rb +101 -0
- data/lib/yes/core/payload_store/base.rb +33 -0
- data/lib/yes/core/payload_store/errors.rb +13 -0
- data/lib/yes/core/payload_store/lookup.rb +44 -0
- data/lib/yes/core/process_managers/access_token_client.rb +107 -0
- data/lib/yes/core/process_managers/base.rb +40 -0
- data/lib/yes/core/process_managers/command_runner.rb +109 -0
- data/lib/yes/core/process_managers/service_client.rb +57 -0
- data/lib/yes/core/process_managers/state.rb +118 -0
- data/lib/yes/core/railtie.rb +58 -0
- data/lib/yes/core/read_model/builder.rb +267 -0
- data/lib/yes/core/read_model/event_handler.rb +64 -0
- data/lib/yes/core/read_model/filter.rb +118 -0
- data/lib/yes/core/read_model/filter_query_builder.rb +104 -0
- data/lib/yes/core/serializer.rb +21 -0
- data/lib/yes/core/subscriptions.rb +94 -0
- data/lib/yes/core/test_support/event_helpers.rb +27 -0
- data/lib/yes/core/test_support/jwt_helpers.rb +30 -0
- data/lib/yes/core/test_support/subscriptions_helper.rb +88 -0
- data/lib/yes/core/test_support/test_helper.rb +27 -0
- data/lib/yes/core/test_support.rb +5 -0
- data/lib/yes/core/transaction_details.rb +90 -0
- data/lib/yes/core/type_lookup.rb +88 -0
- data/lib/yes/core/types.rb +110 -0
- data/lib/yes/core/utils/aggregate_shortcuts.rb +164 -0
- data/lib/yes/core/utils/caller_utils.rb +37 -0
- data/lib/yes/core/utils/command_utils.rb +226 -0
- data/lib/yes/core/utils/error_notifier.rb +101 -0
- data/lib/yes/core/utils/event_name_resolver.rb +67 -0
- data/lib/yes/core/utils/exponential_retrier.rb +180 -0
- data/lib/yes/core/utils/hash_utils.rb +63 -0
- data/lib/yes/core/version.rb +7 -0
- data/lib/yes/core.rb +85 -0
- data/lib/yes.rb +0 -0
- metadata +324 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Core
|
|
5
|
+
module Jobs
|
|
6
|
+
# Background job that runs periodically to recover stuck read models
|
|
7
|
+
# This job should be scheduled to run every 30 seconds via cron or similar
|
|
8
|
+
class ReadModelRecoveryJob < ActiveJob::Base
|
|
9
|
+
# Circuit breaker configuration
|
|
10
|
+
MAX_CONSECUTIVE_FAILURES = 5
|
|
11
|
+
CIRCUIT_BREAKER_TIMEOUT = 5.minutes
|
|
12
|
+
|
|
13
|
+
queue_as :critical
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
# Track circuit breaker state in memory (or Redis in production)
|
|
17
|
+
attr_accessor :consecutive_failures, :circuit_opened_at
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
self.consecutive_failures = 0
|
|
21
|
+
self.circuit_opened_at = nil
|
|
22
|
+
|
|
23
|
+
# Performs the recovery of stuck read models
|
|
24
|
+
# @param stuck_timeout [Integer] Minutes after which a model is considered stuck (default: 1)
|
|
25
|
+
# @param batch_size [Integer] Number of read models to process at once (default: 100)
|
|
26
|
+
def perform(stuck_timeout_minutes: 1, batch_size: 100)
|
|
27
|
+
# Check circuit breaker
|
|
28
|
+
if circuit_open?
|
|
29
|
+
Rails.logger.warn('ReadModelRecoveryJob circuit breaker is open, skipping execution')
|
|
30
|
+
return
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
stuck_timeout = stuck_timeout_minutes.minutes
|
|
34
|
+
|
|
35
|
+
Rails.logger.info("Starting read model recovery scan (timeout: #{stuck_timeout_minutes} minutes)")
|
|
36
|
+
|
|
37
|
+
results = Yes::Core::CommandHandling::ReadModelRecoveryService.recover_all_stuck_read_models(
|
|
38
|
+
stuck_timeout:,
|
|
39
|
+
batch_size:
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
process_results(results)
|
|
43
|
+
|
|
44
|
+
# Reset circuit breaker on success
|
|
45
|
+
self.class.consecutive_failures = 0
|
|
46
|
+
|
|
47
|
+
Rails.logger.info("Read model recovery scan completed: #{results.size} models processed")
|
|
48
|
+
rescue ActiveRecord::ActiveRecordError => e
|
|
49
|
+
# Database-related errors (connection issues, deadlocks, etc.)
|
|
50
|
+
Rails.logger.error("Database error during read model recovery: #{e.message}")
|
|
51
|
+
handle_job_failure(e)
|
|
52
|
+
raise
|
|
53
|
+
rescue PgEventstore::Error => e
|
|
54
|
+
# Event store related errors (revision conflicts, stream errors)
|
|
55
|
+
Rails.logger.error("Event store error during read model recovery: #{e.message}")
|
|
56
|
+
handle_job_failure(e)
|
|
57
|
+
raise
|
|
58
|
+
rescue NameError, NoMethodError => e
|
|
59
|
+
# Configuration or class loading errors - these should not trigger circuit breaker
|
|
60
|
+
Rails.logger.error("Configuration error during read model recovery: #{e.message}")
|
|
61
|
+
Rails.logger.error('This likely indicates a misconfiguration - please check your read model classes')
|
|
62
|
+
# Don't increment circuit breaker for configuration errors
|
|
63
|
+
raise
|
|
64
|
+
rescue StandardError => e
|
|
65
|
+
# Unexpected errors - log with full backtrace for debugging
|
|
66
|
+
Rails.logger.error("Unexpected error during read model recovery: #{e.class.name} - #{e.message}")
|
|
67
|
+
Rails.logger.error(e.backtrace.join("\n"))
|
|
68
|
+
|
|
69
|
+
# Still handle as a failure for circuit breaker
|
|
70
|
+
handle_job_failure(e)
|
|
71
|
+
|
|
72
|
+
# Re-raise to ensure job framework knows it failed
|
|
73
|
+
raise
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
# Processes the recovery results and tracks metrics
|
|
79
|
+
# @param results [Array<RecoveryResult>] The recovery results
|
|
80
|
+
def process_results(results)
|
|
81
|
+
successful = results.count(&:success)
|
|
82
|
+
failed = results.count { |r| !r.success }
|
|
83
|
+
|
|
84
|
+
if failed.positive?
|
|
85
|
+
Rails.logger.warn("Read model recovery had #{failed} failures out of #{results.size} attempts")
|
|
86
|
+
|
|
87
|
+
# Send alert if too many failures
|
|
88
|
+
alert_on_high_failure_rate(failed, results.size) if failed > results.size / 2
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Track metrics (integrate with your monitoring solution)
|
|
92
|
+
track_metrics(successful:, failed:)
|
|
93
|
+
|
|
94
|
+
# Alert on models stuck for too long
|
|
95
|
+
check_for_long_stuck_models
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Checks if circuit breaker is open
|
|
99
|
+
# @return [Boolean] True if circuit is open
|
|
100
|
+
def circuit_open?
|
|
101
|
+
return false unless self.class.circuit_opened_at
|
|
102
|
+
|
|
103
|
+
if Time.current - self.class.circuit_opened_at > CIRCUIT_BREAKER_TIMEOUT
|
|
104
|
+
Rails.logger.info('ReadModelRecoveryJob circuit breaker timeout expired, resetting')
|
|
105
|
+
self.class.circuit_opened_at = nil
|
|
106
|
+
self.class.consecutive_failures = 0
|
|
107
|
+
false
|
|
108
|
+
else
|
|
109
|
+
true
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Handles job failure and manages circuit breaker
|
|
114
|
+
# @param error [Exception] The error that occurred
|
|
115
|
+
def handle_job_failure(error)
|
|
116
|
+
self.class.consecutive_failures += 1
|
|
117
|
+
|
|
118
|
+
Rails.logger.error(
|
|
119
|
+
"ReadModelRecoveryJob failed (attempt #{self.class.consecutive_failures}): #{error.message}"
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
return unless self.class.consecutive_failures >= MAX_CONSECUTIVE_FAILURES
|
|
123
|
+
|
|
124
|
+
self.class.circuit_opened_at = Time.current
|
|
125
|
+
Rails.logger.error(
|
|
126
|
+
"ReadModelRecoveryJob circuit breaker opened after #{MAX_CONSECUTIVE_FAILURES} consecutive failures"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# Send critical alert
|
|
130
|
+
alert_on_circuit_breaker_open
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Checks for models that have been stuck for too long
|
|
134
|
+
def check_for_long_stuck_models
|
|
135
|
+
critical_timeout = 5.minutes
|
|
136
|
+
|
|
137
|
+
# Find models stuck for more than critical timeout
|
|
138
|
+
long_stuck_models = find_long_stuck_models(critical_timeout)
|
|
139
|
+
|
|
140
|
+
return unless long_stuck_models.any?
|
|
141
|
+
|
|
142
|
+
alert_on_long_stuck_models(long_stuck_models)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Finds read models stuck for longer than the specified timeout
|
|
146
|
+
# @param timeout [ActiveSupport::Duration] The timeout duration
|
|
147
|
+
# @return [Array<ActiveRecord::Base>] Long stuck models
|
|
148
|
+
def find_long_stuck_models(timeout)
|
|
149
|
+
models = []
|
|
150
|
+
|
|
151
|
+
read_model_classes.each do |model_class|
|
|
152
|
+
next unless model_class.column_names.include?('pending_update_since')
|
|
153
|
+
|
|
154
|
+
models.concat(
|
|
155
|
+
model_class.
|
|
156
|
+
where.not(pending_update_since: nil).
|
|
157
|
+
where(pending_update_since: ...timeout.ago).
|
|
158
|
+
to_a
|
|
159
|
+
)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
models
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Gets all read model classes from configuration
|
|
166
|
+
# @return [Array<Class>] Read model classes
|
|
167
|
+
def read_model_classes
|
|
168
|
+
Yes::Core.configuration.all_read_model_classes
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Tracks metrics (implement based on your monitoring solution)
|
|
172
|
+
# @param successful [Integer] Number of successful recoveries
|
|
173
|
+
# @param failed [Integer] Number of failed recoveries
|
|
174
|
+
def track_metrics(successful:, failed:)
|
|
175
|
+
# Example for StatsD/DataDog
|
|
176
|
+
# StatsD.gauge('read_model_recovery.successful', successful)
|
|
177
|
+
# StatsD.gauge('read_model_recovery.failed', failed)
|
|
178
|
+
|
|
179
|
+
# Example for Prometheus
|
|
180
|
+
# Prometheus.gauge(:read_model_recovery_successful, successful)
|
|
181
|
+
# Prometheus.gauge(:read_model_recovery_failed, failed)
|
|
182
|
+
|
|
183
|
+
Rails.logger.info("Recovery metrics - Successful: #{successful}, Failed: #{failed}")
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Sends alert on high failure rate
|
|
187
|
+
# @param failed [Integer] Number of failures
|
|
188
|
+
# @param total [Integer] Total attempts
|
|
189
|
+
def alert_on_high_failure_rate(failed, total)
|
|
190
|
+
message = "High read model recovery failure rate: #{failed}/#{total} attempts failed"
|
|
191
|
+
Rails.logger.error(message)
|
|
192
|
+
|
|
193
|
+
# Implement your alerting mechanism here
|
|
194
|
+
# Example: Sentry.capture_message(message, level: :error)
|
|
195
|
+
# Example: Slack.notify(message, channel: '#alerts')
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Sends alert when circuit breaker opens
|
|
199
|
+
def alert_on_circuit_breaker_open
|
|
200
|
+
message = 'ReadModelRecoveryJob circuit breaker opened - job execution suspended'
|
|
201
|
+
Rails.logger.error(message)
|
|
202
|
+
|
|
203
|
+
# Implement critical alerting here
|
|
204
|
+
# Example: PagerDuty.trigger(message)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Sends alert for long stuck models
|
|
208
|
+
# @param models [Array<ActiveRecord::Base>] The stuck models
|
|
209
|
+
def alert_on_long_stuck_models(models)
|
|
210
|
+
message = "#{models.size} read models stuck for > 5 minutes: " \
|
|
211
|
+
"#{models.map { |m| "#{m.class.name}##{m.id}" }.join(', ')}"
|
|
212
|
+
Rails.logger.error(message)
|
|
213
|
+
|
|
214
|
+
# Implement critical alerting here
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Core
|
|
5
|
+
module Middlewares
|
|
6
|
+
# PgEventstore middleware for encrypting/decrypting event data.
|
|
7
|
+
#
|
|
8
|
+
# @example
|
|
9
|
+
# PgEventstore.configure do |config|
|
|
10
|
+
# config.middlewares[:encryptor] = Yes::Core::Middlewares::Encryptor.new(key_repository)
|
|
11
|
+
# end
|
|
12
|
+
class Encryptor
|
|
13
|
+
include PgEventstore::Middleware
|
|
14
|
+
|
|
15
|
+
attr_reader :key_repository
|
|
16
|
+
private :key_repository
|
|
17
|
+
|
|
18
|
+
# @param key_repository [#find, #create, #encrypt, #decrypt]
|
|
19
|
+
def initialize(key_repository)
|
|
20
|
+
@key_repository = key_repository
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @param event [PgEventstore::Event]
|
|
24
|
+
# @return [PgEventstore::Event]
|
|
25
|
+
def serialize(event)
|
|
26
|
+
return event unless event.class.respond_to?(:encryption_schema)
|
|
27
|
+
|
|
28
|
+
encryptor = DataEncryptor.new(
|
|
29
|
+
data: event.data, schema: event.class.encryption_schema, repository: key_repository
|
|
30
|
+
)
|
|
31
|
+
encryptor.call
|
|
32
|
+
event.data = encryptor.encrypted_data
|
|
33
|
+
event.metadata['encryption'] = encryptor.encryption_metadata
|
|
34
|
+
event
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @param event [PgEventstore::Event]
|
|
38
|
+
# @return [PgEventstore::Event]
|
|
39
|
+
def deserialize(event)
|
|
40
|
+
decrypted_data =
|
|
41
|
+
DataDecryptor.new(data: event.data, schema: event.metadata['encryption'], repository: key_repository).call
|
|
42
|
+
event.data = decrypted_data
|
|
43
|
+
event
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Core
|
|
5
|
+
module Middlewares
|
|
6
|
+
# PgEventstore middleware that adds a created_at timestamp to event metadata
|
|
7
|
+
# on serialization and parses it back on deserialization.
|
|
8
|
+
class Timestamp
|
|
9
|
+
include PgEventstore::Middleware
|
|
10
|
+
|
|
11
|
+
# @param event [PgEventstore::Event]
|
|
12
|
+
# @return [PgEventstore::Event]
|
|
13
|
+
def serialize(event)
|
|
14
|
+
event.metadata[:created_at] ||= Time.now.utc
|
|
15
|
+
event
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @param event [PgEventstore::Event]
|
|
19
|
+
# @return [PgEventstore::Event]
|
|
20
|
+
def deserialize(event)
|
|
21
|
+
return event unless event.metadata.key?('created_at')
|
|
22
|
+
|
|
23
|
+
event.metadata['created_at'] = Time.zone.parse(event.metadata['created_at'])
|
|
24
|
+
event
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Core
|
|
5
|
+
module Middlewares
|
|
6
|
+
# PgEventstore middleware that converts event data and metadata
|
|
7
|
+
# to HashWithIndifferentAccess, allowing both string and symbol key access.
|
|
8
|
+
class WithIndifferentAccess
|
|
9
|
+
include PgEventstore::Middleware
|
|
10
|
+
|
|
11
|
+
# @param event [PgEventstore::Event]
|
|
12
|
+
# @return [PgEventstore::Event]
|
|
13
|
+
def serialize(event)
|
|
14
|
+
event.metadata = event.metadata.with_indifferent_access
|
|
15
|
+
event.data = event.data.with_indifferent_access
|
|
16
|
+
event
|
|
17
|
+
end
|
|
18
|
+
alias deserialize serialize
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Core
|
|
5
|
+
module OpenTelemetry
|
|
6
|
+
# Wraps OpenTelemetry span creation with SQL tracking support.
|
|
7
|
+
#
|
|
8
|
+
# @example
|
|
9
|
+
# span = OtlSpan.new(otl_data: OtlData.new(span_name: 'MySpan'), otl_tracer: tracer)
|
|
10
|
+
# span.otl_span(arg1, arg2) { do_work }
|
|
11
|
+
class OtlSpan
|
|
12
|
+
# Configuration struct for OpenTelemetry span data
|
|
13
|
+
OtlData = Struct.new(:span_name, :span_kind, :span_attributes, :links_extractor, :track_sql) do
|
|
14
|
+
# @param span_name [String, nil] name of the span
|
|
15
|
+
# @param span_kind [Symbol] kind of span (:internal, :client, :server, :producer, :consumer)
|
|
16
|
+
# @param span_attributes [Hash] additional span attributes
|
|
17
|
+
# @param links_extractor [Proc] extracts OTL context links from arguments
|
|
18
|
+
# @param track_sql [Boolean] whether to track SQL queries within the span
|
|
19
|
+
def initialize(span_name: nil, span_kind: :internal, span_attributes: {}, links_extractor: proc { [] },
|
|
20
|
+
track_sql: false)
|
|
21
|
+
super
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @return [OtlData] span configuration
|
|
26
|
+
attr_reader :otl_data
|
|
27
|
+
|
|
28
|
+
# @return [Object] OpenTelemetry tracer instance
|
|
29
|
+
attr_reader :otl_tracer
|
|
30
|
+
|
|
31
|
+
# @param otl_data [OtlData] span configuration
|
|
32
|
+
# @param otl_tracer [Object] OpenTelemetry tracer instance
|
|
33
|
+
def initialize(otl_data:, otl_tracer:)
|
|
34
|
+
@otl_data = otl_data
|
|
35
|
+
@otl_tracer = otl_tracer
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Creates a span and executes the given block within it.
|
|
39
|
+
#
|
|
40
|
+
# @param args [Array] positional arguments passed to the links extractor
|
|
41
|
+
# @param kwargs [Hash] keyword arguments passed to the links extractor
|
|
42
|
+
# @yield the block to execute within the span
|
|
43
|
+
# @return [Object] the return value of the block
|
|
44
|
+
def otl_span(*args, **kwargs, &block)
|
|
45
|
+
links = otl_links(args, kwargs)
|
|
46
|
+
|
|
47
|
+
parent_span = ::OpenTelemetry::Trace.current_span.context.valid? ? ::OpenTelemetry::Trace.current_span : nil
|
|
48
|
+
root_track_sql = parent_span&.try(:attributes)&.[]('root_track_sql') ||
|
|
49
|
+
parent_span&.try(:attributes)&.[]('track_sql')
|
|
50
|
+
|
|
51
|
+
otl_tracer.in_span(
|
|
52
|
+
otl_data.span_name || 'UnknownName',
|
|
53
|
+
links:,
|
|
54
|
+
kind: otl_data.span_kind,
|
|
55
|
+
attributes: {
|
|
56
|
+
'track_sql' => otl_data.track_sql,
|
|
57
|
+
'root_track_sql' => root_track_sql || false
|
|
58
|
+
}.merge(otl_data.span_attributes)
|
|
59
|
+
) do
|
|
60
|
+
next unless block_given?
|
|
61
|
+
next yield if !root_track_sql && !otl_data.track_sql
|
|
62
|
+
next yield if parent_span.present? && root_track_sql
|
|
63
|
+
|
|
64
|
+
callback = lambda do |sql_event|
|
|
65
|
+
next if %w[SCHEMA TRANSACTION].include?(sql_event.payload[:name])
|
|
66
|
+
|
|
67
|
+
otl_tracer.in_span("SQL #{sql_event.payload[:name]}") do |span|
|
|
68
|
+
span.set_attribute('db.system', 'postgresql')
|
|
69
|
+
span.set_attribute('db.statement', sql_event.payload[:sql])
|
|
70
|
+
span.set_attribute('db.binds', sql_event.payload[:binds].map do |attr|
|
|
71
|
+
next { name: attr.name, value: attr.value } if attr.respond_to?(:name) && attr.respond_to?(:value)
|
|
72
|
+
|
|
73
|
+
{ name: attr.class.to_s, value: attr }
|
|
74
|
+
end.to_json)
|
|
75
|
+
span.set_attribute('db.event_name', sql_event.payload[:name])
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
ActiveSupport::Notifications.subscribed(callback, 'sql.active_record', &block)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
# Extracts OpenTelemetry links from arguments using the configured links_extractor.
|
|
85
|
+
#
|
|
86
|
+
# @param args [Array] positional arguments
|
|
87
|
+
# @param kwargs [Hash] keyword arguments
|
|
88
|
+
# @return [Array<OpenTelemetry::Trace::Link>] extracted links
|
|
89
|
+
def otl_links(args, kwargs)
|
|
90
|
+
return [] if args.blank? && kwargs.blank?
|
|
91
|
+
return [] unless (otl_contexts = otl_data.links_extractor.call(*args, **kwargs).presence)
|
|
92
|
+
|
|
93
|
+
otl_contexts.filter_map do |_context_name, context_data|
|
|
94
|
+
next unless context_data['traceparent']
|
|
95
|
+
|
|
96
|
+
trace_parent_ctx = ::OpenTelemetry::Trace::Propagation::TraceContext::TraceParent.from_string(
|
|
97
|
+
context_data['traceparent']
|
|
98
|
+
)
|
|
99
|
+
trace_span_ctx = ::OpenTelemetry::Trace::SpanContext.new(
|
|
100
|
+
trace_id: trace_parent_ctx.trace_id,
|
|
101
|
+
span_id: trace_parent_ctx.span_id,
|
|
102
|
+
trace_flags: trace_parent_ctx.flags
|
|
103
|
+
)
|
|
104
|
+
::OpenTelemetry::Trace::Link.new(trace_span_ctx)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Core
|
|
5
|
+
module OpenTelemetry
|
|
6
|
+
# Mixin for adding OpenTelemetry tracing to classes.
|
|
7
|
+
#
|
|
8
|
+
# When OpenTelemetry is not configured (no tracer set), all tracing
|
|
9
|
+
# operations are no-ops with zero overhead.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# class MyService
|
|
13
|
+
# include Yes::Core::OpenTelemetry::Trackable
|
|
14
|
+
#
|
|
15
|
+
# def process(data)
|
|
16
|
+
# # implementation
|
|
17
|
+
# end
|
|
18
|
+
# otl_trackable :process, OtlSpan::OtlData.new(span_name: 'Process Data')
|
|
19
|
+
# end
|
|
20
|
+
module Trackable
|
|
21
|
+
extend ActiveSupport::Concern
|
|
22
|
+
|
|
23
|
+
module ClassMethods
|
|
24
|
+
# Decorates a method with OpenTelemetry tracing.
|
|
25
|
+
#
|
|
26
|
+
# @param method_name [Symbol] the method to track
|
|
27
|
+
# @param otl_data [OtlSpan::OtlData] span configuration
|
|
28
|
+
# @return [Symbol] the tracked method name
|
|
29
|
+
def otl_trackable(method_name, otl_data = OtlSpan::OtlData.new(span_name: nil))
|
|
30
|
+
otl_data.span_name ||= name
|
|
31
|
+
|
|
32
|
+
instance_module = Module.new do
|
|
33
|
+
define_method(method_name) do |*args, **kwargs, &blk|
|
|
34
|
+
return super(*args, **kwargs, &blk) unless singleton_class.otl_tracer
|
|
35
|
+
|
|
36
|
+
OtlSpan.new(otl_data:, otl_tracer: singleton_class.otl_tracer).otl_span(*args, **kwargs) do
|
|
37
|
+
super(*args, **kwargs, &blk)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
prepend instance_module
|
|
42
|
+
|
|
43
|
+
method_name
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @return [Object, nil] the configured OpenTelemetry tracer or nil
|
|
47
|
+
def otl_tracer
|
|
48
|
+
Yes::Core.configuration.otl_tracer
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @return [OpenTelemetry::Trace::Span, nil] the current span or nil if no tracer
|
|
52
|
+
def current_span
|
|
53
|
+
return nil unless otl_tracer
|
|
54
|
+
|
|
55
|
+
::OpenTelemetry::Trace.current_span
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# @return [OpenTelemetry::Context, nil] the current context or nil if no tracer
|
|
59
|
+
def current_context
|
|
60
|
+
return nil unless otl_tracer
|
|
61
|
+
|
|
62
|
+
::OpenTelemetry::Context.current
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Executes a block within a new span.
|
|
66
|
+
#
|
|
67
|
+
# @param name [String] the span name
|
|
68
|
+
# @yield the block to execute
|
|
69
|
+
# @return [Object] the return value of the block
|
|
70
|
+
def with_otl_span(name, &)
|
|
71
|
+
return yield unless otl_tracer
|
|
72
|
+
|
|
73
|
+
otl_tracer.in_span(name, &)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Propagates the current context to a carrier hash.
|
|
77
|
+
#
|
|
78
|
+
# @param ctx_carrier [Hash] the carrier to inject context into
|
|
79
|
+
# @param service_name [Boolean] whether to include the service name
|
|
80
|
+
# @return [HashWithIndifferentAccess] the carrier with context
|
|
81
|
+
def propagate_context(ctx_carrier = {}, service_name: false)
|
|
82
|
+
ctx_carrier.tap do |carrier|
|
|
83
|
+
::OpenTelemetry.propagation.inject(carrier)
|
|
84
|
+
ctx_carrier[:service] = Rails.application.class.module_parent.name if service_name
|
|
85
|
+
end.with_indifferent_access
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Extracts context from a carrier hash.
|
|
89
|
+
#
|
|
90
|
+
# @param carrier [Hash, nil] the carrier to extract from
|
|
91
|
+
# @return [OpenTelemetry::Context, nil] the extracted context
|
|
92
|
+
def extract_current_context(carrier)
|
|
93
|
+
return nil unless carrier&.key?('traceparent')
|
|
94
|
+
|
|
95
|
+
::OpenTelemetry.propagation.extract(carrier)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Core
|
|
5
|
+
module PayloadStore
|
|
6
|
+
# Base class for payload store operations providing shared error handling
|
|
7
|
+
# and client access.
|
|
8
|
+
class Base
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
# @param response [Object] the payload store response
|
|
12
|
+
# @param events [Object] the events associated with the request
|
|
13
|
+
# @return [Boolean] true if the response was an error
|
|
14
|
+
def handle_payload_store_error(response, events)
|
|
15
|
+
return false if response.success?
|
|
16
|
+
|
|
17
|
+
msg = "Payload Store error for class: #{self.class.name}"
|
|
18
|
+
error = Yes::Core::PayloadStore::Errors::ClientError.new(
|
|
19
|
+
msg, extra: { payload_store_response: response.failure, events: }
|
|
20
|
+
)
|
|
21
|
+
Yes::Core::Utils::ErrorNotifier.new.payload_extraction_failed(error)
|
|
22
|
+
|
|
23
|
+
true
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @return [Object] the configured payload store client
|
|
27
|
+
def payload_store_client
|
|
28
|
+
@payload_store_client ||= Yes::Core.configuration.payload_store_client
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Core
|
|
5
|
+
module PayloadStore
|
|
6
|
+
# Resolves payload store references in events by looking up stored values.
|
|
7
|
+
class Lookup < Base
|
|
8
|
+
# @param event [PgEventstore::Event] the event with potential payload store references
|
|
9
|
+
# @return [Hash] resolved key-value pairs from the payload store
|
|
10
|
+
def call(event)
|
|
11
|
+
return {} unless event.respond_to?(:ps_fields_with_values)
|
|
12
|
+
|
|
13
|
+
keys = event.ps_fields_with_values
|
|
14
|
+
return {} if keys.blank?
|
|
15
|
+
|
|
16
|
+
raise Yes::Core::PayloadStore::Errors::MissingClient unless payload_store_client
|
|
17
|
+
|
|
18
|
+
ps_response = payload_store_client.get_payloads(keys.values)
|
|
19
|
+
|
|
20
|
+
unless ps_response.success?
|
|
21
|
+
handle_payload_store_error(ps_response, event)
|
|
22
|
+
return {}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
resolved_payloads(ps_response, keys)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
# @param response [Object] the successful payload store response
|
|
31
|
+
# @param keys [Hash] the original key mappings
|
|
32
|
+
# @return [Hash] resolved payloads mapped back to their original keys
|
|
33
|
+
def resolved_payloads(response, keys)
|
|
34
|
+
response.value!.each_with_object({}) do |resp, ps_attr_hash|
|
|
35
|
+
key = keys.key(resp.attributes[:key])
|
|
36
|
+
next unless key
|
|
37
|
+
|
|
38
|
+
ps_attr_hash[key] = resp.attributes[:value]
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|