Skip to content

fix(provider)!: RaygunMessage object in on_grouping_key method #118

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
May 21, 2025
4 changes: 2 additions & 2 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -353,9 +353,9 @@ Customer data can be passed in which will be displayed in the Raygun web app. Th
Custom grouping logic
---------------------

You can create custom exception grouping logic that overrides the automatic Raygun grouping by passing in a function that accepts one parameter using this function. The callback's one parameter is an instance of RaygunMessage (python[2/3]/raygunmsgs.py), and the callback should return a string.
You can create custom exception grouping logic that overrides the automatic Raygun grouping by passing in a function that accepts one parameter using this function. The callback's one parameter is an instance of `RaygunMessage` (`python3/raygunmsgs.py`), and the callback should return a string.

The RaygunMessage instance contains all the error and state data that is about to be sent to the Raygun API. In your callback you can inspect this RaygunMessage, hash together the fields you want to group by, then return a string which is the grouping key.
The `RaygunMessage` instance contains all the error and state data that is about to be sent to the Raygun API. In your callback you can inspect this `RaygunMessage`, hash together the fields you want to group by, then return a string which is the grouping key.

This string needs to be between 1 and 100 characters long. If the callback is not set or the string isn't valid, the default automatic grouping will be used.

Expand Down
19 changes: 17 additions & 2 deletions python3/raygun4py/raygunmsgs.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
USE_MULTIPROCESSING = False

import platform
from datetime import datetime
from datetime import datetime, timezone

from raygun4py import http_utilities

Expand Down Expand Up @@ -129,15 +129,30 @@ def set_user(self, user):
class RaygunMessage(object):

def __init__(self):
self.occurredOn = datetime.utcnow()
self.occurredOn = datetime.now(timezone.utc)
self.details = {}

def __copy__(self):
new_instance = RaygunMessage()
new_instance.details = self.details.copy()
new_instance.occurredOn = self.occurredOn
return new_instance

def copy(self):
return self.__copy__()

Comment on lines +135 to +143
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Necessary to avoid modifying the original object in pure functions

def get_error(self):
return self.details.get("error")

def get_details(self):
return self.details

def set_details(self, details):
self.details = details

def set_error(self, error):
self.details["error"] = error


class RaygunErrorMessage(object):
log = logging.getLogger(__name__)
Expand Down
20 changes: 11 additions & 9 deletions python3/raygun4py/raygunprovider.py
Original file line number Diff line number Diff line change
Expand Up @@ -295,42 +295,44 @@ def _create_message(
)

def _transform_message(self, message):
message = message.copy()
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_transform_message should be pure and not modify the original message object

message = utilities.ignore_exceptions(self.ignored_exceptions, message)

if message is not None:
message = utilities.filter_keys(self.filtered_keys, message)
message["details"]["groupingKey"] = utilities.execute_grouping_key(
details = message.get_details()
details = utilities.filter_keys(self.filtered_keys, details)
Comment on lines +302 to +303
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pass the details to the filter_keys function instead of passing the whole message object

details["groupingKey"] = utilities.execute_grouping_key(
self.grouping_key_callback, message
)
message.set_details(details)

if self.before_send_callback is not None:
mutated_payload = self.before_send_callback(message["details"])
mutated_payload = self.before_send_callback(message.get_details())

if mutated_payload is not None:
message["details"] = mutated_payload
message.set_details(mutated_payload)
else:
return None

return message

def _post(self, raygunMessage):
raygunMessage = raygunMessage.copy()
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_post should be a pure function and not modify the original raygunMessage

options = {
"enforce_payload_size_limit": self.enforce_payload_size_limit,
"log_payload_size_limit_breaches": self.log_payload_size_limit_breaches,
}

if (
isinstance(raygunMessage["details"]["error"], raygunmsgs.RaygunErrorMessage)
isinstance(raygunMessage.get_error(), raygunmsgs.RaygunErrorMessage)
and "enforce_payload_size_limit" in options
and options["enforce_payload_size_limit"] is True
):
error = jsonpickle.loads(
jsonpickle.dumps(raygunMessage["details"]["error"])
)
error = jsonpickle.loads(jsonpickle.dumps(raygunMessage.get_error()))

error.check_and_modify_payload_size(options)

raygunMessage["details"]["error"] = error
raygunMessage.set_error(error)

json = jsonpickle.encode(raygunMessage, unpicklable=False)

Expand Down
17 changes: 11 additions & 6 deletions python3/raygun4py/utilities.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import re

from raygun4py import raygunmsgs


def ignore_exceptions(ignored_exceptions, message):
classname = message.get_error().get_classname()
Expand All @@ -11,11 +9,18 @@ def ignore_exceptions(ignored_exceptions, message):
return message


def filter_keys(filtered_keys, object):
iteration_target = object
def filter_keys(filtered_keys, obj):
"""
Filter keys from a dictionary.

Parameters:
filtered_keys (list): A list of keys to filter.
obj (dict): The dictionary to filter.

if isinstance(object, raygunmsgs.RaygunMessage):
iteration_target = object.__dict__
Comment on lines -17 to -18
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The filter_keys function shouldn't be used with RaygunMessage directly, instead it should be applied to the details

Returns:
dict: The filtered dictionary.
"""
iteration_target = dict(obj)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

make a copy to avoid modifying the original object


for key in iter(iteration_target.keys()):
if isinstance(iteration_target[key], dict):
Expand Down
22 changes: 22 additions & 0 deletions python3/tests/test_raygunmsgs.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,28 @@ def test_environment_variables_are_ignored(self):
self.builder.raygunMessage.details["environment"]["environmentVariables"]
)

def test_get_error(self):
self.builder.set_exception_details(
raygunmsgs.RaygunErrorMessage(Exception, None, None, {})
)
self.assertIsNotNone(self.builder.raygunMessage.get_error())

def test_get_details(self):
self.builder.set_exception_details(
raygunmsgs.RaygunErrorMessage(Exception, None, None, {})
)
self.assertIsNotNone(self.builder.raygunMessage.get_details())

def test_set_error(self):
message = raygunmsgs.RaygunMessage()
message.set_error("Error")
self.assertEqual(message.get_error(), "Error")

def test_set_details(self):
message = raygunmsgs.RaygunMessage()
message.set_details({"foo": "bar"})
self.assertEqual(message.get_details(), {"foo": "bar"})


class TestRaygunErrorMessage(unittest.TestCase):
ONEHUNDRED_AND_FIFTY_KB = 150 * 1024
Expand Down
23 changes: 16 additions & 7 deletions python3/tests/test_raygunprovider.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,9 @@ class TestGroupingKey(unittest.TestCase):
def the_callback(self, raygun_message):
return self.key

def the_callback_with_error(self, raygun_message):
return raygun_message.get_error().message[:100]

def create_dummy_message(self):
self.sender = raygunprovider.RaygunSender("apikey")

Expand All @@ -134,43 +137,49 @@ def create_dummy_message(self):
msg.set_exception_details(errorMessage)
return msg.build()

def test_message_with_error(self):
msg = self.create_dummy_message()
self.sender.on_grouping_key(self.the_callback_with_error)
msg = self.sender._transform_message(msg)
self.assertEqual(msg.get_details()["groupingKey"], "Exception: None")

def test_groupingkey_is_not_none_with_callback(self):
msg = self.create_dummy_message()
self.sender.on_grouping_key(self.the_callback)
self.key = "foo"
self.sender._transform_message(msg)
msg = self.sender._transform_message(msg)

self.assertIsNotNone(msg.get_details()["groupingKey"])

def test_groupingkey_is_set_with_callback(self):
msg = self.create_dummy_message()
self.sender.on_grouping_key(self.the_callback)
self.key = "foo"
self.sender._transform_message(msg)
msg = self.sender._transform_message(msg)

self.assertEqual(msg.get_details()["groupingKey"], "foo")

def test_groupingkey_is_string_with_callback(self):
msg = self.create_dummy_message()
self.sender.on_grouping_key(self.the_callback)
self.key = "foo"
self.sender._transform_message(msg)
msg = self.sender._transform_message(msg)

self.assertIsInstance(msg.get_details()["groupingKey"], str)

def test_groupingkey_is_none_when_not_string_returned_from_callback(self):
msg = self.create_dummy_message()
self.sender.on_grouping_key(self.the_callback)
self.key = object
self.sender._transform_message(msg)
msg = self.sender._transform_message(msg)

self.assertIsNone(msg.get_details()["groupingKey"])

def test_groupingkey_is_none_when_empty_string_returned_from_callback(self):
msg = self.create_dummy_message()
self.sender.on_grouping_key(self.the_callback)
self.key = ""
self.sender._transform_message(msg)
msg = self.sender._transform_message(msg)

self.assertIsNone(msg.get_details()["groupingKey"])

Expand All @@ -182,7 +191,7 @@ def test_groupingkey_is_set_when_ok_length_string_returned_from_callback(self):
for i in range(0, 99):
self.key += "a"

self.sender._transform_message(msg)
msg = self.sender._transform_message(msg)
self.assertEqual(msg.get_details()["groupingKey"], self.key)

def test_groupingkey_is_none_when_too_long_string_returned_from_callback(self):
Expand All @@ -193,7 +202,7 @@ def test_groupingkey_is_none_when_too_long_string_returned_from_callback(self):
for i in range(0, 100):
self.key += "a"

self.sender._transform_message(msg)
msg = self.sender._transform_message(msg)
self.assertIsNone(msg.get_details()["groupingKey"])


Expand Down
6 changes: 3 additions & 3 deletions python3/tests/test_utilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,22 @@ class TestRaygunUtilities(unittest.TestCase):
def test_filter_keys(self):
test_obj = {"foo": "bar", "baz": "qux"}

utilities.filter_keys(["foo"], test_obj)
test_obj = utilities.filter_keys(["foo"], test_obj)

self.assertEqual(test_obj["foo"], "<filtered>")

def test_filter_keys_recursive(self):
test_obj = {"foo": "bar", "baz": "qux", "boo": {"foo": "qux"}}

utilities.filter_keys(["foo"], test_obj)
test_obj = utilities.filter_keys(["foo"], test_obj)

self.assertEqual(test_obj["foo"], "<filtered>")
self.assertEqual(test_obj["boo"]["foo"], "<filtered>")

def test_filter_keys_with_wildcard(self):
test_obj = {"foobr": "bar", "foobz": "baz", "fooqx": "foo", "baz": "qux"}

utilities.filter_keys(["foo*"], test_obj)
test_obj = utilities.filter_keys(["foo*"], test_obj)

self.assertEqual(test_obj["foobr"], "<filtered>")
self.assertEqual(test_obj["foobz"], "<filtered>")
Expand Down