Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@ After completing the installation steps above, you can immediately test the syst
python hello_world.py
```

### Crash reporting

If the plain2code client crashes unexpectedly, it sends an anonymous crash report to Codeplain to help improve the tool. To disable crash reporting, set the `CODEPLAIN_NO_TELEMETRY=1` flag.


## Releasing

Releases are built and published with [uv](https://docs.astral.sh/uv/). The version is read from `_version.py`.
Expand Down
51 changes: 28 additions & 23 deletions plain2code.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,35 @@
get_log_file_path,
)
from plain2code_state import RunState
from plain2code_telemetry import capture_crash, initialize_telemetry
from system_config import system_config
from tui.plain2code_tui import Plain2CodeTUI
from tui.plain_module_render_choice_tui import PlainModuleRenderChoiceTUI

DEFAULT_TEMPLATE_DIRS = importlib.resources.files("standard_template_library")
RENDER_THREAD_SHUTDOWN_TIMEOUT = 0.7

# Exceptions that represent expected, user-facing error conditions. They are
# reported to the user directly and must never be sent to Sentry as crashes.
EXPECTED_EXCEPTIONS = (
InvalidFridArgument,
FileNotFoundError,
MissingResource,
TemplateNotFoundError,
PlainSyntaxError,
MissingPreviousFunctionalitiesError,
MissingAPIKey,
InvalidAPIKey,
OutdatedClientVersion,
ConflictingRequirements,
RenderingCreditBalanceTooLow,
NetworkConnectionError,
ModuleDoesNotExistError,
UnsupportedResourceType,
GitNotInstalledError,
SystemExit,
)


def setup_logging(
args,
Expand Down Expand Up @@ -324,6 +346,8 @@ def main(): # noqa: C901
args, event_bus, run_state, args.log_to_file, args.log_file_name, args.filename, args.headless
)

initialize_telemetry()

exc_info = None
error_message = None

Expand All @@ -340,36 +364,17 @@ def main(): # noqa: C901
else:
error_message = str(e) if str(e) else repr(e)

if not isinstance(
e,
(
InvalidFridArgument,
FileNotFoundError,
MissingResource,
TemplateNotFoundError,
PlainSyntaxError,
MissingPreviousFunctionalitiesError,
MissingAPIKey,
InvalidAPIKey,
OutdatedClientVersion,
ConflictingRequirements,
RenderingCreditBalanceTooLow,
NetworkConnectionError,
ModuleDoesNotExistError,
UnsupportedResourceType,
GitNotInstalledError,
),
):
if not isinstance(e, EXPECTED_EXCEPTIONS):
exc_info = sys.exc_info()
finally:
if exc_info:
dump_crash_logs(args, run_state)
capture_crash(exc_info, run_state, args)
print_exit_summary(
run_state,
args.filename,
error_message=error_message,
)
if exc_info:
# Log traceback
dump_crash_logs(args, run_state)

if args.headless and (exc_info is not None or not run_state.render_succeeded):
sys.exit(1)
Expand Down
4 changes: 4 additions & 0 deletions plain2code_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ def __init__(self, spec_filename: str, replay_with: Optional[str] = None):
self.unittest_batch_id: int = 0
self.render_time_accumulated: int = 0
self.last_render_start_timestamp: float = time.monotonic()
# Mirrored from the render state machine for crash reporting; not authoritative.
self.current_module: Optional[str] = None
self.current_frid: Optional[str] = None
self.current_render_state: Optional[str] = None

def increment_call_count(self):
self.call_count += 1
Expand Down
114 changes: 114 additions & 0 deletions plain2code_telemetry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"""Crash reporting via Sentry.

Only unexpected exceptions are reported (the caller decides which exceptions are
expected; see EXPECTED_EXCEPTIONS in plain2code.py). Reporting is on by default
and can be disabled by setting the CODEPLAIN_NO_TELEMETRY environment variable
to any non-empty value.
"""

import os
from typing import Any, Optional

import sentry_sdk
from sentry_sdk.integrations.atexit import AtexitIntegration
from sentry_sdk.integrations.dedupe import DedupeIntegration
from sentry_sdk.integrations.modules import ModulesIntegration
from sentry_sdk.scrubber import DEFAULT_DENYLIST, EventScrubber

from plain2code_state import RunState
from system_config import system_config

SENTRY_DSN = "https://64d0d86b50b34e2dede3e4eaf5142282@o4510793955934208.ingest.us.sentry.io/4511540621213696"

NO_TELEMETRY_ENV_VAR = "CODEPLAIN_NO_TELEMETRY"
ENVIRONMENT_ENV_VAR = "CODEPLAIN_ENV"
DEFAULT_ENVIRONMENT = "production"

FLUSH_TIMEOUT_SECONDS = 2

# Local variable names whose values may contain proprietary spec or generated
# code content and must be scrubbed from stack traces (extends Sentry's
# default denylist, which already covers api_key, auth, secrets etc.).
# "headers" and "x-api-key" cover the request headers local in
# codeplain_REST_api.post_request; the default denylist only has the
# underscore form "x_api_key".
SCRUB_DENYLIST = DEFAULT_DENYLIST + [
"authorization",
"headers",
"x-api-key",
"plain_source",
"plain_source_tree",
"full_plain_source",
"existing_files_content",
"file_content",
"files_content",
"content",
"source",
"response_json",
"payload",
]


def telemetry_enabled() -> bool:
"""Return True if crash reporting should be active."""
if os.environ.get(NO_TELEMETRY_ENV_VAR):
return False
return True


def initialize_telemetry(**init_overrides: Any) -> bool:
"""Initialize Sentry crash reporting. Returns True if initialized."""
if not telemetry_enabled():
return False

try:
init_kwargs: dict[str, Any] = dict(
dsn=SENTRY_DSN,
release=system_config.client_version,
environment=os.environ.get(ENVIRONMENT_ENV_VAR, DEFAULT_ENVIRONMENT),
send_default_pii=False,
server_name="", # hostname is identifying; don't send it
default_integrations=False,
auto_enabling_integrations=False,
integrations=[
AtexitIntegration(callback=lambda pending, timeout: None),
DedupeIntegration(),
ModulesIntegration(),
],
include_local_variables=True,
event_scrubber=EventScrubber(denylist=SCRUB_DENYLIST, recursive=True),
shutdown_timeout=FLUSH_TIMEOUT_SECONDS,
)
init_kwargs.update(init_overrides)
sentry_sdk.init(**init_kwargs)
return True
except Exception:
return False


def capture_crash(exc_info, run_state: Optional[RunState], args) -> bool:
"""Report an unexpected crash to Sentry. Returns True if an event was sent."""
if not telemetry_enabled():
return False

try:
with sentry_sdk.new_scope() as scope:
if run_state is not None:
scope.set_tag("render_id", run_state.render_id)
scope.set_tag("render_state", run_state.current_render_state)
scope.set_tag("current_module", run_state.current_module)
scope.set_tag("current_frid", run_state.current_frid)
scope.set_tag("headless", bool(getattr(args, "headless", False)))
scope.set_tag("unittests_script_provided", bool(getattr(args, "unittests_script", None)))
scope.set_tag("conformance_tests_script_provided", bool(getattr(args, "conformance_tests_script", None)))
scope.set_tag(
"prepare_environment_script_provided", bool(getattr(args, "prepare_environment_script", None))
)
Comment thread
hisenb3rg marked this conversation as resolved.

event_id = sentry_sdk.capture_exception(exc_info[1])

sentry_sdk.flush(timeout=FLUSH_TIMEOUT_SECONDS)
return event_id is not None
except Exception:
# Telemetry must never break the CLI or mask the original crash.
return False
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ dependencies = [
"rich==15.0.0",
"python-frontmatter==1.3.0",
"networkx==3.6.1",
"sentry-sdk==2.62.0",
]

[project.optional-dependencies]
Expand Down
2 changes: 2 additions & 0 deletions render_machine/code_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ def __init__(self, render_context: RenderContext):
def run(self):
"""Execute the main rendering workflow."""
self.render_context.event_bus.publish(RenderModuleStarted(module_name=self.render_context.module_name))
self.render_context.run_state.current_module = self.render_context.module_name
previous_action_payload = None
previous_state = None

Expand All @@ -63,6 +64,7 @@ def run(self):
)
)
previous_state = deepcopy(self.render_context.state)
self.render_context.run_state.current_render_state = self.render_context.state
self.render_context.script_execution_history.should_update_script_outputs = False

self.render_context.previous_action_payload = previous_action_payload
Expand Down
1 change: 1 addition & 0 deletions render_machine/render_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ def start_implementing_frid(self):
linked_resources=linked_resources,
functional_requirement_render_attempts=0,
)
self.run_state.current_frid = frid
return

def check_frid_iteration_limit(self):
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ pytest==8.3.4
textual>=7.5.0
networkx==3.6.1
transitions==0.9.3
sentry-sdk==2.62.0


# Development dependencies
Expand Down
Loading
Loading