618 lines
21 KiB
Python
618 lines
21 KiB
Python
# #### BEGIN GPL LICENSE BLOCK #####
|
|
#
|
|
# This program is free software; you can redistribute it and/or
|
|
# modify it under the terms of the GNU General Public License
|
|
# as published by the Free Software Foundation; either version 2
|
|
# of the License, or (at your option) any later version.
|
|
#
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU General Public License
|
|
# along with this program; if not, write to the Free Software Foundation,
|
|
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
|
#
|
|
# ##### END GPL LICENSE BLOCK #####
|
|
|
|
"""This module establishes the mechanisms for exception and error reporting."""
|
|
|
|
import functools
|
|
import os
|
|
import platform
|
|
import re
|
|
import sys
|
|
import traceback
|
|
from typing import Optional
|
|
|
|
import bpy
|
|
|
|
from .modules.poliigon_core.env import PoliigonEnvironment
|
|
|
|
|
|
# Must add to local path due to sentry_sdk self importing.
|
|
base = os.path.dirname(__file__)
|
|
module_dir = os.path.join(base, "modules")
|
|
sys.path.append(module_dir)
|
|
|
|
import sentry_sdk # noqa: E402
|
|
|
|
# Flag for whether to send events.
|
|
IS_OPTED_IN = True
|
|
|
|
# Container during an exception to allow user to add information to report.
|
|
LAST_ERROR_CONTEXT = None
|
|
|
|
# Draw error cache to avoid duplicate draw code reports.
|
|
DRAW_ERROR_CACHE = []
|
|
|
|
# From: https://develop.sentry.dev/sdk/event-payloads/transaction/
|
|
TRANSACT_OK = "ok"
|
|
TRANSACT_CANCEL = "cancelled"
|
|
TRANSACT_FAIL = "internal_error"
|
|
|
|
# Maximum number of characters sentry allows for a single tag body.
|
|
MAX_TAG_CHARS = 508
|
|
|
|
# Sample rates used for sentry reporting if user opted in.
|
|
ERROR_RATE = 1.0
|
|
TRANSACTION_RATE = 0.05
|
|
|
|
|
|
def initialize_sentry(software_name: str,
|
|
software_version: str,
|
|
tool_version: str,
|
|
env: PoliigonEnvironment,
|
|
error_rate: Optional[float] = ERROR_RATE,
|
|
transaction_rate: Optional[float] = TRANSACTION_RATE
|
|
) -> None:
|
|
"""Set up sentry for exception capturing.
|
|
|
|
Args:
|
|
software_name: The name of this 3d software, e.g. blender
|
|
software_version: This 3D software's version: 1.2.3, no prefix v
|
|
tool_version: This plugin's version: 1.2.3 (no prefix v)
|
|
env: Instance of class, where env.env_name is one of dev or prod.
|
|
error_sample_rate: Rate used for sentry error reporting.
|
|
transaction_sample_rate: Rate used for sentry transaction reporting.
|
|
"""
|
|
if env.env_name not in ["dev", "prod", "test"]:
|
|
raise ValueError(
|
|
f"Environment must one of dev or prod, not {env.env_name}")
|
|
|
|
if error_rate is None:
|
|
error_rate = ERROR_RATE
|
|
if transaction_rate is None:
|
|
transaction_rate = TRANSACTION_RATE
|
|
|
|
sentry_sdk.init(
|
|
"https://5f6b090945c14a3e87ec29b19d61b7f5@sentry.poliigon.com/7",
|
|
|
|
# Persistent setup.
|
|
environment=env.env_name, # "dev", "test", or "prod"
|
|
release=tool_version, # should be in form of v1.2.3
|
|
|
|
# Override server_name to avoid sending user machine (pii) to sentry
|
|
server_name="user_machine",
|
|
|
|
# Don't perform automatic stack tracing, force using a wrapper instead.
|
|
default_integrations=False,
|
|
auto_enabling_integrations=False,
|
|
auto_session_tracking=False, # Applies to WSGI middleware, n/a here.
|
|
|
|
# Set the sample rate for error reporting, where 1.0 would equate to
|
|
# 100% of errors being reported. Review/update "Sentry for Software"
|
|
# internal design doc before adjusting.
|
|
sample_rate=1.0 if env.forced_sampling else error_rate,
|
|
|
|
# Set the proportion of transactions to sample, where 1.0 would equate
|
|
# to 100% of transactions for performance monitoring. Review/update
|
|
# the "Sentry for Software" internal design doc before adjusting.
|
|
traces_sample_rate=1.0 if env.forced_sampling else transaction_rate,
|
|
)
|
|
|
|
os_lower = platform.platform().lower()
|
|
if "linux" in os_lower:
|
|
os_name = "linux"
|
|
elif "windows" in os_lower:
|
|
os_name = "windows"
|
|
elif "darwin" in os_lower or "macos" in os_lower:
|
|
os_name = "mac"
|
|
else:
|
|
os_name = platform.platform()
|
|
|
|
sentry_sdk.set_tag("software_name", software_name)
|
|
sentry_sdk.set_tag("software_version", software_version)
|
|
sentry_sdk.set_tag("release", tool_version)
|
|
sentry_sdk.set_tag("os_name", os_name)
|
|
sentry_sdk.set_tag("os_version", platform.platform())
|
|
|
|
|
|
def _is_foreground() -> bool:
|
|
"""Only send reports if in foreground mode."""
|
|
return not bpy.app.background
|
|
|
|
|
|
def assign_user(userid: Optional[int]) -> None:
|
|
"""Update reporting to be associated to this logged in user."""
|
|
if userid is None:
|
|
sentry_sdk.set_user(None)
|
|
else:
|
|
sentry_sdk.set_user({"id": userid})
|
|
|
|
|
|
def set_optin(optin: bool) -> None:
|
|
"""Change whether reporting should be sent or not.
|
|
|
|
End any current session on opt out, but don't immediately start a session
|
|
if opting in - sessions are wrapped around individual operator calls.
|
|
|
|
This function is triggered on startup to be in sync with user preferences.
|
|
"""
|
|
global IS_OPTED_IN
|
|
if IS_OPTED_IN:
|
|
_flush()
|
|
IS_OPTED_IN = bool(optin) and _is_foreground()
|
|
|
|
|
|
def get_optin() -> bool:
|
|
"""Returns current status, a function that may be injected elsewhere."""
|
|
return IS_OPTED_IN
|
|
|
|
|
|
def handle_operator(silent=False):
|
|
"""Decorator for the execute(self, context) function of bpy operators.
|
|
|
|
Captures any errors for user reporting, and triggers a popup if the wrapper
|
|
is not set to silent, to give the user a chance to share details.
|
|
|
|
Note: This dectorator has to be made seperate from the general purpose
|
|
decorator due to the way blender registers and requires that all execute
|
|
functions have up-front (self, context) as explicit keywords args, using
|
|
*args and **wkargs does not pass this test.
|
|
|
|
Always call with parentheses, like:
|
|
@reporting.handle_operator()
|
|
def execute():
|
|
|
|
@reporting.handle_operator(silent=True)
|
|
def execute():
|
|
"""
|
|
|
|
def decorator(function: callable) -> callable:
|
|
|
|
def wrapper(self, context):
|
|
"""Primary wrapper, decide dynamically how to execute."""
|
|
if IS_OPTED_IN:
|
|
_start_session()
|
|
res = wrapper_transact(self, context)
|
|
_end_session()
|
|
return res
|
|
else:
|
|
return wrapper_non_transact(self, context)
|
|
|
|
def wrapper_transact(self, context):
|
|
"""Wrapper with session tracking."""
|
|
with sentry_sdk.start_transaction(op="operator",
|
|
name=self.bl_idname
|
|
) as transaction:
|
|
try:
|
|
# Function here is the operator execute function.
|
|
res = function(self, context)
|
|
if res == {'FINISHED'}:
|
|
transaction.set_status(TRANSACT_OK)
|
|
elif res == {'CANCELLED'}:
|
|
transaction.set_status(TRANSACT_CANCEL)
|
|
except Exception as e:
|
|
# Intentionally broad to handle everything.
|
|
err = traceback.format_exc()
|
|
print(err) # Always print raw traceback.
|
|
err = _sanitize_paths(err)
|
|
transaction.set_status(TRANSACT_FAIL)
|
|
capture_exception(e)
|
|
|
|
if len(err) > MAX_TAG_CHARS:
|
|
# Pick the last N characters of the error message.
|
|
err_shrt = err[-MAX_TAG_CHARS:]
|
|
else:
|
|
err_shrt = err
|
|
capture_message("unhandled_ops_error", err_shrt, 'fatal')
|
|
|
|
_set_session_crashed()
|
|
|
|
if silent is False:
|
|
bpy.ops.poliigon.report_error(
|
|
'INVOKE_DEFAULT', error_report=err)
|
|
return {'CANCELLED'}
|
|
return res
|
|
|
|
def wrapper_non_transact(self, context):
|
|
"""Wrapper without session tracking."""
|
|
try:
|
|
# Function here is the operator execute function.
|
|
res = function(self, context)
|
|
except Exception:
|
|
# Intentionally broad to handle everything.
|
|
err = traceback.format_exc()
|
|
print(err) # Always print raw traceback.
|
|
|
|
if silent is False:
|
|
bpy.ops.poliigon.report_error(
|
|
'INVOKE_DEFAULT', error_report=err)
|
|
return {'CANCELLED'}
|
|
return res
|
|
|
|
return wrapper
|
|
return decorator
|
|
|
|
|
|
def handle_thread(transaction_name: str, function: functools.partial) -> callable:
|
|
def wrapper():
|
|
"""Primary wrapper, decide dynamically how to execute."""
|
|
if IS_OPTED_IN:
|
|
_start_session()
|
|
res = wrapper_transact()
|
|
_end_session()
|
|
return res
|
|
else:
|
|
return wrapper_non_transact()
|
|
|
|
def wrapper_transact():
|
|
"""The wrapper for operations that should be transactions."""
|
|
with sentry_sdk.start_transaction(op="thread",
|
|
name=transaction_name
|
|
) as transaction:
|
|
try:
|
|
# Function here is the operator execute function.
|
|
res = function()
|
|
except Exception as e:
|
|
# Intentionally broad to handle everything.
|
|
err = traceback.format_exc()
|
|
err = _sanitize_paths(err)
|
|
transaction.set_status(TRANSACT_FAIL)
|
|
capture_exception(e)
|
|
|
|
if len(err) > MAX_TAG_CHARS:
|
|
# Pick the last N characters of the error message.
|
|
err_shrt = err[-MAX_TAG_CHARS:]
|
|
else:
|
|
err_shrt = err
|
|
capture_message("unhandled_func_error", err_shrt, 'fatal')
|
|
return None
|
|
return res
|
|
|
|
def wrapper_non_transact(*args, **kwargs):
|
|
"""Non transaction wrapper."""
|
|
try:
|
|
# Function here is the operator execute function.
|
|
res = function(*args, **kwargs)
|
|
except Exception as e:
|
|
# Intentionally broad to handle everything.
|
|
err = traceback.format_exc()
|
|
err = _sanitize_paths(err)
|
|
capture_exception(e)
|
|
|
|
return None
|
|
return res
|
|
|
|
return wrapper
|
|
|
|
|
|
def handle_function(silent=True, transact=True):
|
|
"""Decorator for general purpose functions.
|
|
|
|
Silent by default as it is often in code that is running async and would
|
|
disrupt the user.
|
|
|
|
Always call with parentheses, like:
|
|
@reporting.handle_function()
|
|
def my_function():
|
|
|
|
@reporting.handle_function(silent=True)
|
|
def my_function():
|
|
"""
|
|
|
|
def decorator(function: callable) -> callable:
|
|
|
|
def wrapper(*args, **kwargs):
|
|
"""Primary wrapper, decide dynamically how to execute."""
|
|
if transact and IS_OPTED_IN:
|
|
return wrapper_transact(*args, **kwargs)
|
|
else:
|
|
return wrapper_non_transact(*args, **kwargs)
|
|
|
|
def wrapper_transact(*args, **kwargs):
|
|
"""The wrapper for operations that should be transactions."""
|
|
with sentry_sdk.start_transaction(op="thread",
|
|
name=function.__name__
|
|
) as transaction:
|
|
try:
|
|
# Function here is the operator execute function.
|
|
res = function(*args, **kwargs)
|
|
except Exception as e:
|
|
# Intentionally broad to handle everything.
|
|
err = traceback.format_exc()
|
|
err = _sanitize_paths(err)
|
|
transaction.set_status(TRANSACT_FAIL)
|
|
capture_exception(e)
|
|
|
|
if len(err) > MAX_TAG_CHARS:
|
|
# Pick the last N characters of the error message.
|
|
err_shrt = err[-MAX_TAG_CHARS:]
|
|
else:
|
|
err_shrt = err
|
|
capture_message("unhandled_func_error", err_shrt, 'fatal')
|
|
|
|
# Since no popup is surfaced, don't count as crashed.
|
|
# _set_session_crashed()
|
|
|
|
if silent is False:
|
|
bpy.ops.poliigon.report_error(
|
|
'INVOKE_DEFAULT', error_report=err)
|
|
return None
|
|
return res
|
|
|
|
def wrapper_non_transact(*args, **kwargs):
|
|
"""Non transaction wrapper."""
|
|
try:
|
|
# Function here is the operator execute function.
|
|
res = function(*args, **kwargs)
|
|
except Exception as e:
|
|
# Intentionally broad to handle everything.
|
|
err = traceback.format_exc()
|
|
err = _sanitize_paths(err)
|
|
capture_exception(e)
|
|
|
|
if silent is False:
|
|
bpy.ops.poliigon.report_error(
|
|
'INVOKE_DEFAULT', error_report=err)
|
|
return None
|
|
return res
|
|
|
|
return wrapper
|
|
return decorator
|
|
|
|
|
|
def handle_draw():
|
|
"""Decorator for draw functions which would have high trigger rates.
|
|
|
|
Silent by default as it is often in code that is running async and would
|
|
disrupt the user.
|
|
|
|
Being a draw function, there is no return value. This is a safeguard so it
|
|
is not used to decorate operational methods.
|
|
|
|
Always call with parentheses, like:
|
|
@reporting.handle_draw()
|
|
def my_draw_function(self, context):
|
|
"""
|
|
|
|
def decorator(function: callable) -> callable:
|
|
def wrapper(self, context):
|
|
# Not wrapped within a transaction as it is ambient.
|
|
try:
|
|
# Primary draw code here.
|
|
function(self, context)
|
|
except Exception as e:
|
|
# Intentionally broad to handle everything.
|
|
err = traceback.format_exc()
|
|
err = _sanitize_paths(err)
|
|
|
|
print(err) # Always print raw traceback.
|
|
|
|
if IS_OPTED_IN and str(err) not in DRAW_ERROR_CACHE:
|
|
DRAW_ERROR_CACHE.append(str(err))
|
|
capture_exception(e)
|
|
|
|
return wrapper
|
|
return decorator
|
|
|
|
|
|
def handle_invoke():
|
|
"""Decorator for invoke functions.
|
|
|
|
Always call with parentheses, like:
|
|
@reporting.handle_invoke()
|
|
def invoke(self, context, event):
|
|
"""
|
|
|
|
def decorator(function: callable) -> callable:
|
|
def wrapper(self, context, event):
|
|
# Not wrapped within a transaction as it is ambient.
|
|
try:
|
|
# Primary invoke code here.
|
|
res = function(self, context, event)
|
|
return res
|
|
except Exception as e:
|
|
# Intentionally broad to handle everything.
|
|
err = traceback.format_exc()
|
|
err = _sanitize_paths(err)
|
|
|
|
print(err) # Always print raw traceback.
|
|
|
|
if IS_OPTED_IN:
|
|
capture_exception(e)
|
|
return {'CANCELLED'}
|
|
return wrapper
|
|
return decorator
|
|
|
|
|
|
def user_report(code_msg: str, user_msg: str, level='info') -> None:
|
|
"""Send a message to sentry.io outside the context of an exception."""
|
|
if len(code_msg) > MAX_TAG_CHARS:
|
|
# Pick the last N characters of the error message.
|
|
code_msg = code_msg[-MAX_TAG_CHARS:]
|
|
|
|
# No check for `IS_OPTED_IN` as the user explicitly presses 'ok' to send.
|
|
with sentry_sdk.push_scope() as scope:
|
|
scope.set_extra("user_message", user_msg)
|
|
scope.set_extra("error_snippet", code_msg)
|
|
sentry_sdk.capture_message("user_message", level)
|
|
|
|
|
|
def capture_exception(e) -> None:
|
|
"""Captures a runtime exception object to pass forward to sentry.
|
|
|
|
Useful to ensure the function overall continues running, while still
|
|
capturing errors around sensitive sections such as IO operations.
|
|
Otherwise, functions are generally covered by their corresponding handler
|
|
decorator.
|
|
"""
|
|
err = traceback.format_exc()
|
|
print(err) # Always print raw traceback.
|
|
if not IS_OPTED_IN:
|
|
return
|
|
sentry_sdk.capture_exception(e)
|
|
|
|
|
|
message_cache = {}
|
|
|
|
|
|
def capture_message(message: str,
|
|
code_msg: str = None,
|
|
level: str = 'fatal',
|
|
max_reports: int = 10
|
|
) -> None:
|
|
"""Send a message to sentry.io outside the context of an exception.
|
|
|
|
Message is the generalized, issue-grouping name while code_msg is specific
|
|
to this call. Valid levels are: info, error, fatal.
|
|
"""
|
|
|
|
global message_cache
|
|
|
|
print("Message with {} status: {}, {}".format(level, message, code_msg))
|
|
if not IS_OPTED_IN:
|
|
return
|
|
|
|
if message not in message_cache:
|
|
message_cache[message] = 0
|
|
|
|
message_cache[message] += 1
|
|
|
|
if max_reports > 0 and message_cache[message] > max_reports:
|
|
return
|
|
|
|
with sentry_sdk.push_scope() as scope:
|
|
scope.set_extra("error_snippet", code_msg)
|
|
sentry_sdk.capture_message(message, level)
|
|
|
|
|
|
def _sanitize_paths(msg: str):
|
|
"""Strip out long userpaths from strings and replace with short name.
|
|
|
|
Also cuts out the second and third lines, which are wrapper calls.
|
|
"""
|
|
nth_newline = 0
|
|
first_newline = None
|
|
for ind in range(len(msg)):
|
|
if msg[ind] in ["\n", "\r"]:
|
|
if first_newline is None:
|
|
first_newline = ind
|
|
nth_newline += 1
|
|
if nth_newline == 3:
|
|
if len(msg) > ind + 1:
|
|
msg = msg[:first_newline] + '\n' + msg[ind + 1:]
|
|
break
|
|
|
|
# Normalizes backslashes for better detecting subpaths in strings,
|
|
# which can include double escaped strings.
|
|
msg = msg.replace(r'\\', '/').replace(r'\\\\', '/')
|
|
try:
|
|
return re.sub(
|
|
# case insensitive match: File "C:/path/.." or File "/path/.."
|
|
r'(?i)File "([a-z]:){0,1}[/\\]{1,2}.*[/\\]{1,2}',
|
|
'File "<script_path>/',
|
|
str(msg))
|
|
except Exception as err:
|
|
print(err)
|
|
return msg
|
|
|
|
|
|
def _start_session() -> None:
|
|
"""Manually start session tracking, even if there is an existing session.
|
|
|
|
We are not actually tracking overall application sessions, but instead use
|
|
a session to wrap around an individual operator transaction in order
|
|
to calculate error-free users and operations.
|
|
|
|
Intended to only start if the user performs an action (operator).
|
|
|
|
See source reference context manager in sessions.py:
|
|
def auto_session_tracking
|
|
|
|
https://docs.sentry.io/platforms/python/configuration/draining/
|
|
"""
|
|
if not IS_OPTED_IN:
|
|
return
|
|
hub = sentry_sdk.Hub.current
|
|
|
|
# start_session technically takes care of ending existing ones.
|
|
hub.start_session(session_mode="application")
|
|
|
|
|
|
def _end_session() -> None:
|
|
"""Manually end the session on behalf of sentry for opt out or app exit.
|
|
|
|
See source reference context manager in sessions.py:
|
|
def auto_session_tracking
|
|
"""
|
|
if not IS_OPTED_IN:
|
|
return
|
|
hub = sentry_sdk.Hub.current
|
|
hub.end_session()
|
|
|
|
|
|
def _set_session_crashed() -> None:
|
|
"""Force the session to appear as crashed.
|
|
|
|
Had to pull this from lower level sources of the sentry lib to assign, see
|
|
Hub.py's start_session function for partial reference, and session.py
|
|
for updating aspects of the session variable itself.
|
|
"""
|
|
hub = sentry_sdk.Hub.current
|
|
client, scope = hub._stack[-1]
|
|
if scope._session:
|
|
scope._session.update(status="crashed")
|
|
else:
|
|
# This shouldn't happen, but would occur if session was explicitly
|
|
# ended before execution actually finished (such as from nested ops).
|
|
# Ensure we capture crashed sessions for accurate representation.
|
|
capture_message("session_already_ended", 'fatal')
|
|
_start_session()
|
|
client, scope = hub._stack[-1]
|
|
try:
|
|
scope._session.update(status="crashed")
|
|
except Exception as err:
|
|
print("Failed to get scope session with error:")
|
|
print(err)
|
|
|
|
|
|
def _flush() -> None:
|
|
hub = sentry_sdk.Hub.current
|
|
hub.flush()
|
|
|
|
|
|
def register(
|
|
*,
|
|
software_name,
|
|
software_version,
|
|
tool_version,
|
|
env,
|
|
error_rate,
|
|
transaction_rate):
|
|
toolv = "P4B@" + tool_version # Make version unique across sentry org.
|
|
initialize_sentry(
|
|
software_name,
|
|
software_version,
|
|
toolv,
|
|
env=env,
|
|
error_rate=error_rate,
|
|
transaction_rate=transaction_rate)
|
|
|
|
|
|
def unregister():
|
|
if IS_OPTED_IN:
|
|
_flush()
|