From 3daa9fb26353db7ec44ab02fb20dac29c50eb3aa Mon Sep 17 00:00:00 2001 From: Hossam Hammady Date: Mon, 28 Oct 2024 05:07:01 -0400 Subject: [PATCH] Report user_id to New Relic for error tracking (Python 2.7) (#28) * Report user_id to New Relic for error tracking * Log user id reporting to NR --- README.md | 118 ++++++++++++++++++++++++++++++++++------- pyworker/reporter.py | 8 +++ tests/test_reporter.py | 56 ++++++++++++++++++- 3 files changed, 161 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 9124a0f..f25ea57 100644 --- a/README.md +++ b/README.md @@ -21,12 +21,16 @@ should subclass to implement your pure python jobs. It basically replicates the [delayed_job](https://github.com/collectiveidea/delayed_job) Ruby gem behavior. ## Installation -### From Pypi: + +### From PyPI + pip install rubydj-pyworker==0.2.0 -You can also pick up any version from [Pypi](https://pypi.org/project/rubydj-pyworker/) +You can also pick up any version from [PyPI](https://pypi.org/project/rubydj-pyworker/) that supports Python 2.7 (< 1.0.0). -### From Github branch: + +### From Github branch + pip install git+https://github.com/rayyansys/pyworker.git@#egg=rubydj-pyworker ## Usage @@ -97,14 +101,95 @@ configuration options: Youc an also provide a logger class (from `logging` module) to have full control on logging configuration: - import logging +```python +import logging - logging.basicConfig() - logger = logging.getLogger('pyworker') - logger.setLevel(logging.INFO) +logging.basicConfig() +logger = logging.getLogger('pyworker') +logger.setLevel(logging.INFO) - w = Worker(dbstring, logger) - w.run() +w = Worker(dbstring, logger) +w.run() +``` + +## Monitoring + +Workers can be monitored using [New Relic](https://newrelic.com/). All you need +to do is to create a free account there, then add the following to your +environment variables: + +```bash +NEW_RELIC_LICENSE_KEY= +NEW_RELIC_APP_NAME= +``` + +All jobs will be reported under the `BackgroundTask` category. This includes +standard metrics like throughput, response time (job duration), error rate, etc. +Additional transaction custom attributes are also reported out of the box: + +1. `jobName`: the name of the job class +1. `jobId`: the id of the delayed job in the database +1. `jobQueue`: the queue name of the job +1. `jobAttempts`: the number of attempts for the job +1. `jobLatency`: the time between the job creation and the time it was picked up by the worker + +If you wish to automatically report additional attributes from the delayed job table +(e.g. the priority of the job or any other custom fields), you can do so by +providing a list of fields to the worker: + +```python +worker = Worker(dbstring, + logger=logger, + extra_delayed_job_fields=['priority', 'custom_field']) +``` + +Columns of types `string`/`text`, `int`, `float` and `bool` are reported as is. +`json`/`jsonb` types are expanded into separate attributes. All other fields are converted +to JSON strings. + +You can also automatically prefix all attributes with a string before reporting: + +```python +worker = Worker(dbstring, + logger=logger, + reported_attributes_prefix='myapp.') +``` + +If you wish to report additional custom attributes from your job, you can do so +by calling the `reporter` object that is available in the job instance: + +```python +class MyJob(Job): + def __init__(self, *args, **kwargs): + super(MyJob, self).__init__(*args, **kwargs) + + def run(self): + ... + self.reporter.report( + my_custom_attribute='my_custom_value', + another_custom_attribute='another_custom_value', + ...) + ... +``` + +The prefix will be applied to all attributes reported from the job as well +as the camel case conversion. If you wish to skip both, you can use the +`report_raw` function instead of `report`. + +In all cases, two additional attributes are reported: + +1. `error`: a Boolean indicating whether the job has failed or not +1. `jobFailure`: a Boolean indicating whether the job has permanently failed or not + +The `error` attribute is reported as is, i.e. without prefix or camel case conversion. + +Additionally, the end user id is reported if the custom attributes contain the id +in any of the below formats: + +1. `user_id` (with and without prefix) +1. `userId` (with and without prefix) + +This is useful in identifying impacted users count in case of job errors. ## Limitations @@ -133,21 +218,14 @@ Do your changes, then send a pull request. ## Publish ### Using Python + 1. Increment the version number in `setup.py` 1. Install twine: `pip install twine`. You may need to upgrade pip first: `pip install --upgrade pip` 1. Create the distribution files: `python setup.py sdist bdist_wheel` -1. Optionally upload to [Test PyPi](https://test.pypi.org/) as a dry run: `twine upload -r testpypi dist/*`. You will need a separate account there -1. Upload to [PyPi](https://pypi.org/): `twine upload dist/*` - -### Using Docker -Increment the version as in the first step above then: - -```bash -docker build . -t pyworker:0.1.0 -docker run -it --rm pyworker:0.1.0 -``` +1. Optionally upload to [Test PyPI](https://test.pypi.org/) as a dry run: `twine upload -r testpypi dist/*`. You will need a separate account there +1. Upload to [PyPI](https://pypi.org/): `twine upload dist/*` -Enter your PyPi username and password when prompted. +Enter your PyPI username and password when prompted. ## License diff --git a/pyworker/reporter.py b/pyworker/reporter.py index 4853604..d470ddc 100644 --- a/pyworker/reporter.py +++ b/pyworker/reporter.py @@ -72,6 +72,14 @@ def _convert_value(value): def _report_newrelic(self, attributes): if self._logger: self._logger.debug('Reporter: reporting to NewRelic: %s' % attributes) + # report user id if available in attributes in any form + possible_keys = [self._prefix + 'userId', self._prefix + 'user_id', 'userId', 'user_id'] + possible_values = [attributes.get(key) for key in possible_keys if key in attributes] + if possible_values: + user_id = str(possible_values[0]) + newrelic.agent.set_user_id(user_id) + if self._logger: + self._logger.debug('Reporter: reporting to NewRelic user_id: %s' % user_id) # convert attributes dict to list of tuples attributes = list(attributes.items()) newrelic.agent.add_custom_attributes(attributes) diff --git a/tests/test_reporter.py b/tests/test_reporter.py index e6fa258..fbb200f 100644 --- a/tests/test_reporter.py +++ b/tests/test_reporter.py @@ -188,7 +188,7 @@ def test_reporter_convert_value_converts_value_to_json_if_not_supported(self): #********** ._report_newrelic tests **********# @patch('pyworker.reporter.newrelic.agent') - def test_reporter_report_newrelic_calls_newrelic_record_exception(self, newrelic_agent): + def test_reporter_report_newrelic_calls_newrelic_add_custom_attributes(self, newrelic_agent): reporter = Reporter() reporter._report_newrelic({ 'test_key1': 'test_value', @@ -199,3 +199,57 @@ def test_reporter_report_newrelic_calls_newrelic_record_exception(self, newrelic ('test_key1', 'test_value'), ('test_key2', 2) ]) + + @patch('pyworker.reporter.newrelic.agent') + def test_reporter_report_newrelic_does_not_call_newrelic_set_user_id_if_user_id_not_in_attributes(self, newrelic_agent): + reporter = Reporter() + reporter._report_newrelic({ + 'test_key1': 'test_value', + 'test_key2': 2 + }) + + newrelic_agent.set_user_id.assert_not_called() + + @patch('pyworker.reporter.newrelic.agent') + def test_reporter_report_newrelic_calls_newrelic_set_user_id_if_user_id_in_attributes(self, newrelic_agent): + reporter = Reporter() + reporter._report_newrelic({ + 'test_key1': 'test_value', + 'test_key2': 2, + 'user_id': 123 + }) + + newrelic_agent.set_user_id.assert_called_once_with('123') + + @patch('pyworker.reporter.newrelic.agent') + def test_reporter_report_newrelic_calls_newrelic_set_user_id_if_userId_in_attributes(self, newrelic_agent): + reporter = Reporter() + reporter._report_newrelic({ + 'test_key1': 'test_value', + 'test_key2': 2, + 'userId': 123 + }) + + newrelic_agent.set_user_id.assert_called_once_with('123') + + @patch('pyworker.reporter.newrelic.agent') + def test_reporter_report_newrelic_calls_newrelic_set_user_id_if_prefixed_user_id_in_attributes(self, newrelic_agent): + reporter = Reporter(attribute_prefix='test_') + reporter._report_newrelic({ + 'test_key1': 'test_value', + 'test_key2': 2, + 'test_user_id': 123 + }) + + newrelic_agent.set_user_id.assert_called_once_with('123') + + @patch('pyworker.reporter.newrelic.agent') + def test_reporter_report_newrelic_calls_newrelic_set_user_id_if_prefixed_userId_in_attributes(self, newrelic_agent): + reporter = Reporter(attribute_prefix='test_') + reporter._report_newrelic({ + 'test_key1': 'test_value', + 'test_key2': 2, + 'test_userId': 123 + }) + + newrelic_agent.set_user_id.assert_called_once_with('123')