Skip to content

Commit c95df72

Browse files
committed
Add support for custom notifications without exceptions
Allow notify() and notify_sync() to accept String messages or Hash payloads in addition to Exception objects. This enables sending custom alerts, warnings, or informational notices without needing to raise an exception. Usage: Checkend.notify("Rate limit exceeded", error_class: "RateLimitError") Checkend.notify({ error_class: "Alert", message: "Something happened" })
1 parent 69acfcd commit c95df72

File tree

4 files changed

+436
-10
lines changed

4 files changed

+436
-10
lines changed

lib/checkend.rb

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -77,45 +77,64 @@ def flush(timeout: nil)
7777

7878
# ========== Primary API ==========
7979

80-
# Report an exception to Checkend
80+
# Report an error or custom notification to Checkend
8181
#
82-
# @param exception [Exception] the exception to report
82+
# Accepts an Exception, String message, or Hash with error details.
83+
#
84+
# @param exception_or_message [Exception, String, Hash] the error to report
85+
# - Exception: reports the exception with its class, message, and backtrace
86+
# - String: reports a custom notification with the string as the message
87+
# - Hash: reports a custom notification with :error_class, :message, :backtrace
88+
# @param error_class [String] custom error class (only used with String messages)
8389
# @param context [Hash] additional context data
8490
# @param request [Hash] request information
8591
# @param user [Hash] user information
8692
# @param fingerprint [String] custom fingerprint for grouping
8793
# @param tags [Array<String>] tags for filtering
8894
# @return [Hash, nil] the API response or nil if not sent
89-
def notify(exception, context: {}, request: nil, user: nil, fingerprint: nil, tags: [])
95+
#
96+
# @example Report an exception
97+
# Checkend.notify(exception)
98+
#
99+
# @example Report a custom message
100+
# Checkend.notify("User exceeded rate limit", error_class: "RateLimitExceeded")
101+
#
102+
# @example Report with a hash
103+
# Checkend.notify(error_class: "CustomAlert", message: "Something happened")
104+
#
105+
def notify(exception_or_message, error_class: nil, context: {}, request: nil, user: nil, fingerprint: nil, tags: [])
90106
return nil unless should_notify?
91-
return nil if configuration.ignore_exception?(exception)
92107

93-
notice = NoticeBuilder.build(
94-
exception: exception,
108+
notice = build_notice(
109+
exception_or_message,
110+
error_class: error_class,
95111
context: context,
96112
request: request,
97113
user: user,
98114
fingerprint: fingerprint,
99115
tags: tags
100116
)
117+
return nil unless notice
101118

102119
# Run before_notify callbacks
103120
return nil unless before_notify_callbacks_allow?(notice)
104121

105122
send_notice(notice)
106123
end
107124

108-
# Report an exception synchronously (blocking)
125+
# Report an error or custom notification synchronously (blocking)
109126
#
110127
# Useful for CLI tools, tests, or when you need confirmation of delivery.
111128
#
112-
# @param exception [Exception] the exception to report
129+
# @param exception_or_message [Exception, String, Hash] the error to report
113130
# @param options [Hash] same options as notify
114131
# @return [Hash, nil] the API response or nil if not sent
115-
def notify_sync(exception, **options)
132+
def notify_sync(exception_or_message, **options)
116133
return nil unless should_notify?
117134

118-
notice = NoticeBuilder.build(exception: exception, **options)
135+
notice = build_notice(exception_or_message, **options)
136+
return nil unless notice
137+
119138
client.send_notice(notice)
120139
end
121140

@@ -188,6 +207,24 @@ def should_notify?
188207
true
189208
end
190209

210+
def build_notice(exception_or_message, error_class: nil, context: {}, request: nil, user: nil, fingerprint: nil, tags: [])
211+
options = { context: context, request: request, user: user, fingerprint: fingerprint, tags: tags }
212+
213+
case exception_or_message
214+
when Exception
215+
return nil if configuration.ignore_exception?(exception_or_message)
216+
217+
NoticeBuilder.build(exception: exception_or_message, **options)
218+
when String
219+
NoticeBuilder.build_from_message(exception_or_message, error_class: error_class, **options)
220+
when Hash
221+
NoticeBuilder.build_from_hash(exception_or_message, **options)
222+
else
223+
log_debug("notify called with unsupported type: #{exception_or_message.class}")
224+
nil
225+
end
226+
end
227+
191228
def before_notify_callbacks_allow?(notice)
192229
configuration.before_notify.each do |callback|
193230
result = callback.call(notice)

lib/checkend/notice_builder.rb

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ class NoticeBuilder
99
MAX_BACKTRACE_LINES = 100
1010
MAX_MESSAGE_LENGTH = 10_000
1111

12+
# Default error class for custom notifications without an exception
13+
DEFAULT_ERROR_CLASS = 'Checkend::Notice'
14+
1215
class << self
1316
# Build a Notice from an exception
1417
#
@@ -40,6 +43,70 @@ def build(exception:, context: {}, request: nil, user: nil, fingerprint: nil, ta
4043
notice
4144
end
4245

46+
# Build a Notice from a message string (custom notification without exception)
47+
#
48+
# @param message [String] the notification message
49+
# @param error_class [String] custom error class name (defaults to 'Checkend::Notice')
50+
# @param context [Hash] additional context data
51+
# @param request [Hash] request information
52+
# @param user [Hash] user information
53+
# @param fingerprint [String] custom fingerprint for grouping
54+
# @param tags [Array<String>] tags for filtering
55+
# @return [Notice] the constructed notice
56+
def build_from_message(message, error_class: nil, context: {}, request: nil, user: nil, fingerprint: nil, tags: [])
57+
notice = Notice.new
58+
59+
# Error info - no backtrace for custom notifications
60+
notice.error_class = error_class || DEFAULT_ERROR_CLASS
61+
notice.message = truncate_message(message)
62+
notice.backtrace = []
63+
notice.fingerprint = fingerprint
64+
notice.tags = Array(tags)
65+
66+
# Merge thread-local context with provided context
67+
notice.context = merge_context(context)
68+
notice.user = user || thread_local_user
69+
notice.request = request || {}
70+
71+
# Environment
72+
notice.environment = Checkend.configuration.environment
73+
74+
notice
75+
end
76+
77+
# Build a Notice from a hash (custom notification with full control)
78+
#
79+
# @param hash [Hash] the notification data
80+
# @option hash [String] :error_class custom error class name
81+
# @option hash [String] :message the notification message
82+
# @option hash [Array<String>] :backtrace optional backtrace
83+
# @param context [Hash] additional context data
84+
# @param request [Hash] request information
85+
# @param user [Hash] user information
86+
# @param fingerprint [String] custom fingerprint for grouping
87+
# @param tags [Array<String>] tags for filtering
88+
# @return [Notice] the constructed notice
89+
def build_from_hash(hash, context: {}, request: nil, user: nil, fingerprint: nil, tags: [])
90+
notice = Notice.new
91+
92+
# Error info from hash
93+
notice.error_class = hash[:error_class] || hash['error_class'] || DEFAULT_ERROR_CLASS
94+
notice.message = truncate_message(hash[:message] || hash['message'])
95+
notice.backtrace = clean_backtrace(hash[:backtrace] || hash['backtrace'] || [])
96+
notice.fingerprint = fingerprint || hash[:fingerprint] || hash['fingerprint']
97+
notice.tags = Array(tags.empty? ? (hash[:tags] || hash['tags'] || []) : tags)
98+
99+
# Merge thread-local context with provided context
100+
notice.context = merge_context(context)
101+
notice.user = user || thread_local_user
102+
notice.request = request || {}
103+
104+
# Environment
105+
notice.environment = Checkend.configuration.environment
106+
107+
notice
108+
end
109+
43110
private
44111

45112
def clean_backtrace(backtrace)

test/checkend/notice_builder_test.rb

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,4 +162,174 @@ def test_handles_nil_backtrace
162162

163163
assert_empty notice.backtrace
164164
end
165+
166+
# ========== build_from_message tests ==========
167+
168+
def test_build_from_message_creates_notice
169+
notice = Checkend::NoticeBuilder.build_from_message('Something went wrong')
170+
171+
assert_equal 'Checkend::Notice', notice.error_class
172+
assert_equal 'Something went wrong', notice.message
173+
assert_empty notice.backtrace
174+
end
175+
176+
def test_build_from_message_with_custom_error_class
177+
notice = Checkend::NoticeBuilder.build_from_message(
178+
'Rate limit exceeded',
179+
error_class: 'RateLimitError'
180+
)
181+
182+
assert_equal 'RateLimitError', notice.error_class
183+
assert_equal 'Rate limit exceeded', notice.message
184+
end
185+
186+
def test_build_from_message_with_context
187+
notice = Checkend::NoticeBuilder.build_from_message(
188+
'Alert message',
189+
context: { severity: 'high', source: 'api' }
190+
)
191+
192+
assert_equal 'high', notice.context[:severity]
193+
assert_equal 'api', notice.context[:source]
194+
end
195+
196+
def test_build_from_message_with_user
197+
notice = Checkend::NoticeBuilder.build_from_message(
198+
'User alert',
199+
user: { id: 123, email: 'test@example.com' }
200+
)
201+
202+
assert_equal 123, notice.user[:id]
203+
assert_equal 'test@example.com', notice.user[:email]
204+
end
205+
206+
def test_build_from_message_with_tags
207+
notice = Checkend::NoticeBuilder.build_from_message(
208+
'Tagged alert',
209+
tags: %w[urgent security]
210+
)
211+
212+
assert_equal %w[urgent security], notice.tags
213+
end
214+
215+
def test_build_from_message_with_fingerprint
216+
notice = Checkend::NoticeBuilder.build_from_message(
217+
'Custom fingerprint',
218+
fingerprint: 'my-custom-fp'
219+
)
220+
221+
assert_equal 'my-custom-fp', notice.fingerprint
222+
end
223+
224+
def test_build_from_message_includes_environment
225+
notice = Checkend::NoticeBuilder.build_from_message('Test')
226+
227+
assert_equal 'test', notice.environment
228+
end
229+
230+
def test_build_from_message_truncates_long_messages
231+
long_message = 'a' * 15_000
232+
233+
notice = Checkend::NoticeBuilder.build_from_message(long_message)
234+
235+
assert_equal 10_000, notice.message.length
236+
assert notice.message.end_with?('...')
237+
end
238+
239+
def test_build_from_message_merges_thread_local_context
240+
Thread.current[:checkend_context] = { existing: 'value' }
241+
242+
notice = Checkend::NoticeBuilder.build_from_message('Test', context: { new_key: 'new' })
243+
244+
assert_equal 'value', notice.context[:existing]
245+
assert_equal 'new', notice.context[:new_key]
246+
ensure
247+
Thread.current[:checkend_context] = nil
248+
end
249+
250+
# ========== build_from_hash tests ==========
251+
252+
def test_build_from_hash_creates_notice
253+
notice = Checkend::NoticeBuilder.build_from_hash({
254+
error_class: 'CustomError',
255+
message: 'Something happened'
256+
})
257+
258+
assert_equal 'CustomError', notice.error_class
259+
assert_equal 'Something happened', notice.message
260+
end
261+
262+
def test_build_from_hash_with_string_keys
263+
notice = Checkend::NoticeBuilder.build_from_hash({
264+
'error_class' => 'StringKeyError',
265+
'message' => 'Using string keys'
266+
})
267+
268+
assert_equal 'StringKeyError', notice.error_class
269+
assert_equal 'Using string keys', notice.message
270+
end
271+
272+
def test_build_from_hash_defaults_error_class
273+
notice = Checkend::NoticeBuilder.build_from_hash({ message: 'Just a message' })
274+
275+
assert_equal 'Checkend::Notice', notice.error_class
276+
end
277+
278+
def test_build_from_hash_with_backtrace
279+
notice = Checkend::NoticeBuilder.build_from_hash({
280+
error_class: 'CustomError',
281+
message: 'With backtrace',
282+
backtrace: ['file.rb:10:in `method`', 'file.rb:20:in `caller`']
283+
})
284+
285+
assert_equal 2, notice.backtrace.length
286+
assert_includes notice.backtrace.first, 'file.rb:10'
287+
end
288+
289+
def test_build_from_hash_with_fingerprint_in_hash
290+
notice = Checkend::NoticeBuilder.build_from_hash({
291+
error_class: 'CustomError',
292+
message: 'Test',
293+
fingerprint: 'hash-fingerprint'
294+
})
295+
296+
assert_equal 'hash-fingerprint', notice.fingerprint
297+
end
298+
299+
def test_build_from_hash_option_fingerprint_overrides_hash
300+
notice = Checkend::NoticeBuilder.build_from_hash(
301+
{ error_class: 'CustomError', fingerprint: 'hash-fp' },
302+
fingerprint: 'option-fp'
303+
)
304+
305+
assert_equal 'option-fp', notice.fingerprint
306+
end
307+
308+
def test_build_from_hash_with_tags_in_hash
309+
notice = Checkend::NoticeBuilder.build_from_hash({
310+
error_class: 'CustomError',
311+
message: 'Test',
312+
tags: %w[from hash]
313+
})
314+
315+
assert_equal %w[from hash], notice.tags
316+
end
317+
318+
def test_build_from_hash_option_tags_overrides_hash
319+
notice = Checkend::NoticeBuilder.build_from_hash(
320+
{ error_class: 'CustomError', tags: %w[from hash] },
321+
tags: %w[from options]
322+
)
323+
324+
assert_equal %w[from options], notice.tags
325+
end
326+
327+
def test_build_from_hash_with_context
328+
notice = Checkend::NoticeBuilder.build_from_hash(
329+
{ error_class: 'CustomError' },
330+
context: { key: 'value' }
331+
)
332+
333+
assert_equal 'value', notice.context[:key]
334+
end
165335
end

0 commit comments

Comments
 (0)