Skip to content

Commit f89fc54

Browse files
committed
wip: test the sentry interceptor
1 parent 4c458f3 commit f89fc54

6 files changed

Lines changed: 242 additions & 39 deletions

File tree

sentry/activity.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,22 @@
44

55

66
@dataclass
7-
class ComposeGreetingInput:
8-
greeting: str
9-
name: str
7+
class WorkingActivityInput:
8+
message: str
109

1110

1211
@activity.defn
13-
async def compose_greeting(input: ComposeGreetingInput) -> str:
12+
async def working_activity(input: WorkingActivityInput) -> str:
13+
activity.logger.info("Running activity with parameter %s" % input)
14+
return "Success"
15+
16+
17+
@dataclass
18+
class BrokenActivityInput:
19+
message: str
20+
21+
22+
@activity.defn
23+
async def broken_activity(input: BrokenActivityInput) -> str:
1424
activity.logger.info("Running activity with parameter %s" % input)
1525
raise Exception("Activity failed!")

sentry/starter.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from temporalio.client import Client
44

5-
from sentry.workflow import GreetingWorkflow
5+
from sentry.workflow import SentryExampleWorkflow, SentryExampleWorkflowInput
66

77

88
async def main():
@@ -12,8 +12,8 @@ async def main():
1212
# Run workflow
1313
try:
1414
result = await client.execute_workflow(
15-
GreetingWorkflow.run,
16-
"World",
15+
SentryExampleWorkflow.run,
16+
SentryExampleWorkflowInput(option="broken"),
1717
id="sentry-workflow-id",
1818
task_queue="sentry-task-queue",
1919
)

sentry/worker.py

Lines changed: 26 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import asyncio
2-
import logging
32
import os
43

54
import sentry_sdk
@@ -12,11 +11,9 @@
1211
SandboxRestrictions,
1312
)
1413

15-
from sentry.activity import compose_greeting
14+
from sentry.activity import broken_activity, working_activity
1615
from sentry.interceptor import SentryInterceptor
17-
from sentry.workflow import GreetingWorkflow
18-
19-
logger = logging.getLogger(__name__)
16+
from sentry.workflow import SentryExampleWorkflow
2017

2118
interrupt_event = asyncio.Event()
2219

@@ -29,27 +26,30 @@ def before_send(event: Event, hint: Hint) -> Event | None:
2926
return event
3027

3128

32-
async def main():
33-
# Configure logging
34-
logging.basicConfig(level=logging.INFO)
35-
36-
# Initialize the Sentry SDK
37-
if sentry_dsn := os.environ.get("SENTRY_DSN"):
38-
environment = os.environ.get("ENVIRONMENT")
39-
sentry_sdk.init(
40-
dsn=sentry_dsn,
41-
environment=environment,
42-
integrations=[
43-
AsyncioIntegration(),
44-
],
45-
attach_stacktrace=True,
46-
before_send=before_send,
47-
)
48-
logger.info(f"Sentry SDK initialized for environment: {environment!r}")
49-
else:
50-
logger.warning(
29+
def initialise_sentry() -> None:
30+
sentry_dsn = os.environ.get("SENTRY_DSN")
31+
if not sentry_dsn:
32+
print(
5133
"SENTRY_DSN environment variable is not set. Sentry will not be initialized."
5234
)
35+
return
36+
37+
environment = os.environ.get("ENVIRONMENT")
38+
sentry_sdk.init(
39+
dsn=sentry_dsn,
40+
environment=environment,
41+
integrations=[
42+
AsyncioIntegration(),
43+
],
44+
attach_stacktrace=True,
45+
before_send=before_send,
46+
)
47+
print(f"Sentry SDK initialized for environment: {environment!r}")
48+
49+
50+
async def main():
51+
# Initialize the Sentry SDK
52+
initialise_sentry()
5353

5454
# Start client
5555
client = await Client.connect("localhost:7233")
@@ -58,8 +58,8 @@ async def main():
5858
async with Worker(
5959
client,
6060
task_queue="sentry-task-queue",
61-
workflows=[GreetingWorkflow],
62-
activities=[compose_greeting],
61+
workflows=[SentryExampleWorkflow],
62+
activities=[broken_activity, working_activity],
6363
interceptors=[SentryInterceptor()], # Use SentryInterceptor for error reporting
6464
workflow_runner=SandboxedWorkflowRunner(
6565
restrictions=SandboxRestrictions.default.with_passthrough_modules(

sentry/workflow.py

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,38 @@
1+
import typing
2+
from dataclasses import dataclass
13
from datetime import timedelta
24

35
from temporalio import workflow
46
from temporalio.common import RetryPolicy
57

8+
from sentry.activity import WorkingActivityInput, working_activity
9+
610
with workflow.unsafe.imports_passed_through():
7-
from sentry.activity import ComposeGreetingInput, compose_greeting
11+
from sentry.activity import BrokenActivityInput, broken_activity
12+
13+
14+
@dataclass
15+
class SentryExampleWorkflowInput:
16+
option: typing.Literal["working", "broken"]
817

918

1019
@workflow.defn
11-
class GreetingWorkflow:
20+
class SentryExampleWorkflow:
1221
@workflow.run
13-
async def run(self, name: str) -> str:
14-
workflow.logger.info("Running workflow with parameter %s" % name)
22+
async def run(self, input: SentryExampleWorkflowInput) -> str:
23+
workflow.logger.info("Running workflow with parameter %r" % input)
24+
25+
if input.option == "working":
26+
return await workflow.execute_activity(
27+
working_activity,
28+
WorkingActivityInput(message="Hello, Temporal!"),
29+
start_to_close_timeout=timedelta(seconds=10),
30+
retry_policy=RetryPolicy(maximum_attempts=1),
31+
)
32+
1533
return await workflow.execute_activity(
16-
compose_greeting,
17-
ComposeGreetingInput("Hello", name),
34+
broken_activity,
35+
BrokenActivityInput(message="Hello, Temporal!"),
1836
start_to_close_timeout=timedelta(seconds=10),
1937
retry_policy=RetryPolicy(maximum_attempts=1),
2038
)
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import sentry_sdk
2+
import sentry_sdk.types
3+
4+
5+
class FakeSentryTransport:
6+
"""A fake transport that captures Sentry events in memory"""
7+
8+
# Note: we could extend from sentry_sdk.transport.Transport
9+
# but `sentry_sdk.init` also takes a simple callable that takes
10+
# an Event rather than a serialised Envelope object, so testing
11+
# is easier.
12+
13+
def __init__(self):
14+
self.events: list[sentry_sdk.types.Event] = []
15+
16+
def __callable__(self, event: sentry_sdk.types.Event) -> None:
17+
self.events.append(event)

tests/sentry/test_interceptor.py

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import unittest.mock
2+
from collections import abc
3+
4+
import pytest
5+
import sentry_sdk
6+
import temporalio.activity
7+
import temporalio.workflow
8+
from sentry_sdk.integrations.asyncio import AsyncioIntegration
9+
from temporalio.client import Client
10+
from temporalio.worker import Worker
11+
from temporalio.worker.workflow_sandbox import (
12+
SandboxedWorkflowRunner,
13+
SandboxRestrictions,
14+
)
15+
16+
from sentry.activity import broken_activity, working_activity
17+
from sentry.interceptor import SentryInterceptor
18+
from sentry.workflow import SentryExampleWorkflow, SentryExampleWorkflowInput
19+
from tests.sentry.fake_sentry_transport import FakeSentryTransport
20+
21+
22+
@pytest.fixture
23+
def transport() -> FakeSentryTransport:
24+
"""Fixture to provide a fake transport for Sentry SDK."""
25+
return FakeSentryTransport()
26+
27+
28+
@pytest.fixture(autouse=True)
29+
def sentry_init(transport: FakeSentryTransport) -> None:
30+
"""Initialize Sentry for testing."""
31+
sentry_sdk.init(
32+
# Pass __callable__ explicitly so SDK treats it as Callable[[Event], None]
33+
# it confuses it otherwise
34+
transport=transport.__callable__,
35+
integrations=[
36+
AsyncioIntegration(),
37+
],
38+
)
39+
40+
41+
@pytest.fixture
42+
async def worker(client: Client) -> abc.AsyncIterator[Worker]:
43+
"""Fixture to provide a worker for testing."""
44+
async with Worker(
45+
client,
46+
task_queue="sentry-task-queue",
47+
workflows=[SentryExampleWorkflow],
48+
activities=[broken_activity, working_activity],
49+
interceptors=[SentryInterceptor()],
50+
workflow_runner=SandboxedWorkflowRunner(
51+
restrictions=SandboxRestrictions.default.with_passthrough_modules(
52+
"sentry_sdk"
53+
)
54+
),
55+
) as worker:
56+
yield worker
57+
58+
59+
async def test_sentry_interceptor_reports_no_errors_when_workflow_succeeds(
60+
client: Client, worker: Worker, transport: FakeSentryTransport
61+
) -> None:
62+
"""Test that Sentry interceptor reports no errors when workflow succeeds."""
63+
# WHEN
64+
try:
65+
await client.execute_workflow(
66+
SentryExampleWorkflow.run,
67+
SentryExampleWorkflowInput(option="working"),
68+
id="sentry-workflow-id",
69+
task_queue=worker.task_queue,
70+
)
71+
except Exception:
72+
pytest.fail("Workflow should not raise an exception")
73+
74+
# THEN
75+
assert len(transport.events) == 0, "No events should be captured"
76+
77+
78+
async def test_sentry_interceptor_captures_errors(
79+
client: Client, worker: Worker, transport: FakeSentryTransport
80+
) -> None:
81+
"""Test that errors are captured with correct Sentry metadata."""
82+
# WHEN
83+
try:
84+
await client.execute_workflow(
85+
SentryExampleWorkflow.run,
86+
SentryExampleWorkflowInput(option="broken"),
87+
id="sentry-workflow-id",
88+
task_queue=worker.task_queue,
89+
)
90+
pytest.fail("Workflow should raise an exception")
91+
except Exception:
92+
pass
93+
94+
# THEN
95+
# there should be two events: one for the failed activity and one for the failed workflow
96+
assert len(transport.events) == 2, "Two events should be captured"
97+
98+
# Check the first event - should be the activity exception
99+
# --------------------------------------------------------
100+
event = transport.events[0]
101+
102+
# Check exception was captured
103+
assert event["exception"]["values"][0]["type"] == "Exception"
104+
assert event["exception"]["values"][0]["value"] == "Activity failed!"
105+
106+
# Check useful metadata were were captured as tags
107+
assert event["tags"] == {
108+
"temporal.execution_type": "activity",
109+
"module": "sentry.activity.broken_activity",
110+
"temporal.workflow.type": "SentryExampleWorkflow",
111+
"temporal.workflow.id": "sentry-workflow-id",
112+
"temporal.activity.id": "1",
113+
"temporal.activity.type": "broken_activity",
114+
"temporal.activity.task_queue": "sentry-task-queue",
115+
"temporal.workflow.namespace": "default",
116+
"temporal.workflow.run_id": unittest.mock.ANY,
117+
}
118+
119+
# Check activity input was captured as context
120+
assert event["contexts"]["temporal.activity.input"] == {
121+
"message": "Hello, Temporal!",
122+
}
123+
124+
# Check activity info was captured as context
125+
activity_info = temporalio.activity.Info(
126+
**event["contexts"]["temporal.activity.info"] # type: ignore
127+
)
128+
assert activity_info.activity_type == "broken_activity"
129+
130+
# Check the second event - should be the workflow exception
131+
# ---------------------------------------------------------
132+
event = transport.events[1]
133+
134+
# Check exception was captured
135+
assert event["exception"]["values"][0]["type"] == "ApplicationError"
136+
assert event["exception"]["values"][0]["value"] == "Activity failed!"
137+
138+
# Check useful metadata were were captured as tags
139+
assert event["tags"] == {
140+
"temporal.execution_type": "workflow",
141+
"module": "sentry.workflow.SentryExampleWorkflow.run",
142+
"temporal.workflow.type": "SentryExampleWorkflow",
143+
"temporal.workflow.id": "sentry-workflow-id",
144+
"temporal.workflow.task_queue": "sentry-task-queue",
145+
"temporal.workflow.namespace": "default",
146+
"temporal.workflow.run_id": unittest.mock.ANY,
147+
}
148+
149+
# Check workflow input was captured as context
150+
assert event["contexts"]["temporal.workflow.input"] == {
151+
"option": "broken",
152+
}
153+
154+
# Check workflow info was captured as context
155+
workflow_info = temporalio.workflow.Info(
156+
**event["contexts"]["temporal.workflow.info"] # type: ignore
157+
)
158+
assert workflow_info.workflow_type == "SentryExampleWorkflow"

0 commit comments

Comments
 (0)