├── MANIFEST.in ├── .gitignore ├── tox.ini ├── CHANGELOG ├── README.rst ├── LICENSE ├── tests └── test_string_to_chunks.py ├── setup.py └── raven_sh.py /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | *.pyc 3 | .*.swp 4 | *~ 5 | /.tox 6 | /.cache 7 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27, py37 3 | 4 | [testenv] 5 | deps = pytest 6 | commands = py.test tests/ [] 7 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | ## [0.5] - 2019-04-19 2 | 3 | - Migrate away from deprecated raven to sentry-sdk 4 | - Define the license explicitly 5 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | This is a wrapper executing a command and sending its stdout/stderr to the 2 | Sentry server. 3 | 4 | Useful to work with cron jobs. Unless misconfigured, the wrapper itself is 5 | quiet. It launches the program, captures its output, and if the program has 6 | been ended with non-zero exit code, builds a message and puts it to the remote 7 | server. 8 | 9 | .. warning:: Don't try to launch scripts producing a lot of data to 10 | stdout / stderr with this wrapper, as it stores everything in 11 | memory and thus can easily make your system swap. 12 | 13 | Example of cron task:: 14 | 15 | SENTRY_DSN='http://...../' 16 | */30 * * * * raven-sh -- bash -c 'echo hello world; exit 1' 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2013-2019 Doist 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/test_string_to_chunks.py: -------------------------------------------------------------------------------- 1 | from raven_sh import string_to_chunks 2 | 3 | 4 | def test_string_to_chunks_empty(): 5 | assert string_to_chunks('foo', None) == {} 6 | assert string_to_chunks('foo', '') == {} 7 | 8 | 9 | def test_string_to_chunks_short(): 10 | assert string_to_chunks('foo', 'value', max_chars=6) == {'foo': 'value'} 11 | 12 | 13 | def test_string_to_chunks_long_line(): 14 | src = b'value\n' + b'a' * 100 15 | expected = { 16 | 'foo0': 'value', 17 | 'foo1': 'a' * 100, 18 | } 19 | assert string_to_chunks('foo', src, max_chars=12) == expected 20 | 21 | 22 | def test_string_to_chunks_many_lines(): 23 | src = b'value\n' * 22 24 | expected = { 25 | 'foo00': 'value\nvalue', 26 | 'foo01': 'value\nvalue', 27 | 'foo02': 'value\nvalue', 28 | 'foo03': 'value\nvalue', 29 | 'foo04': 'value\nvalue', 30 | 'foo05': 'value\nvalue', 31 | 'foo06': 'value\nvalue', 32 | 'foo07': 'value\nvalue', 33 | 'foo08': 'value\nvalue', 34 | 'foo09': 'value\nvalue', 35 | 'foo10': 'value\nvalue', 36 | } 37 | assert string_to_chunks('foo', src, max_chars=12) == expected 38 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | from setuptools import setup, find_packages 4 | 5 | def read(fname): 6 | try: 7 | return open(os.path.join(os.path.dirname(__file__), fname)).read() 8 | except: 9 | return '' 10 | 11 | setup( 12 | name='raven-sh', 13 | version='0.5.1', 14 | author='Doist Developers', 15 | author_email='dev@doist.com', 16 | url='https://github.com/doist/raven-sh', 17 | description='raven-sh is a client for Sentry which can be used as ' 18 | 'a wrapper for cron jobs', 19 | long_description=read('README.rst'), 20 | py_modules=['raven_sh'], 21 | install_requires=[ 22 | 'sentry-sdk>=0.7.10', 23 | ], 24 | entry_points={ 25 | 'console_scripts': [ 26 | 'raven-sh = raven_sh:main', 27 | ], 28 | }, 29 | license='MIT', 30 | classifiers=[ 31 | 'License :: OSI Approved :: MIT License', 32 | 'Intended Audience :: Developers', 33 | 'Intended Audience :: System Administrators', 34 | 'Topic :: Software Development', 35 | 'Programming Language :: Python', 36 | 'Programming Language :: Python :: 2', 37 | 'Programming Language :: Python :: 2.7', 38 | 'Programming Language :: Python :: 3', 39 | 'Programming Language :: Python :: 3.7', 40 | ], 41 | ) 42 | -------------------------------------------------------------------------------- /raven_sh.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | 4 | This is a wrapper executing a command and sending its stdout/stderr to the 5 | Sentry server. 6 | 7 | Useful to work with cron jobs. Unless misconfigured, the wrapper itself is 8 | quiet. It launches the program, captures its output, and if the program has 9 | been ended with non-zero exit code, builds a message and puts it to the remote 10 | server. 11 | 12 | .. warning:: Don't try to launch scripts producing a lot of data to 13 | stdout / stderr with this wrapper, as it stores everything in 14 | memory and thus can easily make your system swap. 15 | 16 | Example of cron task:: 17 | 18 | SENTRY_DSN='http://...../' 19 | */30 * * * * raven-sh -- bash -c 'echo hello world; exit 1' 20 | 21 | """ 22 | from __future__ import absolute_import 23 | from __future__ import print_function 24 | 25 | import warnings 26 | 27 | import json 28 | import os 29 | import sys 30 | import optparse 31 | import logging 32 | from pprint import pprint 33 | import sentry_sdk 34 | import subprocess as subp 35 | 36 | if sys.version_info[0] == 2: 37 | _raw, _text = str, unicode 38 | iteritems = lambda x: x.iteritems() 39 | else: 40 | _raw, _text = bytes, str 41 | iteritems = lambda x: x.items() 42 | 43 | 44 | def store_json(option, opt_str, value, parser): 45 | try: 46 | value = json.loads(value) 47 | except ValueError: 48 | print("Invalid JSON was used for option %s. Received: %s" % (opt_str, value)) 49 | sys.exit(1) 50 | setattr(parser.values, option.dest, value) 51 | 52 | 53 | class Runner(object): 54 | 55 | def __init__(self): 56 | parser = self.get_parser() 57 | self.opts, self.args = parser.parse_args() 58 | self.setup_sentry() 59 | if not self.args: 60 | raise SystemExit('Command to execute is not defined. Exit') 61 | self.setup_logging() 62 | 63 | def setup_sentry(self): 64 | dsn = self.opts.dsn or os.getenv('SENTRY_DSN') 65 | environment = self.opts.environment or os.getenv('SENTRY_ENVIRONMENT', 'production') 66 | error_msg = 'Neither --dsn option or SENTRY_DSN env variable defined' 67 | 68 | if not dsn and self.opts.debug: 69 | warnings.warn(error_msg) 70 | dsn = 'https://x:x@localhost/1' 71 | 72 | if not dsn: 73 | raise SystemExit(error_msg) 74 | 75 | sentry_sdk.init(dsn, environment=environment) 76 | 77 | def setup_logging(self): 78 | logging.basicConfig(format='%(message)s') 79 | 80 | def get_parser(self): 81 | parser = optparse.OptionParser(usage=__doc__) 82 | parser.add_option('--dsn', dest='dsn', 83 | help='Sentry DSN. Alternatively setup SENTRY_DSN ' 84 | 'environment variable') 85 | 86 | parser.add_option("--extra", action="callback", callback=store_json, 87 | type="string", nargs=1, dest="extra", 88 | help='extra data to save (as json string)') 89 | 90 | parser.add_option("--tags", action="callback", callback=store_json, 91 | type="string", nargs=1, dest="tags", 92 | help='tags to save with message (as json string)') 93 | 94 | parser.add_option('--debug', action='store_true', 95 | help='Don\'t send anything to remote server. ' 96 | 'Just print stdout and stderr') 97 | 98 | parser.add_option('--message', help='Message string to send to sentry ' 99 | '(optional)') 100 | parser.add_option('--environment', dest='environment', 101 | help='Deploy environment. Alternatively setup ' 102 | 'SENTRY_ENVIRONMENT environment variable') 103 | 104 | return parser 105 | 106 | def run(self): 107 | pipe = subp.Popen(self.args, stdout=subp.PIPE, stderr=subp.PIPE) 108 | out, err = pipe.communicate() 109 | self.log(out, err, pipe.returncode) 110 | 111 | def log(self, out, err, returncode): 112 | if returncode == 0: 113 | return 114 | 115 | tags = self.opts.tags or {} 116 | tags.update({ 117 | 'returncode': returncode, 118 | 'callable': self.args[0] 119 | }) 120 | extra = self.opts.extra or {} 121 | extra.update({ 122 | 'returncode': returncode, 123 | 'command': self.get_command(), 124 | }) 125 | extra.update(string_to_chunks('stdout', out.rstrip())) 126 | extra.update(string_to_chunks('stderr', err.rstrip())) 127 | 128 | capture_message_kwargs = dict( 129 | message=self.get_raven_message(returncode), 130 | level=logging.ERROR, 131 | tags=tags, 132 | extra=extra, 133 | ) 134 | 135 | if self.opts.debug: 136 | pprint(capture_message_kwargs) 137 | else: 138 | with sentry_sdk.push_scope() as scope: 139 | for k, v in iteritems(extra): 140 | scope.set_extra(k, v) 141 | for k, v in iteritems(tags): 142 | scope.set_tag(k, v) 143 | sentry_sdk.capture_message(capture_message_kwargs["message"], 144 | level=capture_message_kwargs["level"]) 145 | 146 | def get_raven_message(self, returncode): 147 | if self.opts.message: 148 | return self.opts.message 149 | return '"%s" failed with code %d' % (self.get_command(), returncode) 150 | 151 | def get_command(self): 152 | return ' '.join(self.args) 153 | 154 | 155 | def string_to_chunks(name, string, max_chars=400): 156 | """ 157 | Sentry has message size limits: no more than 400 chars in a string, and no more than 50 elements in a list. 158 | 159 | Since stdout and stderr is the only important piece of data we have, we do our best to collect as much as possible 160 | """ 161 | chunks = [] 162 | chunk_items = [] 163 | chunk_chars = 0 164 | 165 | for line in (string or '').splitlines(): 166 | 167 | if chunk_chars + len(line) + 1 <= max_chars: # +1 is "\n" to join 168 | # keep adding values to current chunk 169 | chunk_items.append(line) 170 | chunk_chars += len(line) + 1 171 | else: 172 | # close current chunk and create a new one 173 | if chunk_items: 174 | chunks.append('\n'.join(map(ensure_text, chunk_items))) 175 | chunk_items = [line] 176 | chunk_chars = len(line) + 1 177 | 178 | # final action: close current chunk 179 | if chunk_items: 180 | chunks.append('\n'.join(map(ensure_text, chunk_items))) 181 | 182 | # format output 183 | if not chunks: 184 | return {} 185 | 186 | if len(chunks) == 1: 187 | return {name: chunks[0]} 188 | 189 | ret = {} 190 | positions = len(str(len(chunks))) # e.g. returns 3 for 111 chunks 191 | template = '%s%%0%dd' % (name, positions) # something like 'stdout%03d' 192 | for i, chunk in enumerate(chunks): 193 | ret[template % i] = chunk 194 | return ret 195 | 196 | 197 | def ensure_text(obj): 198 | """ 199 | Helper function to convert utf8-encoded raw bytes to text. Converts 200 | unknown characters to Unicode question mark 201 | """ 202 | if isinstance(obj, _raw): 203 | return obj.decode('utf8', 'replace') 204 | elif isinstance(obj, _text): 205 | return obj 206 | return _text(obj) 207 | 208 | 209 | def main(): 210 | runner = Runner() 211 | runner.run() 212 | 213 | if __name__ == '__main__': 214 | main() 215 | --------------------------------------------------------------------------------