Skip to content

Commit 762557a

Browse files
authored
Add Huey Integration (#1555)
* Minimal Huey integration
1 parent 88880be commit 762557a

File tree

8 files changed

+383
-1
lines changed

8 files changed

+383
-1
lines changed
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
name: Test huey
2+
3+
on:
4+
push:
5+
branches:
6+
- master
7+
- release/**
8+
9+
pull_request:
10+
11+
# Cancel in progress workflows on pull_requests.
12+
# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value
13+
concurrency:
14+
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
15+
cancel-in-progress: true
16+
17+
permissions:
18+
contents: read
19+
20+
env:
21+
BUILD_CACHE_KEY: ${{ github.sha }}
22+
CACHED_BUILD_PATHS: |
23+
${{ github.workspace }}/dist-serverless
24+
25+
jobs:
26+
test:
27+
name: huey, python ${{ matrix.python-version }}, ${{ matrix.os }}
28+
runs-on: ${{ matrix.os }}
29+
timeout-minutes: 45
30+
31+
strategy:
32+
fail-fast: false
33+
matrix:
34+
python-version: ["2.7","3.5","3.6","3.7","3.8","3.9","3.10","3.11"]
35+
# python3.6 reached EOL and is no longer being supported on
36+
# new versions of hosted runners on Github Actions
37+
# ubuntu-20.04 is the last version that supported python3.6
38+
# see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877
39+
os: [ubuntu-20.04]
40+
41+
steps:
42+
- uses: actions/checkout@v3
43+
- uses: actions/setup-python@v4
44+
with:
45+
python-version: ${{ matrix.python-version }}
46+
47+
- name: Setup Test Env
48+
run: |
49+
pip install codecov "tox>=3,<4"
50+
51+
- name: Test huey
52+
timeout-minutes: 45
53+
shell: bash
54+
run: |
55+
set -x # print commands that are executed
56+
coverage erase
57+
58+
./scripts/runtox.sh "${{ matrix.python-version }}-huey" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch
59+
coverage combine .coverage*
60+
coverage xml -i
61+
codecov --file coverage.xml
62+
63+
check_required_tests:
64+
name: All huey tests passed or skipped
65+
needs: test
66+
# Always run this, even if a dependent job failed
67+
if: always()
68+
runs-on: ubuntu-20.04
69+
steps:
70+
- name: Check for failures
71+
if: contains(needs.test.result, 'failure')
72+
run: |
73+
echo "One of the dependent jobs have failed. You may need to re-run it." && exit 1

mypy.ini

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,5 @@ disallow_untyped_defs = False
6363
ignore_missing_imports = True
6464
[mypy-flask.signals]
6565
ignore_missing_imports = True
66+
[mypy-huey.*]
67+
ignore_missing_imports = True

sentry_sdk/consts.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ class OP:
7272
QUEUE_SUBMIT_CELERY = "queue.submit.celery"
7373
QUEUE_TASK_CELERY = "queue.task.celery"
7474
QUEUE_TASK_RQ = "queue.task.rq"
75+
QUEUE_SUBMIT_HUEY = "queue.submit.huey"
76+
QUEUE_TASK_HUEY = "queue.task.huey"
7577
SUBPROCESS = "subprocess"
7678
SUBPROCESS_WAIT = "subprocess.wait"
7779
SUBPROCESS_COMMUNICATE = "subprocess.communicate"

sentry_sdk/integrations/huey.py

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
from __future__ import absolute_import
2+
3+
import sys
4+
from datetime import datetime
5+
6+
from sentry_sdk._compat import reraise
7+
from sentry_sdk._types import MYPY
8+
from sentry_sdk import Hub
9+
from sentry_sdk.consts import OP, SENSITIVE_DATA_SUBSTITUTE
10+
from sentry_sdk.hub import _should_send_default_pii
11+
from sentry_sdk.integrations import DidNotEnable, Integration
12+
from sentry_sdk.tracing import Transaction, TRANSACTION_SOURCE_TASK
13+
from sentry_sdk.utils import capture_internal_exceptions, event_from_exception
14+
15+
if MYPY:
16+
from typing import Any, Callable, Optional, Union, TypeVar
17+
18+
from sentry_sdk._types import EventProcessor, Event, Hint
19+
from sentry_sdk.utils import ExcInfo
20+
21+
F = TypeVar("F", bound=Callable[..., Any])
22+
23+
try:
24+
from huey.api import Huey, Result, ResultGroup, Task
25+
from huey.exceptions import CancelExecution, RetryTask
26+
except ImportError:
27+
raise DidNotEnable("Huey is not installed")
28+
29+
30+
HUEY_CONTROL_FLOW_EXCEPTIONS = (CancelExecution, RetryTask)
31+
32+
33+
class HueyIntegration(Integration):
34+
identifier = "huey"
35+
36+
@staticmethod
37+
def setup_once():
38+
# type: () -> None
39+
patch_enqueue()
40+
patch_execute()
41+
42+
43+
def patch_enqueue():
44+
# type: () -> None
45+
old_enqueue = Huey.enqueue
46+
47+
def _sentry_enqueue(self, task):
48+
# type: (Huey, Task) -> Optional[Union[Result, ResultGroup]]
49+
hub = Hub.current
50+
51+
if hub.get_integration(HueyIntegration) is None:
52+
return old_enqueue(self, task)
53+
54+
with hub.start_span(op=OP.QUEUE_SUBMIT_HUEY, description=task.name):
55+
return old_enqueue(self, task)
56+
57+
Huey.enqueue = _sentry_enqueue
58+
59+
60+
def _make_event_processor(task):
61+
# type: (Any) -> EventProcessor
62+
def event_processor(event, hint):
63+
# type: (Event, Hint) -> Optional[Event]
64+
65+
with capture_internal_exceptions():
66+
tags = event.setdefault("tags", {})
67+
tags["huey_task_id"] = task.id
68+
tags["huey_task_retry"] = task.default_retries > task.retries
69+
extra = event.setdefault("extra", {})
70+
extra["huey-job"] = {
71+
"task": task.name,
72+
"args": task.args
73+
if _should_send_default_pii()
74+
else SENSITIVE_DATA_SUBSTITUTE,
75+
"kwargs": task.kwargs
76+
if _should_send_default_pii()
77+
else SENSITIVE_DATA_SUBSTITUTE,
78+
"retry": (task.default_retries or 0) - task.retries,
79+
}
80+
81+
return event
82+
83+
return event_processor
84+
85+
86+
def _capture_exception(exc_info):
87+
# type: (ExcInfo) -> None
88+
hub = Hub.current
89+
90+
if exc_info[0] in HUEY_CONTROL_FLOW_EXCEPTIONS:
91+
hub.scope.transaction.set_status("aborted")
92+
return
93+
94+
hub.scope.transaction.set_status("internal_error")
95+
event, hint = event_from_exception(
96+
exc_info,
97+
client_options=hub.client.options if hub.client else None,
98+
mechanism={"type": HueyIntegration.identifier, "handled": False},
99+
)
100+
hub.capture_event(event, hint=hint)
101+
102+
103+
def _wrap_task_execute(func):
104+
# type: (F) -> F
105+
def _sentry_execute(*args, **kwargs):
106+
# type: (*Any, **Any) -> Any
107+
hub = Hub.current
108+
if hub.get_integration(HueyIntegration) is None:
109+
return func(*args, **kwargs)
110+
111+
try:
112+
result = func(*args, **kwargs)
113+
except Exception:
114+
exc_info = sys.exc_info()
115+
_capture_exception(exc_info)
116+
reraise(*exc_info)
117+
118+
return result
119+
120+
return _sentry_execute # type: ignore
121+
122+
123+
def patch_execute():
124+
# type: () -> None
125+
old_execute = Huey._execute
126+
127+
def _sentry_execute(self, task, timestamp=None):
128+
# type: (Huey, Task, Optional[datetime]) -> Any
129+
hub = Hub.current
130+
131+
if hub.get_integration(HueyIntegration) is None:
132+
return old_execute(self, task, timestamp)
133+
134+
with hub.push_scope() as scope:
135+
with capture_internal_exceptions():
136+
scope._name = "huey"
137+
scope.clear_breadcrumbs()
138+
scope.add_event_processor(_make_event_processor(task))
139+
140+
transaction = Transaction(
141+
name=task.name,
142+
status="ok",
143+
op=OP.QUEUE_TASK_HUEY,
144+
source=TRANSACTION_SOURCE_TASK,
145+
)
146+
147+
if not getattr(task, "_sentry_is_patched", False):
148+
task.execute = _wrap_task_execute(task.execute)
149+
task._sentry_is_patched = True
150+
151+
with hub.start_transaction(transaction):
152+
return old_execute(self, task, timestamp)
153+
154+
Huey._execute = _sentry_execute

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ def get_file_text(file_name):
5151
"django": ["django>=1.8"],
5252
"sanic": ["sanic>=0.8"],
5353
"celery": ["celery>=3"],
54+
"huey": ["huey>=2"],
5455
"beam": ["apache-beam>=2.12"],
5556
"rq": ["rq>=0.6"],
5657
"aiohttp": ["aiohttp>=3.5"],

tests/integrations/huey/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import pytest
2+
3+
pytest.importorskip("huey")

tests/integrations/huey/test_huey.py

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import pytest
2+
from decimal import DivisionByZero
3+
4+
from sentry_sdk import start_transaction
5+
from sentry_sdk.integrations.huey import HueyIntegration
6+
7+
from huey.api import MemoryHuey, Result
8+
from huey.exceptions import RetryTask
9+
10+
11+
@pytest.fixture
12+
def init_huey(sentry_init):
13+
def inner():
14+
sentry_init(
15+
integrations=[HueyIntegration()],
16+
traces_sample_rate=1.0,
17+
send_default_pii=True,
18+
debug=True,
19+
)
20+
21+
return MemoryHuey(name="sentry_sdk")
22+
23+
return inner
24+
25+
26+
@pytest.fixture(autouse=True)
27+
def flush_huey_tasks(init_huey):
28+
huey = init_huey()
29+
huey.flush()
30+
31+
32+
def execute_huey_task(huey, func, *args, **kwargs):
33+
exceptions = kwargs.pop("exceptions", None)
34+
result = func(*args, **kwargs)
35+
task = huey.dequeue()
36+
if exceptions is not None:
37+
try:
38+
huey.execute(task)
39+
except exceptions:
40+
pass
41+
else:
42+
huey.execute(task)
43+
return result
44+
45+
46+
def test_task_result(init_huey):
47+
huey = init_huey()
48+
49+
@huey.task()
50+
def increase(num):
51+
return num + 1
52+
53+
result = increase(3)
54+
55+
assert isinstance(result, Result)
56+
assert len(huey) == 1
57+
task = huey.dequeue()
58+
assert huey.execute(task) == 4
59+
assert result.get() == 4
60+
61+
62+
@pytest.mark.parametrize("task_fails", [True, False], ids=["error", "success"])
63+
def test_task_transaction(capture_events, init_huey, task_fails):
64+
huey = init_huey()
65+
66+
@huey.task()
67+
def division(a, b):
68+
return a / b
69+
70+
events = capture_events()
71+
execute_huey_task(
72+
huey, division, 1, int(not task_fails), exceptions=(DivisionByZero,)
73+
)
74+
75+
if task_fails:
76+
error_event = events.pop(0)
77+
assert error_event["exception"]["values"][0]["type"] == "ZeroDivisionError"
78+
assert error_event["exception"]["values"][0]["mechanism"]["type"] == "huey"
79+
80+
(event,) = events
81+
assert event["type"] == "transaction"
82+
assert event["transaction"] == "division"
83+
assert event["transaction_info"] == {"source": "task"}
84+
85+
if task_fails:
86+
assert event["contexts"]["trace"]["status"] == "internal_error"
87+
else:
88+
assert event["contexts"]["trace"]["status"] == "ok"
89+
90+
assert "huey_task_id" in event["tags"]
91+
assert "huey_task_retry" in event["tags"]
92+
93+
94+
def test_task_retry(capture_events, init_huey):
95+
huey = init_huey()
96+
context = {"retry": True}
97+
98+
@huey.task()
99+
def retry_task(context):
100+
if context["retry"]:
101+
context["retry"] = False
102+
raise RetryTask()
103+
104+
events = capture_events()
105+
result = execute_huey_task(huey, retry_task, context)
106+
(event,) = events
107+
108+
assert event["transaction"] == "retry_task"
109+
assert event["tags"]["huey_task_id"] == result.task.id
110+
assert len(huey) == 1
111+
112+
task = huey.dequeue()
113+
huey.execute(task)
114+
(event, _) = events
115+
116+
assert event["transaction"] == "retry_task"
117+
assert event["tags"]["huey_task_id"] == result.task.id
118+
assert len(huey) == 0
119+
120+
121+
def test_huey_enqueue(init_huey, capture_events):
122+
huey = init_huey()
123+
124+
@huey.task(name="different_task_name")
125+
def dummy_task():
126+
pass
127+
128+
events = capture_events()
129+
130+
with start_transaction() as transaction:
131+
dummy_task()
132+
133+
(event,) = events
134+
135+
assert event["contexts"]["trace"]["trace_id"] == transaction.trace_id
136+
assert event["contexts"]["trace"]["span_id"] == transaction.span_id
137+
138+
assert len(event["spans"])
139+
assert event["spans"][0]["op"] == "queue.submit.huey"
140+
assert event["spans"][0]["description"] == "different_task_name"

0 commit comments

Comments
 (0)