Skip to content

Commit c2e90f0

Browse files
committed
refactor(sentry_integration): enhance error handling and telemetry across services
- Integrated `capture_exception_safe` in various modules to improve exception tracking with additional context, enhancing observability during error occurrences. - Updated error handling in the EmojiManager, HotReload, and GitHubService to capture and report errors with specific operation context. - Streamlined exception logging across multiple services, ensuring unexpected errors are reported to Sentry while maintaining appropriate logging levels for expected behaviors. - Added metrics recording for command execution, database operations, and API calls to improve performance tracking and observability.
1 parent ca296ed commit c2e90f0

File tree

14 files changed

+1126
-259
lines changed

14 files changed

+1126
-259
lines changed

src/tux/services/emoji_manager.py

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
from discord.ext import commands
1515
from loguru import logger
1616

17+
from tux.services.sentry import capture_exception_safe
18+
1719
# --- Configuration Constants ---
1820

1921
DEFAULT_EMOJI_ASSETS_PATH = Path(__file__).parents[3] / "assets" / "emojis"
@@ -84,9 +86,16 @@ def _read_emoji_file(file_path: Path) -> bytes | None:
8486
return None
8587

8688
except Exception as e:
87-
logger.exception(
89+
logger.error(
8890
f"An unexpected error occurred reading file '{file_path}': {e}",
8991
)
92+
capture_exception_safe(
93+
e,
94+
extra_context={
95+
"operation": "read_local_file",
96+
"file_path": str(file_path),
97+
},
98+
)
9099
return None
91100

92101

@@ -188,16 +197,30 @@ async def init(self) -> bool:
188197
logger.error(f"Failed to fetch application emojis during init: {e}")
189198
self._initialized = False
190199
return False
191-
except discord.DiscordException:
192-
logger.exception(
200+
except discord.DiscordException as e:
201+
logger.error(
193202
"Unexpected Discord error during emoji cache initialization.",
194203
)
204+
capture_exception_safe(
205+
e,
206+
extra_context={
207+
"operation": "emoji_cache_initialization",
208+
"error_type": "DiscordException",
209+
},
210+
)
195211
self._initialized = False
196212
return False
197-
except Exception:
198-
logger.exception(
213+
except Exception as e:
214+
logger.error(
199215
"Unexpected non-Discord error during emoji cache initialization.",
200216
)
217+
capture_exception_safe(
218+
e,
219+
extra_context={
220+
"operation": "emoji_cache_initialization",
221+
"error_type": "non-Discord",
222+
},
223+
)
201224
self._initialized = False
202225
return False
203226

@@ -268,9 +291,16 @@ async def _create_discord_emoji(
268291
except ValueError as e:
269292
logger.error(f"Invalid value for creating emoji '{name}': {e}")
270293
except Exception as e:
271-
logger.exception(
294+
logger.error(
272295
f"An unexpected error occurred creating emoji '{name}': {e}",
273296
)
297+
capture_exception_safe(
298+
e,
299+
extra_context={
300+
"operation": "create_emoji",
301+
"emoji_name": name,
302+
},
303+
)
274304

275305
return None
276306

@@ -425,9 +455,16 @@ async def _delete_discord_emoji(self, name: str) -> bool:
425455
except discord.HTTPException as e:
426456
logger.error(f"Failed to delete application emoji '{name}': {e}")
427457
except Exception as e:
428-
logger.exception(
458+
logger.error(
429459
f"An unexpected error occurred deleting emoji '{name}': {e}",
430460
)
461+
capture_exception_safe(
462+
e,
463+
extra_context={
464+
"operation": "delete_emoji",
465+
"emoji_name": name,
466+
},
467+
)
431468

432469
finally:
433470
# Always remove from cache if it was found initially

src/tux/services/hot_reload/service.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,8 @@ async def _reload_extension_with_monitoring(self, extension: str) -> None:
153153
self._reload_stats["total_reloads"] += 1
154154

155155
try:
156-
with sentry_sdk.configure_scope() as scope:
156+
# Use push_scope for isolated scope to avoid polluting current scope
157+
with sentry_sdk.push_scope() as scope:
157158
scope.set_tag("extension", extension)
158159
scope.set_tag("reload_type", "hot_reload")
159160

src/tux/services/sentry/__init__.py

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -60,12 +60,21 @@
6060
# Set initial user to None
6161
sentry_sdk.set_user(None)
6262

63+
from .metrics import (
64+
record_api_metric,
65+
record_cache_metric,
66+
record_cog_metric,
67+
record_command_metric,
68+
record_database_metric,
69+
record_task_metric,
70+
)
6371
from .utils import (
6472
capture_api_error,
6573
capture_cog_error,
6674
capture_database_error,
6775
capture_exception_safe,
6876
capture_tux_exception,
77+
convert_httpx_error,
6978
)
7079

7180
__all__ = [
@@ -97,11 +106,19 @@
97106
"capture_exception_safe",
98107
"capture_span_exception",
99108
"capture_tux_exception",
109+
"convert_httpx_error",
100110
# Instrumentation and tracking
101111
"instrument_bot_commands",
102112
"safe_set_name",
103113
"track_command_end",
104114
"track_command_start",
115+
# Metrics functions
116+
"record_api_metric",
117+
"record_cache_metric",
118+
"record_cog_metric",
119+
"record_command_metric",
120+
"record_database_metric",
121+
"record_task_metric",
105122
]
106123

107124

@@ -284,7 +301,7 @@ def get_current_span(self) -> Any | None:
284301
"""
285302
return get_current_span()
286303

287-
def start_transaction(self, op: str, name: str, description: str = "") -> Any:
304+
def start_transaction(self, op: str, name: str) -> Any:
288305
"""
289306
Start a new Sentry transaction.
290307
@@ -294,33 +311,31 @@ def start_transaction(self, op: str, name: str, description: str = "") -> Any:
294311
The operation type.
295312
name : str
296313
The transaction name.
297-
description : str, optional
298-
A description of the transaction.
299314
300315
Returns
301316
-------
302317
Any
303318
The started transaction object.
304319
"""
305-
return start_transaction(op, name, description)
320+
return start_transaction(op, name)
306321

307-
def start_span(self, op: str, description: str = "") -> Any:
322+
def start_span(self, op: str, name: str = "") -> Any:
308323
"""
309324
Start a new Sentry span.
310325
311326
Parameters
312327
----------
313328
op : str
314329
The operation name for the span.
315-
description : str, optional
316-
A description of the span.
330+
name : str, optional
331+
The name of the span.
317332
318333
Returns
319334
-------
320335
Any
321336
The started span object.
322337
"""
323-
return start_span(op, description)
338+
return start_span(op, name)
324339

325340
def add_breadcrumb(
326341
self,

src/tux/services/sentry/cog.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ async def on_app_command_completion(self, interaction: discord.Interaction) -> N
5151
set_command_context(interaction)
5252
set_user_context(interaction.user)
5353

54-
# Track completion
54+
# Track completion (command_type will be determined in track_command_end)
5555
track_command_end(interaction.command.qualified_name, success=True)
5656

5757
async def cog_load(self) -> None:

src/tux/services/sentry/config.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,11 +88,24 @@ def report_signal(signum: int, _frame: FrameType | None) -> None:
8888
signal.SIGINT.value: "SIGINT",
8989
}.get(signum, f"SIGNAL_{signum}")
9090

91-
sentry_sdk.capture_message(
92-
f"Received {signal_name}, initiating graceful shutdown",
93-
level="info",
91+
# Add more context to the message to avoid "(No error message)" in Sentry
92+
message = (
93+
f"Received {signal_name}, initiating graceful shutdown. "
94+
f"This is a normal shutdown signal, not an error."
9495
)
9596

97+
scope.set_context(
98+
"signal_details",
99+
{
100+
"signal_number": signum,
101+
"signal_name": signal_name,
102+
"shutdown_type": "graceful",
103+
"is_error": False,
104+
},
105+
)
106+
107+
sentry_sdk.capture_message(message, level="info")
108+
96109
logger.info(f"Signal {signal_name} reported to Sentry")
97110

98111

src/tux/services/sentry/context.py

Lines changed: 58 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -22,25 +22,55 @@
2222

2323

2424
def set_user_context(user: discord.User | discord.Member) -> None:
25-
# sourcery skip: extract-method
26-
"""Set user context for Sentry events."""
25+
"""
26+
Set user context for Sentry events using Discord user information.
27+
28+
This function identifies users in Sentry using their Discord user ID as the
29+
primary identifier. This enables user-based filtering, grouping, and analytics
30+
in Sentry, allowing you to see which users encountered errors.
31+
32+
Parameters
33+
----------
34+
user : discord.User | discord.Member
35+
The Discord user to set as context. Must have an `id` attribute.
36+
37+
Notes
38+
-----
39+
The user ID (Discord snowflake) is used as Sentry's user identifier, enabling:
40+
- User-based error grouping and filtering
41+
- User impact analysis (how many users affected)
42+
- User-specific error tracking
43+
- User analytics in Sentry Insights
44+
45+
Additional Discord-specific data is included as custom user attributes:
46+
- Username and display name for better identification
47+
- Guild information (if member)
48+
- Permissions and roles (if member)
49+
- Bot/system flags
50+
"""
2751
if not is_initialized():
2852
return
2953

54+
# Primary identifier: Discord user ID (required by Sentry)
3055
user_data = {
31-
"id": str(user.id),
32-
"username": user.name,
33-
"display_name": user.display_name,
34-
"bot": user.bot,
35-
"system": getattr(user, "system", False),
56+
"id": str(user.id), # Discord user ID - used as unique identifier in Sentry
57+
"username": user.name, # Discord username
58+
"display_name": user.display_name, # Display name (nickname or username)
59+
"bot": user.bot, # Whether user is a bot
60+
"system": getattr(user, "system", False), # Whether user is a system user
3661
}
3762

63+
# Additional Discord-specific context (if member with guild)
3864
if isinstance(user, discord.Member) and user.guild:
39-
user_data["guild_id"] = str(user.guild.id)
40-
user_data["guild_name"] = user.guild.name
41-
user_data["guild_member_count"] = str(user.guild.member_count)
42-
user_data["guild_permissions"] = str(user.guild_permissions.value)
43-
user_data["top_role"] = user.top_role.name if user.top_role else None
65+
user_data.update(
66+
{
67+
"guild_id": str(user.guild.id),
68+
"guild_name": user.guild.name,
69+
"guild_member_count": str(user.guild.member_count),
70+
"guild_permissions": str(user.guild_permissions.value),
71+
"top_role": user.top_role.name if user.top_role else None,
72+
},
73+
)
4474
if user.joined_at:
4575
user_data["joined_at"] = user.joined_at.isoformat()
4676

@@ -86,9 +116,11 @@ def track_command_end(
86116
if not is_initialized():
87117
return
88118

119+
execution_time_ms = 0.0
89120
if start_time := _command_start_times.pop(command_name, None):
90121
execution_time = time.perf_counter() - start_time
91-
set_tag("command.execution_time_ms", round(execution_time * 1000, 2))
122+
execution_time_ms = round(execution_time * 1000, 2)
123+
set_tag("command.execution_time_ms", execution_time_ms)
92124

93125
set_tag("command.success", success)
94126
if error:
@@ -102,6 +134,19 @@ def track_command_end(
102134
},
103135
)
104136

137+
# Record metrics for command execution
138+
# Note: command_type detection would require passing context, defaulting to "unknown"
139+
# This can be enhanced later by modifying track_command_end signature
140+
from .metrics import record_command_metric # noqa: PLC0415
141+
142+
record_command_metric(
143+
command_name=command_name,
144+
execution_time_ms=execution_time_ms,
145+
success=success,
146+
error_type=type(error).__name__ if error else None,
147+
command_type="unknown", # Could be enhanced to detect prefix/slash from context
148+
)
149+
105150

106151
def _set_command_context_from_ctx(ctx: commands.Context[commands.Bot]) -> None:
107152
"""Set context from a command context."""

0 commit comments

Comments
 (0)