Skip to content

Commit f614553

Browse files
authored
DEVOPS-777 feat: add Sentry error tracking and telemetry (#14)
* DEVOPS-776 fix: suppress pkg_resources deprecation warning and replace fs.path with os.path in GitHubSource * DEVOPS-776 fix: add assertion to ensure commit is not None in fetch method of GitHubSource * DEVOPS-776 fix: raise RuntimeError if commit is None in fetch method of GitHubSource * DEVOPS-777 feat: integrate Sentry for error tracking and telemetry * DEVOPS-777 feat: add telemetry command to display telemetry status and collected data * DEVOPS-777 docs: add telemetry section to environment variable reference with configuration details * DEVOPS-777 fix: handle invalid Sentry DSN gracefully Guard against invalid DSN values in init_sentry() to prevent CLI from crashing when telemetry is enabled but SENTRY_DSN is misconfigured. Now prints a warning and disables telemetry instead of raising an exception. * DEVOPS-777 chore(deps): update sentry-sdk dependency version to 2.8.0
1 parent c03210f commit f614553

5 files changed

Lines changed: 517 additions & 0 deletions

File tree

cumulusci/cli/cci.py

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
import code
22
import contextlib
3+
import hashlib
4+
import os
35
import pdb
6+
import platform
47
import runpy
58
import sys
69
import traceback
710

811
import click
912
import requests
1013
import rich
14+
import sentry_sdk
1115
from rich.console import Console
1216
from rich.markup import escape
1317

@@ -35,6 +39,124 @@
3539
warn_if_no_long_paths,
3640
)
3741

42+
SENTRY_DSN = "https://774d755112e0f997c8d6650052dc057b@o98429.ingest.us.sentry.io/4510699827691520"
43+
44+
45+
def _get_sentry_environment():
46+
"""Determine Sentry environment based on version or env var.
47+
48+
Returns 'development' for dev/local builds, 'production' for releases.
49+
Can be overridden with CCI_ENVIRONMENT env var.
50+
"""
51+
if env := os.environ.get("CCI_ENVIRONMENT"):
52+
return env
53+
54+
version = cumulusci.__version__
55+
# Dev versions contain 'dev', 'alpha', 'beta', 'rc', or 'unknown'
56+
if any(tag in version.lower() for tag in ("dev", "alpha", "beta", "rc", "unknown")):
57+
return "development"
58+
return "production"
59+
60+
61+
def _get_anonymous_user_id():
62+
"""Generate an anonymous user ID based on machine identifier.
63+
64+
Uses a hash of stable machine identifiers to create a unique
65+
but non-identifiable user ID for error grouping.
66+
"""
67+
# Combine stable system identifiers for consistent hashing
68+
# platform.node() = hostname, platform.machine() = arch,
69+
# platform.processor() = processor info
70+
machine_id = f"{platform.node()}-{platform.machine()}-{platform.processor()}"
71+
return hashlib.sha256(machine_id.encode()).hexdigest()[:16]
72+
73+
74+
def _detect_ci_environment():
75+
"""Detect which CI environment CumulusCI is running in, if any."""
76+
if os.environ.get("GITHUB_ACTIONS") == "true":
77+
return "github_actions"
78+
elif os.environ.get("CIRCLECI"):
79+
return "circleci"
80+
elif os.environ.get("GITLAB_CI"):
81+
return "gitlab"
82+
elif os.environ.get("JENKINS_URL") or os.environ.get("JENKINS_HOME"):
83+
return "jenkins"
84+
elif os.environ.get("BITBUCKET_PIPELINES"):
85+
return "bitbucket"
86+
elif os.environ.get("TF_BUILD"):
87+
return "azure_devops"
88+
elif os.environ.get("CI"):
89+
return "unknown_ci"
90+
return None
91+
92+
93+
def _set_sentry_user_context():
94+
"""Set anonymized user context for Sentry.
95+
96+
Sets anonymous user ID and OS/device context using Sentry's recognized structure.
97+
No PII is collected.
98+
"""
99+
sentry_sdk.set_user({"id": _get_anonymous_user_id()})
100+
101+
# Use Sentry's recognized OS context structure
102+
sentry_sdk.set_context(
103+
"os",
104+
{
105+
"name": platform.system(),
106+
"version": platform.release(),
107+
"build": platform.version(),
108+
},
109+
)
110+
111+
# Use Sentry's recognized device context for architecture
112+
sentry_sdk.set_context(
113+
"device",
114+
{
115+
"arch": platform.machine(),
116+
},
117+
)
118+
119+
# CI environment detection
120+
if ci_env := _detect_ci_environment():
121+
sentry_sdk.set_tag("ci", ci_env)
122+
123+
124+
def init_sentry():
125+
"""Initialize Sentry error tracking.
126+
127+
Telemetry is OFF by default. To enable, set CCI_ENABLE_TELEMETRY=1 in environment.
128+
You can also override the DSN with SENTRY_DSN or environment with CCI_ENVIRONMENT.
129+
"""
130+
if os.environ.get("CCI_ENABLE_TELEMETRY", "").lower() not in ("1", "true", "yes"):
131+
return
132+
133+
dsn = os.environ.get("SENTRY_DSN", SENTRY_DSN)
134+
if not dsn:
135+
return
136+
137+
try:
138+
sentry_sdk.init(
139+
dsn=dsn,
140+
release=cumulusci.__version__,
141+
environment=_get_sentry_environment(),
142+
send_default_pii=False,
143+
attach_stacktrace=True,
144+
max_breadcrumbs=50,
145+
)
146+
except Exception as e:
147+
# Invalid DSN or other init error - disable telemetry gracefully
148+
# Don't crash the CLI just because telemetry configuration is wrong
149+
import sys
150+
151+
print(
152+
f"Warning: Failed to initialize telemetry: {e}. Telemetry disabled.",
153+
file=sys.stderr,
154+
)
155+
return
156+
157+
_set_sentry_user_context()
158+
159+
38160
SUGGEST_ERROR_COMMAND = (
39161
"""Run this command for more information about debugging errors: cci error --help"""
40162
)
@@ -52,6 +174,9 @@ def main(args=None):
52174
53175
This wraps the `click` library in order to do some initialization and centralized error handling.
54176
"""
177+
# Initialize Sentry early to capture any errors during startup
178+
init_sentry()
179+
55180
with contextlib.ExitStack() as stack:
56181
args = args or sys.argv
57182

@@ -79,6 +204,9 @@ def main(args=None):
79204
try:
80205
runtime = CliRuntime(load_keychain=False)
81206
except Exception as e:
207+
# Capture to Sentry (for non-usage errors)
208+
if not isinstance(e, USAGE_ERRORS):
209+
sentry_sdk.capture_exception(e)
82210
handle_exception(e, is_error_command, tempfile_path, debug)
83211
sys.exit(1)
84212

@@ -94,6 +222,10 @@ def main(args=None):
94222
show_debug_info() if debug else console.print("\n[red bold]Aborted!")
95223
sys.exit(1)
96224
except Exception as e:
225+
# Capture to Sentry regardless of debug mode (for non-usage errors)
226+
if not isinstance(e, USAGE_ERRORS):
227+
sentry_sdk.capture_exception(e)
228+
97229
if debug:
98230
show_debug_info()
99231
else:
@@ -232,6 +364,64 @@ def shell(runtime, script=None, python=None):
232364
code.interact(local=variables)
233365

234366

367+
@cli.command(name="telemetry", help="Show telemetry status and what data would be collected")
368+
def telemetry():
369+
"""Display telemetry configuration and data that would be collected."""
370+
console = rich.get_console()
371+
372+
# Check if telemetry is enabled
373+
telemetry_enabled = os.environ.get("CCI_ENABLE_TELEMETRY", "").lower() in (
374+
"1",
375+
"true",
376+
"yes",
377+
)
378+
379+
console.print()
380+
if telemetry_enabled:
381+
console.print("[green bold]Telemetry is ENABLED[/green bold]")
382+
else:
383+
console.print("[yellow bold]Telemetry is DISABLED (default)[/yellow bold]")
384+
console.print(
385+
"To enable telemetry, set: [cyan]export CCI_ENABLE_TELEMETRY=1[/cyan]"
386+
)
387+
388+
console.print()
389+
console.print("[bold]Data that would be collected:[/bold]")
390+
console.print()
391+
392+
# Show what would be collected
393+
console.print(f" [dim]CumulusCI Version:[/dim] {cumulusci.__version__}")
394+
console.print(f" [dim]Environment:[/dim] {_get_sentry_environment()}")
395+
console.print(f" [dim]Anonymous User ID:[/dim] {_get_anonymous_user_id()}")
396+
console.print()
397+
console.print(" [dim]OS Context:[/dim]")
398+
console.print(f" [dim]Name:[/dim] {platform.system()}")
399+
console.print(f" [dim]Version:[/dim] {platform.release()}")
400+
console.print(f" [dim]Build:[/dim] {platform.version()}")
401+
console.print()
402+
console.print(" [dim]Device Context:[/dim]")
403+
console.print(f" [dim]Architecture:[/dim] {platform.machine()}")
404+
405+
ci_env = _detect_ci_environment()
406+
if ci_env:
407+
console.print()
408+
console.print(f" [dim]CI Environment:[/dim] {ci_env}")
409+
410+
console.print()
411+
console.print("[bold]Data NOT collected:[/bold]")
412+
console.print(" - Salesforce credentials or tokens")
413+
console.print(" - Org data or metadata")
414+
console.print(" - Project-specific configuration")
415+
console.print(" - File contents or paths")
416+
console.print(" - Personal information")
417+
console.print()
418+
console.print(
419+
"For more information, see: "
420+
"[link=https://claritisoftware.github.io/CumulusCI/env-var-reference.html#telemetry]"
421+
"https://claritisoftware.github.io/CumulusCI/env-var-reference.html#telemetry[/link]"
422+
)
423+
424+
235425
# Top Level Groups
236426

237427
cli.add_command(error)

0 commit comments

Comments
 (0)