11import code
22import contextlib
3+ import hashlib
4+ import os
35import pdb
6+ import platform
47import runpy
58import sys
69import traceback
710
811import click
912import requests
1013import rich
14+ import sentry_sdk
1115from rich .console import Console
1216from rich .markup import escape
1317
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+
38160SUGGEST_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
237427cli .add_command (error )
0 commit comments