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.
Files changed (128) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +69 -0
  5. data/lib/yes/core/active_job_serializers/command_group_serializer.rb +29 -0
  6. data/lib/yes/core/active_job_serializers/dry_struct_serializer.rb +57 -0
  7. data/lib/yes/core/aggregate/draftable.rb +205 -0
  8. data/lib/yes/core/aggregate/dsl/attribute_data.rb +37 -0
  9. data/lib/yes/core/aggregate/dsl/attribute_definer.rb +54 -0
  10. data/lib/yes/core/aggregate/dsl/attribute_definers/aggregate.rb +36 -0
  11. data/lib/yes/core/aggregate/dsl/attribute_definers/standard.rb +36 -0
  12. data/lib/yes/core/aggregate/dsl/class_name_convention.rb +80 -0
  13. data/lib/yes/core/aggregate/dsl/class_resolvers/authorizer.rb +132 -0
  14. data/lib/yes/core/aggregate/dsl/class_resolvers/base.rb +80 -0
  15. data/lib/yes/core/aggregate/dsl/class_resolvers/command/authorizer.rb +30 -0
  16. data/lib/yes/core/aggregate/dsl/class_resolvers/command/authorizer_factory.rb +34 -0
  17. data/lib/yes/core/aggregate/dsl/class_resolvers/command/base.rb +38 -0
  18. data/lib/yes/core/aggregate/dsl/class_resolvers/command/cerbos_authorizer.rb +114 -0
  19. data/lib/yes/core/aggregate/dsl/class_resolvers/command/command.rb +70 -0
  20. data/lib/yes/core/aggregate/dsl/class_resolvers/command/event.rb +88 -0
  21. data/lib/yes/core/aggregate/dsl/class_resolvers/command/guard_evaluator.rb +84 -0
  22. data/lib/yes/core/aggregate/dsl/class_resolvers/command/simple_authorizer.rb +50 -0
  23. data/lib/yes/core/aggregate/dsl/class_resolvers/command/state_updater.rb +46 -0
  24. data/lib/yes/core/aggregate/dsl/class_resolvers/read_model.rb +75 -0
  25. data/lib/yes/core/aggregate/dsl/class_resolvers/read_model_filter.rb +88 -0
  26. data/lib/yes/core/aggregate/dsl/class_resolvers/read_model_serializer.rb +76 -0
  27. data/lib/yes/core/aggregate/dsl/command_data.rb +54 -0
  28. data/lib/yes/core/aggregate/dsl/command_definer.rb +263 -0
  29. data/lib/yes/core/aggregate/dsl/command_shortcut_expander.rb +233 -0
  30. data/lib/yes/core/aggregate/dsl/constant_resolver.rb +67 -0
  31. data/lib/yes/core/aggregate/dsl/method_definers/attribute/accessor.rb +28 -0
  32. data/lib/yes/core/aggregate/dsl/method_definers/attribute/aggregate_accessor.rb +36 -0
  33. data/lib/yes/core/aggregate/dsl/method_definers/attribute/base.rb +42 -0
  34. data/lib/yes/core/aggregate/dsl/method_definers/command/base.rb +42 -0
  35. data/lib/yes/core/aggregate/dsl/method_definers/command/can_command.rb +41 -0
  36. data/lib/yes/core/aggregate/dsl/method_definers/command/command.rb +50 -0
  37. data/lib/yes/core/aggregate/has_authorizer.rb +86 -0
  38. data/lib/yes/core/aggregate/has_read_model.rb +169 -0
  39. data/lib/yes/core/aggregate/read_model_rebuilder.rb +40 -0
  40. data/lib/yes/core/aggregate/shared_read_model_rebuilder.rb +158 -0
  41. data/lib/yes/core/aggregate.rb +404 -0
  42. data/lib/yes/core/authentication_error.rb +8 -0
  43. data/lib/yes/core/authorization/cerbos_client_provider.rb +27 -0
  44. data/lib/yes/core/authorization/command_authorizer.rb +40 -0
  45. data/lib/yes/core/authorization/command_cerbos_authorizer.rb +182 -0
  46. data/lib/yes/core/authorization/read_model_authorizer.rb +22 -0
  47. data/lib/yes/core/authorization/read_models_authorizer.rb +49 -0
  48. data/lib/yes/core/authorization/read_request_authorizer.rb +32 -0
  49. data/lib/yes/core/authorization/read_request_cerbos_authorizer.rb +112 -0
  50. data/lib/yes/core/command.rb +35 -0
  51. data/lib/yes/core/command_handling/aggregate_tracker.rb +33 -0
  52. data/lib/yes/core/command_handling/command_executor.rb +171 -0
  53. data/lib/yes/core/command_handling/command_handler.rb +124 -0
  54. data/lib/yes/core/command_handling/event_publisher.rb +189 -0
  55. data/lib/yes/core/command_handling/guard_evaluator.rb +165 -0
  56. data/lib/yes/core/command_handling/guard_runner.rb +76 -0
  57. data/lib/yes/core/command_handling/payload_proxy.rb +159 -0
  58. data/lib/yes/core/command_handling/read_model_recovery_service.rb +264 -0
  59. data/lib/yes/core/command_handling/read_model_revision_guard.rb +198 -0
  60. data/lib/yes/core/command_handling/read_model_updater.rb +103 -0
  61. data/lib/yes/core/command_handling/state_updater.rb +113 -0
  62. data/lib/yes/core/commands/bus.rb +46 -0
  63. data/lib/yes/core/commands/group.rb +135 -0
  64. data/lib/yes/core/commands/group_response.rb +13 -0
  65. data/lib/yes/core/commands/helper.rb +126 -0
  66. data/lib/yes/core/commands/notifier.rb +65 -0
  67. data/lib/yes/core/commands/processor.rb +137 -0
  68. data/lib/yes/core/commands/response.rb +63 -0
  69. data/lib/yes/core/commands/stateless/group_handler.rb +186 -0
  70. data/lib/yes/core/commands/stateless/group_response.rb +15 -0
  71. data/lib/yes/core/commands/stateless/handler.rb +292 -0
  72. data/lib/yes/core/commands/stateless/handler_helpers.rb +321 -0
  73. data/lib/yes/core/commands/stateless/response.rb +14 -0
  74. data/lib/yes/core/commands/stateless/subject.rb +41 -0
  75. data/lib/yes/core/commands/validator.rb +28 -0
  76. data/lib/yes/core/configuration.rb +432 -0
  77. data/lib/yes/core/data_decryptor.rb +59 -0
  78. data/lib/yes/core/data_encryptor.rb +60 -0
  79. data/lib/yes/core/encryption_metadata.rb +33 -0
  80. data/lib/yes/core/error.rb +14 -0
  81. data/lib/yes/core/error_messages.rb +37 -0
  82. data/lib/yes/core/event.rb +222 -0
  83. data/lib/yes/core/event_class_resolver.rb +40 -0
  84. data/lib/yes/core/generators/read_models/add_pending_update_tracking_generator.rb +43 -0
  85. data/lib/yes/core/generators/read_models/templates/add_pending_update_tracking.rb.erb +122 -0
  86. data/lib/yes/core/generators/read_models/templates/migration.rb.erb +9 -0
  87. data/lib/yes/core/generators/read_models/update_generator.rb +147 -0
  88. data/lib/yes/core/jobs/read_model_recovery_job.rb +219 -0
  89. data/lib/yes/core/middlewares/encryptor.rb +48 -0
  90. data/lib/yes/core/middlewares/timestamp.rb +29 -0
  91. data/lib/yes/core/middlewares/with_indifferent_access.rb +22 -0
  92. data/lib/yes/core/models/application_record.rb +9 -0
  93. data/lib/yes/core/open_telemetry/otl_span.rb +110 -0
  94. data/lib/yes/core/open_telemetry/trackable.rb +101 -0
  95. data/lib/yes/core/payload_store/base.rb +33 -0
  96. data/lib/yes/core/payload_store/errors.rb +13 -0
  97. data/lib/yes/core/payload_store/lookup.rb +44 -0
  98. data/lib/yes/core/process_managers/access_token_client.rb +107 -0
  99. data/lib/yes/core/process_managers/base.rb +40 -0
  100. data/lib/yes/core/process_managers/command_runner.rb +109 -0
  101. data/lib/yes/core/process_managers/service_client.rb +57 -0
  102. data/lib/yes/core/process_managers/state.rb +118 -0
  103. data/lib/yes/core/railtie.rb +58 -0
  104. data/lib/yes/core/read_model/builder.rb +267 -0
  105. data/lib/yes/core/read_model/event_handler.rb +64 -0
  106. data/lib/yes/core/read_model/filter.rb +118 -0
  107. data/lib/yes/core/read_model/filter_query_builder.rb +104 -0
  108. data/lib/yes/core/serializer.rb +21 -0
  109. data/lib/yes/core/subscriptions.rb +94 -0
  110. data/lib/yes/core/test_support/event_helpers.rb +27 -0
  111. data/lib/yes/core/test_support/jwt_helpers.rb +30 -0
  112. data/lib/yes/core/test_support/subscriptions_helper.rb +88 -0
  113. data/lib/yes/core/test_support/test_helper.rb +27 -0
  114. data/lib/yes/core/test_support.rb +5 -0
  115. data/lib/yes/core/transaction_details.rb +90 -0
  116. data/lib/yes/core/type_lookup.rb +88 -0
  117. data/lib/yes/core/types.rb +110 -0
  118. data/lib/yes/core/utils/aggregate_shortcuts.rb +164 -0
  119. data/lib/yes/core/utils/caller_utils.rb +37 -0
  120. data/lib/yes/core/utils/command_utils.rb +226 -0
  121. data/lib/yes/core/utils/error_notifier.rb +101 -0
  122. data/lib/yes/core/utils/event_name_resolver.rb +67 -0
  123. data/lib/yes/core/utils/exponential_retrier.rb +180 -0
  124. data/lib/yes/core/utils/hash_utils.rb +63 -0
  125. data/lib/yes/core/version.rb +7 -0
  126. data/lib/yes/core.rb +85 -0
  127. data/lib/yes.rb +0 -0
  128. 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,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ class ApplicationRecord < ActiveRecord::Base
6
+ self.abstract_class = true
7
+ end
8
+ end
9
+ 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,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ module PayloadStore
6
+ # Error classes for payload store operations
7
+ module Errors
8
+ class MissingClient < Error; end
9
+ class ClientError < Error; end
10
+ end
11
+ end
12
+ end
13
+ 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