Module: BetterService::Concerns::Instrumentation

Extended by:
ActiveSupport::Concern
Included in:
Services::Base
Defined in:
lib/better_service/concerns/instrumentation.rb

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.prepended(base) ⇒ Object

Hook into prepend to wrap call method

This is called when the concern is prepended to a class.



34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
# File 'lib/better_service/concerns/instrumentation.rb', line 34

def self.prepended(base)
  # Always wrap call method
  base.class_eval do
      # Save original call method
      alias_method :call_without_instrumentation, :call

      # Define new call method with instrumentation
      define_method(:call) do
        return call_without_instrumentation unless instrumentation_enabled?

        service_name = self.class.name
        user_id = extract_user_id_from_instance

        # Publish service.started event
        payload = build_start_payload(service_name, user_id)
        ActiveSupport::Notifications.instrument("service.started", payload)

        # Execute the service
        start_time = Time.current

        begin
          result = call_without_instrumentation
          duration = ((Time.current - start_time) * 1000).round(2) # milliseconds

          # Validate that result is a BetterService::Result object
          unless result.is_a?(BetterService::Result)
            raise BetterService::Errors::Runtime::InvalidResultError.new(
              "Service #{service_name} must return BetterService::Result, got #{result.class}",
              context: { service: service_name, result_class: result.class.name }
            )
          end

          if result.failure?
            # Publish service.failed event for Result failures
            failure_payload = build_result_failure_payload(
              service_name, user_id, result, duration
            )
            ActiveSupport::Notifications.instrument("service.failed", failure_payload)
          else
            # Publish service.completed event
            completion_payload = build_completion_payload(
              service_name, user_id, result, duration
            )
            ActiveSupport::Notifications.instrument("service.completed", completion_payload)
          end

          result
        rescue => error
          duration = ((Time.current - start_time) * 1000).round(2)

          # Extract original error if wrapped in ExecutionError
          original_error = error.respond_to?(:original_error) && error.original_error ? error.original_error : error

          # Publish service.failed event
          failure_payload = build_failure_payload(
            service_name, user_id, original_error, duration
          )
          ActiveSupport::Notifications.instrument("service.failed", failure_payload)

          # Re-raise the error (don't swallow it)
          raise
        end
      end
    end
end

Instance Method Details

#build_completion_payload(service_name, user_id, result, duration) ⇒ Hash

Build payload for service.completed event

Parameters:

  • service_name (String)

    Name of service class

  • user_id (Integer, String, nil)

    User ID

  • result (BetterService::Result)

    Service result

  • duration (Float)

    Execution duration in milliseconds

Returns:

  • (Hash)

    Event payload



152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
# File 'lib/better_service/concerns/instrumentation.rb', line 152

def build_completion_payload(service_name, user_id, result, duration)
  payload = {
    service_name: service_name,
    user_id: user_id,
    duration: duration,
    timestamp: Time.current.iso8601,
    success: true
  }

  # Include params if configured and available
  if BetterService.configuration.instrumentation_include_args && respond_to?(:params, true)
    payload[:params] = send(:params)
  end

  # Include result if configured
  if BetterService.configuration.instrumentation_include_result
    payload[:result] = result
  end

  # Include cache metadata if available from Result meta
  if result.meta[:cache_hit]
    payload[:cache_hit] = result.meta[:cache_hit]
  end
  if result.meta[:cache_key]
    payload[:cache_key] = result.meta[:cache_key]
  end

  payload
end

#build_failure_payload(service_name, user_id, error, duration) ⇒ Hash

Build payload for service.failed event

Parameters:

  • service_name (String)

    Name of service class

  • user_id (Integer, String, nil)

    User ID

  • error (Exception)

    The error that was raised

  • duration (Float)

    Execution duration in milliseconds

Returns:

  • (Hash)

    Event payload



220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
# File 'lib/better_service/concerns/instrumentation.rb', line 220

def build_failure_payload(service_name, user_id, error, duration)
  payload = {
    service_name: service_name,
    user_id: user_id,
    duration: duration,
    timestamp: Time.current.iso8601,
    success: false,
    error_class: error.class.name,
    error_message: error.message
  }

  # Include params if configured and available
  if BetterService.configuration.instrumentation_include_args && respond_to?(:params, true)
    payload[:params] = send(:params)
  end

  # Include backtrace (first 5 lines) for debugging
  if error.backtrace
    payload[:error_backtrace] = error.backtrace.first(5)
  end

  payload
end

#build_result_failure_payload(service_name, user_id, result, duration) ⇒ Hash

Build payload for service.failed event from Result object

Parameters:

  • service_name (String)

    Name of service class

  • user_id (Integer, String, nil)

    User ID

  • result (BetterService::Result)

    Result object with failure

  • duration (Float)

    Execution duration in milliseconds

Returns:

  • (Hash)

    Event payload



189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
# File 'lib/better_service/concerns/instrumentation.rb', line 189

def build_result_failure_payload(service_name, user_id, result, duration)
  payload = {
    service_name: service_name,
    user_id: user_id,
    duration: duration,
    timestamp: Time.current.iso8601,
    success: false,
    error_class: result.meta[:error_code]&.to_s || "UnknownError",
    error_message: result.message || "Service failed"
  }

  # Include params if configured and available
  if BetterService.configuration.instrumentation_include_args && respond_to?(:params, true)
    payload[:params] = send(:params)
  end

  # Include validation errors if present
  if result.validation_errors
    payload[:validation_errors] = result.validation_errors
  end

  payload
end

#build_start_payload(service_name, user_id) ⇒ Hash

Build payload for service.started event

Parameters:

  • service_name (String)

    Name of service class

  • user_id (Integer, String, nil)

    User ID

Returns:

  • (Hash)

    Event payload



130
131
132
133
134
135
136
137
138
139
140
141
142
143
# File 'lib/better_service/concerns/instrumentation.rb', line 130

def build_start_payload(service_name, user_id)
  payload = {
    service_name: service_name,
    user_id: user_id,
    timestamp: Time.current.iso8601
  }

  # Include params if configured and available
  if BetterService.configuration.instrumentation_include_args && respond_to?(:params, true)
    payload[:params] = send(:params)
  end

  payload
end

#extract_user_id_from_instanceInteger, ...

Extract user ID from service instance

Returns:

  • (Integer, String, nil)


116
117
118
119
120
121
122
123
# File 'lib/better_service/concerns/instrumentation.rb', line 116

def extract_user_id_from_instance
  return nil unless respond_to?(:user, true)

  user = send(:user)
  return nil unless user

  user.respond_to?(:id) ? user.id : user
end

#instrumentation_enabled?Boolean

Check if instrumentation is enabled for this service

Returns:

  • (Boolean)


103
104
105
106
107
108
109
110
111
# File 'lib/better_service/concerns/instrumentation.rb', line 103

def instrumentation_enabled?
  return false unless BetterService.configuration.instrumentation_enabled

  excluded = BetterService.configuration.instrumentation_excluded_services
  full_name = self.class.name

  # Check exact match or if excluded name matches the end of full name
  !excluded.any? { |excluded_name| full_name == excluded_name || full_name.end_with?("::#{excluded_name}") }
end

#publish_cache_hit(cache_key, context = nil) ⇒ void

This method returns an undefined value.

Publish cache hit event

Called from Cacheable concern when cache lookup succeeds.

Parameters:

  • cache_key (String)

    The cache key that was hit

  • context (String) (defaults to: nil)

    Cache context (e.g., “products”)



251
252
253
254
255
256
257
258
259
260
261
262
263
# File 'lib/better_service/concerns/instrumentation.rb', line 251

def publish_cache_hit(cache_key, context = nil)
  return unless instrumentation_enabled?

  payload = {
    service_name: self.class.name,
    event_type: "cache_hit",
    cache_key: cache_key,
    context: context,
    timestamp: Time.current.iso8601
  }

  ActiveSupport::Notifications.instrument("cache.hit", payload)
end

#publish_cache_miss(cache_key, context = nil) ⇒ void

This method returns an undefined value.

Publish cache miss event

Called from Cacheable concern when cache lookup fails.

Parameters:

  • cache_key (String)

    The cache key that missed

  • context (String) (defaults to: nil)

    Cache context (e.g., “products”)



272
273
274
275
276
277
278
279
280
281
282
283
284
# File 'lib/better_service/concerns/instrumentation.rb', line 272

def publish_cache_miss(cache_key, context = nil)
  return unless instrumentation_enabled?

  payload = {
    service_name: self.class.name,
    event_type: "cache_miss",
    cache_key: cache_key,
    context: context,
    timestamp: Time.current.iso8601
  }

  ActiveSupport::Notifications.instrument("cache.miss", payload)
end