Repository: gruntwork-io/terragrunt (language: go)

Issue:
Log code refactoring
## Describe the bug

1. Clean up logs, get rid of global variables so that tests run in parallel.
2. Use variable parameters to create new loggers.
3. Make the log package reusable for the engine project as well.

## Versions

- Terragrunt version: v0.67.0



Discussion / hints:

Fix patch:
diff --git a/cli/app.go b/cli/app.go
index 52470dfb31..3eeb8719ae 100644
--- a/cli/app.go
+++ b/cli/app.go
@@ -4,14 +4,15 @@ import (
 	"context"
 	goerrors "errors"
 	"fmt"
-	"io"
 	"os"
 	"path/filepath"
 	"sort"
 	"time"
 
 	"github.com/gruntwork-io/terragrunt/engine"
-
+	"github.com/gruntwork-io/terragrunt/pkg/log"
+	"github.com/gruntwork-io/terragrunt/pkg/log/format"
+	"github.com/gruntwork-io/terragrunt/pkg/log/hooks"
 	"github.com/gruntwork-io/terragrunt/telemetry"
 	"github.com/gruntwork-io/terragrunt/terraform"
 	"golang.org/x/sync/errgroup"
@@ -43,7 +44,6 @@ import (
 	terraformCmd "github.com/gruntwork-io/terragrunt/cli/commands/terraform"
 	terragruntinfo "github.com/gruntwork-io/terragrunt/cli/commands/terragrunt-info"
 	validateinputs "github.com/gruntwork-io/terragrunt/cli/commands/validate-inputs"
-	"github.com/gruntwork-io/terragrunt/internal/log"
 	"github.com/gruntwork-io/terragrunt/options"
 	"github.com/gruntwork-io/terragrunt/pkg/cli"
 )
@@ -59,21 +59,18 @@ func init() {
 
 type App struct {
 	*cli.App
+	opts *options.TerragruntOptions
 }
 
 // NewApp creates the Terragrunt CLI App.
-func NewApp(writer io.Writer, errWriter io.Writer) *App {
-	opts := options.NewTerragruntOptions()
-	opts.Writer = writer
-	opts.ErrWriter = errWriter
-
+func NewApp(opts *options.TerragruntOptions) *App {
 	app := cli.NewApp()
 	app.Name = "terragrunt"
 	app.Usage = "Terragrunt is a flexible orchestration tool that allows Infrastructure as Code written in OpenTofu/Terraform to scale. For documentation, see https://terragrunt.gruntwork.io/."
 	app.Author = "Gruntwork <www.gruntwork.io>"
 	app.Version = version.GetVersion()
-	app.Writer = writer
-	app.ErrWriter = errWriter
+	app.Writer = opts.Writer
+	app.ErrWriter = opts.ErrWriter
 
 	app.Flags = append(
 		commands.NewGlobalFlags(opts),
@@ -87,7 +84,7 @@ func NewApp(writer io.Writer, errWriter io.Writer) *App {
 	app.DefaultCommand = terraformCmd.NewCommand(opts).WrapAction(WrapWithTelemetry(opts)) // by default, if no terragrunt command is specified, run the Terraform command
 	app.OsExiter = OSExiter
 
-	return &App{app}
+	return &App{app, opts}
 }
 
 func (app *App) Run(args []string) error {
@@ -99,16 +96,16 @@ func (app *App) RunContext(ctx context.Context, args []string) error {
 	defer cancel()
 
 	shell.RegisterSignalHandler(func(signal os.Signal) {
-		log.Infof("%s signal received. Gracefully shutting down... (it can take up to %v)", cases.Title(language.English).String(signal.String()), shell.SignalForwardingDelay)
+		app.opts.Logger.Infof("%s signal received. Gracefully shutting down... (it can take up to %v)", cases.Title(language.English).String(signal.String()), shell.SignalForwardingDelay)
 		cancel()
 
 		shell.RegisterSignalHandler(func(signal os.Signal) {
-			log.Infof("Second %s signal received, force shutting down...", cases.Title(language.English).String(signal.String()))
+			app.opts.Logger.Infof("Second %s signal received, force shutting down...", cases.Title(language.English).String(signal.String()))
 			os.Exit(1)
 		})
 
 		time.Sleep(forceExitInterval)
-		log.Infof("Failed to gracefully shutdown within %v, force shutting down...", forceExitInterval)
+		app.opts.Logger.Infof("Failed to gracefully shutdown within %v, force shutting down...", forceExitInterval)
 		os.Exit(1)
 	}, shell.InterruptSignals...)
 
@@ -283,19 +280,6 @@ func initialSetup(cliCtx *cli.Context, opts *options.TerragruntOptions) error {
 
 	opts.Env = env.Parse(os.Environ())
 
-	// --- Logger
-	if opts.DisableLogColors {
-		util.DisableLogColors()
-	}
-
-	if opts.DisableLogFormatting {
-		util.DisableLogFormatting()
-	}
-
-	if opts.JsonLogFormat {
-		util.JsonFormat()
-	}
-
 	// --- Working Dir
 	if opts.WorkingDir == "" {
 		currentDir, err := os.Getwd()
@@ -313,8 +297,19 @@ func initialSetup(cliCtx *cli.Context, opts *options.TerragruntOptions) error {
 		return errors.WithStackTrace(err)
 	}
 
+	opts.Logger = opts.Logger.WithField(format.PrefixKeyName, workingDir)
+
 	opts.RootWorkingDir = filepath.ToSlash(workingDir)
 
+	if !opts.LogShowAbsPaths {
+		hook, err := hooks.NewRelativePathHook(opts.RootWorkingDir)
+		if err != nil {
+			return err
+		}
+
+		opts.Logger.SetOptions(log.WithHooks(hook))
+	}
+
 	// --- Download Dir
 	if opts.DownloadDir == "" {
 		opts.DownloadDir = util.JoinPath(opts.WorkingDir, util.TerragruntCacheDir)
@@ -327,12 +322,6 @@ func initialSetup(cliCtx *cli.Context, opts *options.TerragruntOptions) error {
 
 	opts.DownloadDir = filepath.ToSlash(downloadDir)
 
-	opts.LogLevel = util.ParseLogLevel(opts.LogLevelStr)
-	opts.Logger = util.CreateLogEntry("", opts.LogLevel, nil, opts.DisableLogColors, opts.DisableLogFormatting)
-	opts.Logger.Logger.SetOutput(cliCtx.App.ErrWriter)
-
-	log.SetLogger(opts.Logger.Logger)
-
 	// --- Terragrunt ConfigPath
 	if opts.TerragruntConfigPath == "" {
 		opts.TerragruntConfigPath = config.GetDefaultConfigPath(opts.WorkingDir)
@@ -345,11 +334,6 @@ func initialSetup(cliCtx *cli.Context, opts *options.TerragruntOptions) error {
 		return errors.WithStackTrace(err)
 	}
 
-	opts.RelativeTerragruntConfigPath, err = util.GetPathRelativeToWithSeparator(opts.TerragruntConfigPath, opts.RootWorkingDir)
-	if err != nil {
-		return err
-	}
-
 	opts.TerraformPath = filepath.ToSlash(opts.TerraformPath)
 
 	opts.ExcludeDirs, err = util.GlobCanonicalPath(opts.WorkingDir, opts.ExcludeDirs...)
diff --git a/cli/commands/catalog/action.go b/cli/commands/catalog/action.go
index d8d23b87d1..3125d99e85 100644
--- a/cli/commands/catalog/action.go
+++ b/cli/commands/catalog/action.go
@@ -10,7 +10,6 @@ import (
 	"github.com/gruntwork-io/terragrunt/cli/commands/catalog/module"
 	"github.com/gruntwork-io/terragrunt/cli/commands/catalog/tui"
 	"github.com/gruntwork-io/terragrunt/config"
-	"github.com/gruntwork-io/terragrunt/internal/log"
 	"github.com/gruntwork-io/terragrunt/options"
 	"github.com/gruntwork-io/terragrunt/util"
 )
@@ -40,7 +39,7 @@ func Run(ctx context.Context, opts *options.TerragruntOptions, repoURL string) e
 	for _, repoURL := range repoURLs {
 		tempDir := filepath.Join(os.TempDir(), fmt.Sprintf(tempDirFormat, util.EncodeBase64Sha1(repoURL)))
 
-		repo, err := module.NewRepo(ctx, repoURL, tempDir)
+		repo, err := module.NewRepo(ctx, opts.Logger, repoURL, tempDir)
 		if err != nil {
 			return err
 		}
@@ -50,7 +49,7 @@ func Run(ctx context.Context, opts *options.TerragruntOptions, repoURL string) e
 			return err
 		}
 
-		log.Infof("Found %d modules in repository %q", len(repoModules), repoURL)
+		opts.Logger.Infof("Found %d modules in repository %q", len(repoModules), repoURL)
 
 		modules = append(modules, repoModules...)
 	}
diff --git a/cli/commands/catalog/module/module.go b/cli/commands/catalog/module/module.go
index 909c9e19d2..c26d7a31f3 100644
--- a/cli/commands/catalog/module/module.go
+++ b/cli/commands/catalog/module/module.go
@@ -6,7 +6,7 @@ import (
 
 	"github.com/gruntwork-io/go-commons/collections"
 	"github.com/gruntwork-io/go-commons/errors"
-	"github.com/gruntwork-io/terragrunt/internal/log"
+	"github.com/gruntwork-io/terragrunt/pkg/log"
 )
 
 const (
@@ -22,6 +22,7 @@ var (
 type Modules []*Module
 
 type Module struct {
+	*Repo
 	*Doc
 
 	cloneURL  string
@@ -33,6 +34,7 @@ type Module struct {
 // NewModule returns a module instance if the given `moduleDir` path contains a Terraform module, otherwise returns nil.
 func NewModule(repo *Repo, moduleDir string) (*Module, error) {
 	module := &Module{
+		Repo:      repo,
 		cloneURL:  repo.cloneURL,
 		repoPath:  repo.path,
 		moduleDir: moduleDir,
@@ -42,7 +44,7 @@ func NewModule(repo *Repo, moduleDir string) (*Module, error) {
 		return nil, err
 	}
 
-	log.Debugf("Found module in directory %q", moduleDir)
+	repo.logger.Debugf("Found module in directory %q", moduleDir)
 
 	moduleURL, err := repo.ModuleURL(moduleDir)
 	if err != nil {
@@ -63,6 +65,10 @@ func NewModule(repo *Repo, moduleDir string) (*Module, error) {
 	return module, nil
 }
 
+func (module *Module) Logger() log.Logger {
+	return module.logger
+}
+
 // FilterValue implements /github.com/charmbracelet/bubbles.list.Item.FilterValue
 func (module *Module) FilterValue() string {
 	return module.Title()
diff --git a/cli/commands/catalog/module/repo.go b/cli/commands/catalog/module/repo.go
index d94353c946..9448c8dc60 100644
--- a/cli/commands/catalog/module/repo.go
+++ b/cli/commands/catalog/module/repo.go
@@ -11,7 +11,7 @@ import (
 	"github.com/gitsight/go-vcsurl"
 	"github.com/gruntwork-io/go-commons/errors"
 	"github.com/gruntwork-io/go-commons/files"
-	"github.com/gruntwork-io/terragrunt/internal/log"
+	"github.com/gruntwork-io/terragrunt/pkg/log"
 	"github.com/gruntwork-io/terragrunt/terraform"
 	"github.com/hashicorp/go-getter"
 	"gopkg.in/ini.v1"
@@ -32,6 +32,8 @@ var (
 )
 
 type Repo struct {
+	logger log.Logger
+
 	cloneURL string
 	path     string
 
@@ -39,8 +41,9 @@ type Repo struct {
 	branchName string
 }
 
-func NewRepo(ctx context.Context, cloneURL, tempDir string) (*Repo, error) {
+func NewRepo(ctx context.Context, logger log.Logger, cloneURL, tempDir string) (*Repo, error) {
 	repo := &Repo{
+		logger:   logger,
 		cloneURL: cloneURL,
 		path:     tempDir,
 	}
@@ -152,7 +155,7 @@ func (repo *Repo) clone(ctx context.Context) error {
 				return errors.WithStackTrace(err)
 			}
 
-			log.Debugf("Converting relative path %q to absolute %q", repoPath, absRepoPath)
+			repo.logger.Debugf("Converting relative path %q to absolute %q", repoPath, absRepoPath)
 		}
 
 		repo.path = repoPath
@@ -175,7 +178,7 @@ func (repo *Repo) clone(ctx context.Context) error {
 	// For example, in MacOS the service is responsible for deleting unused files deletes only files while leaving the directory structure is untouched, which in turn misleads `go-getter`, which thinks that the repository exists but cannot update it due to the lack of files. In such cases, we simply delete the temporary directory in order to clone the one again.
 	// See https://github.com/gruntwork-io/terragrunt/pull/2888
 	if files.FileExists(repo.path) && !files.FileExists(repo.gitHeadfile()) {
-		log.Debugf("The repo dir exists but git file %q does not. Removing the repo dir for cloning from the remote source.", repo.gitHeadfile())
+		repo.logger.Debugf("The repo dir exists but git file %q does not. Removing the repo dir for cloning from the remote source.", repo.gitHeadfile())
 
 		if err := os.RemoveAll(repo.path); err != nil {
 			return errors.WithStackTrace(err)
@@ -189,7 +192,7 @@ func (repo *Repo) clone(ctx context.Context) error {
 
 	repo.cloneURL = sourceUrl.String()
 
-	log.Infof("Cloning repository %q to temporary directory %q", repo.cloneURL, repo.path)
+	repo.logger.Infof("Cloning repository %q to temporary directory %q", repo.cloneURL, repo.path)
 
 	if err := getter.Get(repo.path, strings.Trim(sourceUrl.String(), "/"), getter.WithContext(ctx)); err != nil {
 		return errors.WithStackTrace(err)
@@ -206,7 +209,7 @@ func (repo *Repo) parseRemoteURL() error {
 		return errors.Errorf("the specified path %q is not a git repository", repo.path)
 	}
 
-	log.Debugf("Parsing git config %q", gitConfigPath)
+	repo.logger.Debugf("Parsing git config %q", gitConfigPath)
 
 	inidata, err := ini.Load(gitConfigPath)
 	if err != nil {
@@ -233,7 +236,7 @@ func (repo *Repo) parseRemoteURL() error {
 	}
 
 	repo.remoteURL = inidata.Section(sectionName).Key("url").String()
-	log.Debugf("Remote url: %q for repo: %q", repo.remoteURL, repo.path)
+	repo.logger.Debugf("Remote url: %q for repo: %q", repo.remoteURL, repo.path)
 
 	return nil
 }
diff --git a/cli/commands/catalog/tui/command/scaffold.go b/cli/commands/catalog/tui/command/scaffold.go
index eec83ba149..6464097b34 100644
--- a/cli/commands/catalog/tui/command/scaffold.go
+++ b/cli/commands/catalog/tui/command/scaffold.go
@@ -7,7 +7,6 @@ import (
 	"github.com/gruntwork-io/terragrunt/cli/commands/catalog/module"
 
 	"github.com/gruntwork-io/terragrunt/cli/commands/scaffold"
-	"github.com/gruntwork-io/terragrunt/internal/log"
 	"github.com/gruntwork-io/terragrunt/options"
 )
 
@@ -24,7 +23,7 @@ func NewScaffold(opts *options.TerragruntOptions, module *module.Module) *Scaffo
 }
 
 func (cmd *Scaffold) Run() error {
-	log.Infof("Run Scaffold for the module: %q", cmd.module.TerraformSourcePath())
+	cmd.module.Logger().Infof("Run Scaffold for the module: %q", cmd.module.TerraformSourcePath())
 
 	return scaffold.Run(context.Background(), cmd.terragruntOptions, cmd.module.TerraformSourcePath(), "")
 }
diff --git a/cli/commands/flags.go b/cli/commands/flags.go
index 95498804e3..8772c9da38 100644
--- a/cli/commands/flags.go
+++ b/cli/commands/flags.go
@@ -1,15 +1,26 @@
 package commands
 
 import (
-	"errors"
+	goErrors "errors"
+	"fmt"
 
+	"github.com/gruntwork-io/go-commons/collections"
+	"github.com/gruntwork-io/go-commons/errors"
 	"github.com/gruntwork-io/terragrunt/options"
 	"github.com/gruntwork-io/terragrunt/pkg/cli"
+	"github.com/gruntwork-io/terragrunt/pkg/log"
+	"github.com/gruntwork-io/terragrunt/pkg/log/format"
 	"github.com/gruntwork-io/terragrunt/shell"
 	"github.com/gruntwork-io/terragrunt/util"
 )
 
 const (
+	TerragruntDisableLogFormattingFlagName = "terragrunt-disable-log-formatting"
+	TerragruntDisableLogFormattingEnvName  = "TERRAGRUNT_DISABLE_LOG_FORMATTING"
+
+	TerragruntJsonLogFlagName = "terragrunt-json-log"
+	TerragruntJsonLogEnvName  = "TERRAGRUNT_JSON_LOG"
+
 	TerragruntConfigFlagName = "terragrunt-config"
 	TerragruntConfigEnvName  = "TERRAGRUNT_CONFIG"
 
@@ -117,17 +128,17 @@ const (
 
 	// Logs related flags/envs
 
-	TerragruntJsonLogFlagName = "terragrunt-json-log"
-	TerragruntJsonLogEnvName  = "TERRAGRUNT_JSON_LOG"
-
 	TerragruntLogLevelFlagName = "terragrunt-log-level"
 	TerragruntLogLevelEnvName  = "TERRAGRUNT_LOG_LEVEL"
 
+	TerragruntLogDisableFlagName = "terragrunt-log-disable"
+	TerragruntLogDisableEnvName  = "TERRAGRUNT_LOG_DISABLE"
+
 	TerragruntNoColorFlagName = "terragrunt-no-color"
 	TerragruntNoColorEnvName  = "TERRAGRUNT_NO_COLOR"
 
-	TerragruntDisableLogFormattingFlagName = "terragrunt-disable-log-formatting"
-	TerragruntDisableLogFormattingEnvName  = "TERRAGRUNT_DISABLE_LOG_FORMATTING"
+	TerragruntShowLogAbsPathsFlagName = "terragrunt-log-show-abs-paths"
+	TerragruntShowLogAbsPathsEnvName  = "TERRAGRUNT_LOG_SHOW_ABS_PATHS"
 
 	TerragruntForwardTFStdoutFlagName = "terragrunt-forward-tf-stdout"
 	TerragruntForwardTFStdoutEnvName  = "TERRAGRUNT_FORWARD_TF_STDOUT"
@@ -310,38 +321,78 @@ func NewGlobalFlags(opts *options.TerragruntOptions) cli.Flags {
 		&cli.GenericFlag[string]{
 			Name:        TerragruntLogLevelFlagName,
 			EnvVar:      TerragruntLogLevelEnvName,
-			Destination: &opts.LogLevelStr,
-			Usage:       "Sets the logging level for Terragrunt. Supported levels: panic, fatal, error, warn, info, debug, trace.",
+			DefaultText: opts.LogLevel.String(),
+			Usage:       fmt.Sprintf("Sets the logging level for Terragrunt. Supported levels: %s", log.AllLevels),
+			Action: func(ctx *cli.Context, val string) error {
+				// Before the release of v0.67.0, these levels actually disabled logs, since we do not use these levels for logging.
+				// For backward compatibility we simulate the same behavior.
+				removedLevels := []string{
+					"panic",
+					"fatal",
+				}
+
+				if collections.ListContainsElement(removedLevels, val) {
+					opts.ForwardTFStdout = true
+					opts.Logger.SetOptions(log.WithFormatter(&format.SilentFormatter{}))
+					return nil
+				}
+
+				level, err := log.ParseLevel(val)
+				if err != nil {
+					return errors.Errorf("flag --%s, %w", TerragruntLogLevelFlagName, err)
+				}
+
+				opts.Logger.SetOptions(log.WithLevel(level))
+				opts.LogLevel = level
+				return nil
+
+			},
 		},
 		&cli.BoolFlag{
-			Name:        TerragruntDisableLogFormattingFlagName,
-			EnvVar:      TerragruntDisableLogFormattingEnvName,
-			Destination: &opts.DisableLogFormatting,
-			Action: func(ctx *cli.Context) error {
-				if opts.DisableLogFormatting {
-					util.DisableLogFormatting()
-				}
+			Name:   TerragruntLogDisableFlagName,
+			EnvVar: TerragruntLogDisableEnvName,
+			Usage:  "Disable logging",
+			Action: func(ctx *cli.Context, _ bool) error {
+				opts.ForwardTFStdout = true
+				opts.Logger.SetOptions(log.WithFormatter(&format.SilentFormatter{}))
 				return nil
 			},
-			Usage: "If specified, logs will be displayed in key/value format. By default, logs are formatted in a human readable format.",
 		},
 		&cli.BoolFlag{
-			Name:        TerragruntNoColorFlagName,
-			EnvVar:      TerragruntNoColorEnvName,
-			Destination: &opts.DisableLogColors,
-			Action: func(ctx *cli.Context) error {
-				if opts.DisableLogColors {
-					util.DisableLogColors()
-				}
+			Name:        TerragruntDisableLogFormattingFlagName,
+			EnvVar:      TerragruntDisableLogFormattingEnvName,
+			Destination: &opts.DisableLogFormatting,
+			Usage:       "If specified, logs will be displayed in key/value format. By default, logs are formatted in a human readable format.",
+			Action: func(ctx *cli.Context, val bool) error {
+				opts.LogFormatter.DisableLogFormatting = val
 				return nil
 			},
-			Usage: "If specified, Terragrunt output won't contain any color.",
 		},
 		&cli.BoolFlag{
 			Name:        TerragruntJsonLogFlagName,
 			EnvVar:      TerragruntJsonLogEnvName,
 			Destination: &opts.JsonLogFormat,
 			Usage:       "If specified, Terragrunt will output its logs in JSON format.",
+			Action: func(ctx *cli.Context, _ bool) error {
+				opts.Logger.SetOptions(log.WithFormatter(&format.JSONFormatter{}))
+				return nil
+			},
+		},
+		&cli.BoolFlag{
+			Name:        TerragruntShowLogAbsPathsFlagName,
+			EnvVar:      TerragruntShowLogAbsPathsEnvName,
+			Destination: &opts.LogShowAbsPaths,
+			Usage:       "Show absolute paths in logs",
+		},
+		&cli.BoolFlag{
+			Name:        TerragruntNoColorFlagName,
+			EnvVar:      TerragruntNoColorEnvName,
+			Destination: &opts.DisableLogColors,
+			Usage:       "If specified, Terragrunt output won't contain any color.",
+			Action: func(ctx *cli.Context, val bool) error {
+				opts.LogFormatter.DisableColors = val
+				return nil
+			},
 		},
 		&cli.BoolFlag{
 			Name:        TerragruntTfLogJsonFlagName,
@@ -460,7 +511,7 @@ func NewHelpFlag(opts *options.TerragruntOptions) cli.Flag {
 		Name:    HelpFlagName,  // --help, -help
 		Aliases: []string{"h"}, //  -h
 		Usage:   "Show help",
-		Action: func(ctx *cli.Context) (err error) {
+		Action: func(ctx *cli.Context, _ bool) (err error) {
 			defer func() {
 				// exit the app
 				err = cli.NewExitError(err, 0)
@@ -472,7 +523,7 @@ func NewHelpFlag(opts *options.TerragruntOptions) cli.Flag {
 
 				// If the command name is not found, it is most likely a terraform command, show Terraform help.
 				var invalidCommandNameError cli.InvalidCommandNameError
-				if ok := errors.As(err, &invalidCommandNameError); ok {
+				if ok := goErrors.As(err, &invalidCommandNameError); ok {
 					terraformHelpCmd := append([]string{cmdName, "-help"}, ctx.Args().Tail()...)
 					return shell.RunTerraformCommand(ctx, opts, terraformHelpCmd...)
 				}
@@ -491,7 +542,7 @@ func NewVersionFlag(opts *options.TerragruntOptions) cli.Flag {
 		Name:    VersionFlagName, // --version, -version
 		Aliases: []string{"v"},   //  -v
 		Usage:   "Show terragrunt version",
-		Action: func(ctx *cli.Context) (err error) {
+		Action: func(ctx *cli.Context, _ bool) (err error) {
 			defer func() {
 				// exit the app
 				err = cli.NewExitError(err, 0)
diff --git a/cli/commands/hclfmt/action.go b/cli/commands/hclfmt/action.go
index a889f38ccb..58888b1940 100644
--- a/cli/commands/hclfmt/action.go
+++ b/cli/commands/hclfmt/action.go
@@ -11,15 +11,16 @@ import (
 	"path/filepath"
 	"strings"
 
-	"github.com/gruntwork-io/go-commons/errors"
+	"github.com/gruntwork-io/terragrunt/pkg/log"
+	"github.com/gruntwork-io/terragrunt/pkg/log/writer"
 
-	"github.com/sirupsen/logrus"
+	"github.com/gruntwork-io/go-commons/errors"
 
 	"github.com/hashicorp/go-multierror"
-	"github.com/hashicorp/hcl/v2/hclparse"
 	"github.com/hashicorp/hcl/v2/hclwrite"
 	"github.com/mattn/go-zglob"
 
+	"github.com/gruntwork-io/terragrunt/config/hclparse"
 	"github.com/gruntwork-io/terragrunt/options"
 	"github.com/gruntwork-io/terragrunt/util"
 )
@@ -136,12 +137,12 @@ func formatTgHCL(opts *options.TerragruntOptions, tgHclFile string) error {
 }
 
 // checkErrors takes in the contents of a hcl file and looks for syntax errors.
-func checkErrors(logger *logrus.Entry, disableColor bool, contents []byte, tgHclFile string) error {
+func checkErrors(logger log.Logger, disableColor bool, contents []byte, tgHclFile string) error {
 	parser := hclparse.NewParser()
 	_, diags := parser.ParseHCL(contents, tgHclFile)
 
-	writer := &util.LogWriter{Logger: logger, Level: logrus.ErrorLevel}
-	diagWriter := util.GetDiagnosticsWriter(writer, parser, disableColor)
+	writer := writer.New(writer.WithLogger(logger), writer.WithDefaultLevel(log.ErrorLevel))
+	diagWriter := parser.GetDiagnosticsWriter(writer, disableColor)
 
 	err := diagWriter.WriteDiagnostics(diags)
 	if err != nil {
diff --git a/cli/commands/run-all/action.go b/cli/commands/run-all/action.go
index 7c1dfe9e4c..41f198f58a 100644
--- a/cli/commands/run-all/action.go
+++ b/cli/commands/run-all/action.go
@@ -69,7 +69,7 @@ func RunAllOnStack(ctx context.Context, opts *options.TerragruntOptions, stack *
 	}
 
 	if prompt != "" {
-		shouldRunAll, err := shell.PromptUserForYesNo(prompt, opts)
+		shouldRunAll, err := shell.PromptUserForYesNo(ctx, prompt, opts)
 		if err != nil {
 			return err
 		}
diff --git a/cli/commands/terraform/action.go b/cli/commands/terraform/action.go
index 9ad66c288a..5e0d575b7f 100644
--- a/cli/commands/terraform/action.go
+++ b/cli/commands/terraform/action.go
@@ -360,7 +360,7 @@ func confirmActionWithDependentModules(ctx context.Context, terragruntOptions *o
 
 		prompt := "WARNING: Are you sure you want to continue?"
 
-		shouldRun, err := shell.PromptUserForYesNo(prompt, terragruntOptions)
+		shouldRun, err := shell.PromptUserForYesNo(ctx, prompt, terragruntOptions)
 		if err != nil {
 			terragruntOptions.Logger.Error(err)
 			return false
@@ -447,7 +447,16 @@ func RunTerraformWithRetry(ctx context.Context, terragruntOptions *options.Terra
 	for i := 0; i < terragruntOptions.RetryMaxAttempts; i++ {
 		if out, err := shell.RunTerraformCommandWithOutput(ctx, terragruntOptions, terragruntOptions.TerraformCliArgs...); err != nil {
 			if out == nil || !IsRetryable(terragruntOptions, out) {
-				terragruntOptions.Logger.WithError(err).Errorf("%s invocation failed in %s", terragruntOptions.TerraformImplementation, terragruntOptions.WorkingDir)
+				logger := terragruntOptions.Logger
+
+				if execErr := util.Unwrap[util.ProcessExecutionError](err); execErr != nil {
+					if execErr.Stderr != "" {
+						logger = logger.WithField("stderr", "\n"+execErr.Stderr)
+					}
+				}
+
+				logger.Errorf("%s invocation failed in %s", terragruntOptions.TerraformImplementation, terragruntOptions.WorkingDir)
+
 				return err
 			} else {
 				terragruntOptions.Logger.Infof("Encountered an error eligible for retrying. Sleeping %v before retrying.\n", terragruntOptions.RetrySleepIntervalSec)
diff --git a/cli/commands/terraform/download_source.go b/cli/commands/terraform/download_source.go
index cb0216b717..c7b96e4397 100644
--- a/cli/commands/terraform/download_source.go
+++ b/cli/commands/terraform/download_source.go
@@ -50,7 +50,7 @@ func downloadTerraformSource(ctx context.Context, source string, terragruntOptio
 	}
 	// Always include the .tflint.hcl file, if it exists
 	includeInCopy = append(includeInCopy, tfLintConfig)
-	if err := util.CopyFolderContents(terragruntOptions.WorkingDir, terraformSource.WorkingDir, ModuleManifestName, includeInCopy); err != nil {
+	if err := util.CopyFolderContents(terragruntOptions.Logger, terragruntOptions.WorkingDir, terraformSource.WorkingDir, ModuleManifestName, includeInCopy); err != nil {
 		return nil, err
 	}
 
@@ -210,7 +210,7 @@ func updateGetters(terragruntOptions *options.TerragruntOptions, terragruntConfi
 					includeInCopy = *terragruntConfig.Terraform.IncludeInCopy
 				}
 
-				client.Getters[getterName] = &FileCopyGetter{IncludeInCopy: includeInCopy}
+				client.Getters[getterName] = &FileCopyGetter{IncludeInCopy: includeInCopy, Logger: terragruntOptions.Logger}
 			} else {
 				client.Getters[getterName] = getterValue
 			}
diff --git a/cli/commands/terraform/file_copy_getter.go b/cli/commands/terraform/file_copy_getter.go
index c1c8bab860..34d34dadb0 100644
--- a/cli/commands/terraform/file_copy_getter.go
+++ b/cli/commands/terraform/file_copy_getter.go
@@ -5,6 +5,7 @@ import (
 	"os"
 
 	"github.com/gruntwork-io/go-commons/errors"
+	"github.com/gruntwork-io/terragrunt/pkg/log"
 	"github.com/gruntwork-io/terragrunt/util"
 	"github.com/hashicorp/go-getter"
 )
@@ -21,6 +22,8 @@ type FileCopyGetter struct {
 	// List of glob paths that should be included in the copy. This can be used to override the default behavior of
 	// Terragrunt, which will skip hidden folders.
 	IncludeInCopy []string
+
+	Logger log.Logger
 }
 
 // The original FileGetter does NOT know how to do folder copying (it only does symlinks), so we provide a copy
@@ -38,7 +41,7 @@ func (g *FileCopyGetter) Get(dst string, u *url.URL) error {
 		return errors.Errorf("source path must be a directory")
 	}
 
-	return util.CopyFolderContents(path, dst, SourceManifestName, g.IncludeInCopy)
+	return util.CopyFolderContents(g.Logger, path, dst, SourceManifestName, g.IncludeInCopy)
 }
 
 // GetFile The original FileGetter already knows how to do file copying so long as we set the Copy flag to true, so just
diff --git a/cli/commands/terraform/hook.go b/cli/commands/terraform/hook.go
index d3777f0ff1..194f199c7d 100644
--- a/cli/commands/terraform/hook.go
+++ b/cli/commands/terraform/hook.go
@@ -42,9 +42,8 @@ func processErrorHooks(ctx context.Context, hooks []config.ErrorHook, terragrunt
 				originalError := errors.Unwrap(e)
 				if originalError != nil {
 					var processExecutionError util.ProcessExecutionError
-					ok := errors.As(originalError, &processExecutionError)
-					if ok {
-						errorMessage = fmt.Sprintf("%s\n%s", processExecutionError.StdOut, processExecutionError.Stderr)
+					if ok := errors.As(originalError, &processExecutionError); ok {
+						errorMessage = fmt.Sprintf("%s\n%s", processExecutionError.Stdout, processExecutionError.Stderr)
 					}
 				}
 				result = fmt.Sprintf("%s\n%s", result, errorMessage)
diff --git a/cli/commands/validate-inputs/action.go b/cli/commands/validate-inputs/action.go
index 630a520a21..abf3f02db4 100644
--- a/cli/commands/validate-inputs/action.go
+++ b/cli/commands/validate-inputs/action.go
@@ -212,7 +212,7 @@ func getTerraformInputNamesFromVarFiles(opts *options.TerragruntOptions, terragr
 		varFiles = append(varFiles, arg.GetVarFiles(opts.Logger)...)
 	}
 
-	return getVarNamesFromVarFiles(varFiles)
+	return getVarNamesFromVarFiles(opts, varFiles)
 }
 
 // getTerraformInputNamesFromCLIArgs will return the list of names of variables configured by -var and -var-file CLI
@@ -238,7 +238,7 @@ func getTerraformInputNamesFromCLIArgs(opts *options.TerragruntOptions, terragru
 		}
 	}
 
-	fileVars, err := getVarNamesFromVarFiles(varFiles)
+	fileVars, err := getVarNamesFromVarFiles(opts, varFiles)
 	if err != nil {
 		return inputNames, err
 	}
@@ -277,16 +277,16 @@ func getTerraformInputNamesFromAutomaticVarFiles(opts *options.TerragruntOptions
 
 	automaticVarFiles = append(automaticVarFiles, jsonVarFiles...)
 
-	return getVarNamesFromVarFiles(automaticVarFiles)
+	return getVarNamesFromVarFiles(opts, automaticVarFiles)
 }
 
 // getVarNamesFromVarFiles will parse all the given var files and returns a list of names of variables that are
 // configured in all of them combined together.
-func getVarNamesFromVarFiles(varFiles []string) ([]string, error) {
+func getVarNamesFromVarFiles(opts *options.TerragruntOptions, varFiles []string) ([]string, error) {
 	inputNames := []string{}
 
 	for _, varFile := range varFiles {
-		fileVars, err := getVarNamesFromVarFile(varFile)
+		fileVars, err := getVarNamesFromVarFile(opts, varFile)
 		if err != nil {
 			return inputNames, err
 		}
@@ -299,7 +299,7 @@ func getVarNamesFromVarFiles(varFiles []string) ([]string, error) {
 
 // getVarNamesFromVarFile will parse the given terraform var file and return a list of names of variables that are
 // configured in that var file.
-func getVarNamesFromVarFile(varFile string) ([]string, error) {
+func getVarNamesFromVarFile(opts *options.TerragruntOptions, varFile string) ([]string, error) {
 	fileContents, err := os.ReadFile(varFile)
 	if err != nil {
 		return nil, err
@@ -311,7 +311,7 @@ func getVarNamesFromVarFile(varFile string) ([]string, error) {
 			return nil, err
 		}
 	} else {
-		if err := config.ParseAndDecodeVarFile(varFile, fileContents, &variables); err != nil {
+		if err := config.ParseAndDecodeVarFile(opts, varFile, fileContents, &variables); err != nil {
 			return nil, err
 		}
 	}
diff --git a/cli/deprecated_flags.go b/cli/deprecated_flags.go
index a28556420a..144f7c3c65 100644
--- a/cli/deprecated_flags.go
+++ b/cli/deprecated_flags.go
@@ -5,7 +5,6 @@ import (
 	"github.com/gruntwork-io/terragrunt/cli/commands"
 	"github.com/gruntwork-io/terragrunt/options"
 	"github.com/gruntwork-io/terragrunt/pkg/cli"
-	"github.com/gruntwork-io/terragrunt/util"
 )
 
 // The following flags are DEPRECATED
@@ -22,8 +21,8 @@ func NewDeprecatedFlags(opts *options.TerragruntOptions) cli.Flags {
 			EnvVar: TerragruntIncludeModulePrefixEnvName,
 			Usage:  "When this flag is set output from Terraform sub-commands is prefixed with module path.",
 			Hidden: true,
-			Action: func(ctx *cli.Context) error {
-				util.GlobalFallbackLogEntry.Warnf("The %q flag is deprecated. Use the functionality-inverted %q flag instead. By default, Terraform/OpenTofu output is integrated into the Terragrunt log, which prepends additional data, such as timestamps and prefixes, to log entries.", TerragruntIncludeModulePrefixFlagName, commands.TerragruntForwardTFStdoutFlagName)
+			Action: func(ctx *cli.Context, _ bool) error {
+				opts.Logger.Warnf("The %q flag is deprecated. Use the functionality-inverted %q flag instead. By default, Terraform/OpenTofu output is integrated into the Terragrunt log, which prepends additional data, such as timestamps and prefixes, to log entries.", TerragruntIncludeModulePrefixFlagName, commands.TerragruntForwardTFStdoutFlagName)
 				return nil
 			},
 		},
diff --git a/cli/provider_cache.go b/cli/provider_cache.go
index 0608982be3..a314692ced 100644
--- a/cli/provider_cache.go
+++ b/cli/provider_cache.go
@@ -15,7 +15,6 @@ import (
 
 	"github.com/google/uuid"
 	"github.com/gruntwork-io/go-commons/errors"
-	"github.com/gruntwork-io/terragrunt/internal/log"
 	"github.com/gruntwork-io/terragrunt/options"
 	"github.com/gruntwork-io/terragrunt/shell"
 	"github.com/gruntwork-io/terragrunt/terraform"
@@ -97,7 +96,7 @@ func InitProviderCacheServer(opts *options.TerragruntOptions) (*ProviderCache, e
 		return nil, err
 	}
 
-	providerService := services.NewProviderService(opts.ProviderCacheDir, userProviderDir, cliCfg.CredentialsSource())
+	providerService := services.NewProviderService(opts.ProviderCacheDir, userProviderDir, cliCfg.CredentialsSource(), opts.Logger)
 
 	var (
 		providerHandlers = make([]handlers.ProviderHandler, 0, len(cliCfg.ProviderInstallation.Methods))
@@ -138,6 +137,7 @@ func InitProviderCacheServer(opts *options.TerragruntOptions) (*ProviderCache, e
 		cache.WithToken(opts.ProviderCacheToken),
 		cache.WithServices(providerService),
 		cache.WithProviderHandlers(providerHandlers...),
+		cache.WithLogger(opts.Logger),
 	)
 
 	return &ProviderCache{
@@ -177,7 +177,7 @@ func (cache *ProviderCache) TerraformCommandHook(ctx context.Context, opts *opti
 		return nil, err
 	}
 
-	log.Infof("Caching terraform providers for %s", opts.WorkingDir)
+	opts.Logger.Infof("Caching terraform providers for %s", opts.WorkingDir)
 	// Before each init, we warm up the global cache to ensure that all necessary providers are cached.
 	// To do this we are using 'terraform providers lock' to force TF to request all the providers from our TG cache, and that's how we know what providers TF needs, and can load them into the cache.
 	// It's low cost operation, because it does not cache the same provider twice, but only new previously non-existent providers.
diff --git a/codegen/generate.go b/codegen/generate.go
index abc6fb91b8..f75acbcd0c 100644
--- a/codegen/generate.go
+++ b/codegen/generate.go
@@ -128,12 +128,7 @@ func WriteToFile(terragruntOptions *options.TerragruntOptions, basePath string,
 		return errors.WithStackTrace(err)
 	}
 
-	relTargetPath, err := util.GetPathRelativeToWithSeparator(targetPath, terragruntOptions.RootWorkingDir)
-	if err != nil {
-		return err
-	}
-
-	terragruntOptions.Logger.Debugf("Generated file %s.", relTargetPath)
+	terragruntOptions.Logger.Debugf("Generated file %s.", targetPath)
 
 	return nil
 }
diff --git a/config/config.go b/config/config.go
index ff9184ffb6..d60e53ecb7 100644
--- a/config/config.go
+++ b/config/config.go
@@ -11,6 +11,9 @@ import (
 	"strconv"
 	"strings"
 
+	"github.com/gruntwork-io/terragrunt/pkg/log"
+	"github.com/gruntwork-io/terragrunt/pkg/log/writer"
+
 	"github.com/gruntwork-io/terragrunt/internal/cache"
 	"github.com/gruntwork-io/terragrunt/shell"
 	"github.com/gruntwork-io/terragrunt/telemetry"
@@ -20,7 +23,6 @@ import (
 	"github.com/hashicorp/go-getter"
 	"github.com/hashicorp/hcl/v2"
 	"github.com/hashicorp/hcl/v2/hclwrite"
-	"github.com/sirupsen/logrus"
 	"github.com/zclconf/go-cty/cty"
 
 	"github.com/gruntwork-io/go-commons/errors"
@@ -75,11 +77,12 @@ var (
 	}
 
 	DefaultParserOptions = func(opts *options.TerragruntOptions) []hclparse.Option {
-		writer := &util.LogWriter{Logger: opts.Logger, Level: logrus.ErrorLevel}
+		writer := writer.New(writer.WithLogger(opts.Logger), writer.WithDefaultLevel(log.ErrorLevel))
 
 		return []hclparse.Option{
 			hclparse.WithDiagnosticsWriter(writer, opts.DisableLogColors),
 			hclparse.WithFileUpdate(updateBareIncludeBlock),
+			hclparse.WithLogger(opts.Logger),
 		}
 	}
 
@@ -308,16 +311,6 @@ func (cfgs IncludeConfigsMap) ContainsPath(path string) bool {
 
 type IncludeConfigs []IncludeConfig
 
-func (confs *IncludeConfigs) UpdateRelativePaths(basePath string) error {
-	for _, conf := range *confs {
-		if err := conf.UpdateRelativePath(basePath); err != nil {
-			return err
-		}
-	}
-
-	return nil
-}
-
 // IncludeConfig represents the configuration settings for a parent Terragrunt configuration file that you can
 // include into a child Terragrunt configuration file. You can have more than one include config.
 type IncludeConfig struct {
@@ -325,18 +318,6 @@ type IncludeConfig struct {
 	Path          string  `hcl:"path,attr"`
 	Expose        *bool   `hcl:"expose,attr"`
 	MergeStrategy *string `hcl:"merge_strategy,attr"`
-	RelativePath  string
-}
-
-func (conf *IncludeConfig) UpdateRelativePath(basePath string) error {
-	relPath, err := util.GetPathRelativeToWithSeparator(conf.Path, basePath)
-	if err != nil {
-		return err
-	}
-
-	conf.RelativePath = relPath
-
-	return nil
 }
 
 func (cfg *IncludeConfig) String() string {
@@ -523,7 +504,7 @@ func (conf *TerraformExtraArguments) String() string {
 		conf.EnvVars)
 }
 
-func (conf *TerraformExtraArguments) GetVarFiles(logger *logrus.Entry) []string {
+func (conf *TerraformExtraArguments) GetVarFiles(logger log.Logger) []string {
 	var varFiles []string
 
 	// Include all specified RequiredVarFiles.
@@ -726,7 +707,7 @@ func isTerragruntModuleDir(path string, terragruntOptions *options.TerragruntOpt
 
 // Read the Terragrunt config file from its default location
 func ReadTerragruntConfig(ctx context.Context, terragruntOptions *options.TerragruntOptions, parserOptions []hclparse.Option) (*TerragruntConfig, error) {
-	terragruntOptions.Logger.Debugf("Reading Terragrunt config file at %s", terragruntOptions.RelativeTerragruntConfigPath)
+	terragruntOptions.Logger.Debugf("Reading Terragrunt config file at %s", terragruntOptions.TerragruntConfigPath)
 
 	ctx = shell.ContextWithTerraformCommandHook(ctx, nil)
 	parcingCtx := NewParsingContext(ctx, terragruntOptions).WithParseOption(parserOptions)
@@ -776,7 +757,7 @@ func ParseConfigFile(ctx *ParsingContext, configPath string, includeFromChild *I
 			file = cacheConfig
 		} else {
 			// Parse the HCL file into an AST body that can be decoded multiple times later without having to re-parse
-			file, err = hclparse.NewParser().WithOptions(ctx.ParserOptions...).ParseFromFile(configPath)
+			file, err = hclparse.NewParser(ctx.ParserOptions...).ParseFromFile(configPath)
 			if err != nil {
 				return err
 			}
@@ -801,7 +782,7 @@ func ParseConfigFile(ctx *ParsingContext, configPath string, includeFromChild *I
 
 func ParseConfigString(ctx *ParsingContext, configPath string, configString string, includeFromChild *IncludeConfig) (*TerragruntConfig, error) {
 	// Parse the HCL file into an AST body that can be decoded multiple times later without having to re-parse
-	file, err := hclparse.NewParser().WithOptions(ctx.ParserOptions...).ParseFromString(configString, configPath)
+	file, err := hclparse.NewParser(ctx.ParserOptions...).ParseFromString(configString, configPath)
 	if err != nil {
 		return nil, err
 	}
diff --git a/config/config_helpers.go b/config/config_helpers.go
index 64ebd51fce..e78cd09f40 100644
--- a/config/config_helpers.go
+++ b/config/config_helpers.go
@@ -985,7 +985,7 @@ func readTFVarsFile(ctx *ParsingContext, args []string) (string, error) {
 	}
 
 	var variables map[string]interface{}
-	if err := ParseAndDecodeVarFile(varFile, fileContents, &variables); err != nil {
+	if err := ParseAndDecodeVarFile(ctx.TerragruntOptions, varFile, fileContents, &variables); err != nil {
 		return "", err
 	}
 
@@ -999,8 +999,8 @@ func readTFVarsFile(ctx *ParsingContext, args []string) (string, error) {
 
 // ParseAndDecodeVarFile uses the HCL2 file to parse the given varfile string into an HCL file body, and then decode it
 // into the provided output.
-func ParseAndDecodeVarFile(varFile string, fileContents []byte, out interface{}) error {
-	parser := hclparse.NewParser()
+func ParseAndDecodeVarFile(opts *options.TerragruntOptions, varFile string, fileContents []byte, out interface{}) error {
+	parser := hclparse.NewParser(hclparse.WithLogger(opts.Logger))
 
 	file, err := parser.ParseFromBytes(fileContents, varFile)
 	if err != nil {
diff --git a/config/config_partial.go b/config/config_partial.go
index c0a70779a8..3aed959b3b 100644
--- a/config/config_partial.go
+++ b/config/config_partial.go
@@ -120,16 +120,6 @@ func DecodeBaseBlocks(ctx *ParsingContext, file *hclparse.File, includeFromChild
 		return nil, nil, err
 	}
 
-	if err := terragruntIncludeList.UpdateRelativePaths(ctx.TerragruntOptions.RootWorkingDir); err != nil {
-		return nil, nil, err
-	}
-
-	if includeFromChild != nil {
-		if err := includeFromChild.UpdateRelativePath(ctx.TerragruntOptions.RootWorkingDir); err != nil {
-			return nil, nil, err
-		}
-	}
-
 	trackInclude, err := getTrackInclude(ctx, terragruntIncludeList, includeFromChild)
 	if err != nil {
 		return nil, nil, err
@@ -166,7 +156,7 @@ func PartialParseConfigFile(ctx *ParsingContext, configPath string, include *Inc
 	if cacheConfig, found := hclCache.Get(ctx, cacheKey); found {
 		file = cacheConfig
 	} else {
-		file, err = hclparse.NewParser().WithOptions(ctx.ParserOptions...).ParseFromFile(configPath)
+		file, err = hclparse.NewParser(ctx.ParserOptions...).ParseFromFile(configPath)
 		if err != nil {
 			return nil, err
 		}
@@ -184,14 +174,14 @@ func TerragruntConfigFromPartialConfig(ctx *ParsingContext, file *hclparse.File,
 	terragruntConfigCache := cache.ContextCache[*TerragruntConfig](ctx, RunCmdCacheContextKey)
 	if ctx.TerragruntOptions.UsePartialParseConfigCache {
 		if config, found := terragruntConfigCache.Get(ctx, cacheKey); found {
-			ctx.TerragruntOptions.Logger.Debugf("Cache hit for '%s' (partial parsing), decodeList: '%v'.", ctx.TerragruntOptions.RelativeTerragruntConfigPath, ctx.PartialParseDecodeList)
+			ctx.TerragruntOptions.Logger.Debugf("Cache hit for '%s' (partial parsing), decodeList: '%v'.", ctx.TerragruntOptions.TerragruntConfigPath, ctx.PartialParseDecodeList)
 
 			deepCopy := clone.Clone(config).(*TerragruntConfig)
 
 			return deepCopy, nil
 		}
 
-		ctx.TerragruntOptions.Logger.Debugf("Cache miss for '%s' (partial parsing), decodeList: '%v'.", ctx.TerragruntOptions.RelativeTerragruntConfigPath, ctx.PartialParseDecodeList)
+		ctx.TerragruntOptions.Logger.Debugf("Cache miss for '%s' (partial parsing), decodeList: '%v'.", ctx.TerragruntOptions.TerragruntConfigPath, ctx.PartialParseDecodeList)
 	}
 
 	config, err := PartialParseConfig(ctx, file, includeFromChild)
@@ -224,7 +214,7 @@ func TerragruntConfigFromPartialConfig(ctx *ParsingContext, file *hclparse.File,
 // Note also that the following blocks are never decoded in a partial parse:
 // - inputs
 func PartialParseConfigString(ctx *ParsingContext, configPath, configString string, include *IncludeConfig) (*TerragruntConfig, error) {
-	file, err := hclparse.NewParser().WithOptions(ctx.ParserOptions...).ParseFromString(configString, configPath)
+	file, err := hclparse.NewParser(ctx.ParserOptions...).ParseFromString(configString, configPath)
 	if err != nil {
 		return nil, err
 	}
diff --git a/config/dependency.go b/config/dependency.go
index f513c4e9bd..735b02d75a 100644
--- a/config/dependency.go
+++ b/config/dependency.go
@@ -501,15 +501,10 @@ func getTerragruntOutputIfAppliedElseConfiguredDefault(ctx *ParsingContext, depe
 	// return error.
 	targetConfig := getCleanedTargetConfigPath(dependencyConfig.ConfigPath.AsString(), ctx.TerragruntOptions.TerragruntConfigPath)
 
-	targetOpts, err := cloneTerragruntOptionsForDependency(ctx, targetConfig)
-	if err != nil {
-		return nil, err
-	}
-
 	if dependencyConfig.shouldReturnMockOutputs(ctx) {
 		ctx.TerragruntOptions.Logger.Warnf("Config %s is a dependency of %s that has no outputs, but mock outputs provided and returning those in dependency output.",
-			targetOpts.RelativeTerragruntConfigPath,
-			ctx.TerragruntOptions.RelativeTerragruntConfigPath,
+			targetConfig,
+			ctx.TerragruntOptions.TerragruntConfigPath,
 		)
 
 		return dependencyConfig.MockOutputs, nil
@@ -518,9 +513,9 @@ func getTerragruntOutputIfAppliedElseConfiguredDefault(ctx *ParsingContext, depe
 	// At this point, we expect outputs to exist because there is a `dependency` block without skip_outputs = true, and
 	// returning mocks is not allowed. So return a useful error message indicating that we expected outputs, but they
 	// did not exist.
-	err = TerragruntOutputTargetNoOutputs{
-		targetConfig:  targetOpts.RelativeTerragruntConfigPath,
-		currentConfig: ctx.TerragruntOptions.RelativeTerragruntConfigPath,
+	err := TerragruntOutputTargetNoOutputs{
+		targetConfig:  targetConfig,
+		currentConfig: ctx.TerragruntOptions.TerragruntConfigPath,
 	}
 
 	return nil, err
@@ -605,21 +600,16 @@ func getOutputJsonWithCaching(ctx *ParsingContext, targetConfig string) ([]byte,
 	defer actualLock.Unlock()
 	actualLock.Lock()
 
-	targetOpts, err := cloneTerragruntOptionsForDependency(ctx, targetConfig)
-	if err != nil {
-		return nil, err
-	}
-
 	// This debug log is useful for validating if the locking mechanism is working. If the locking mechanism is working,
 	// we should only see one pair of logs at a time that begin with this statement, and then the relevant "terraform
 	// output" log for the dependency.
-	ctx.TerragruntOptions.Logger.Debugf("Getting output of dependency %s for config %s", targetOpts.RelativeTerragruntConfigPath, ctx.TerragruntOptions.RelativeTerragruntConfigPath)
+	ctx.TerragruntOptions.Logger.Debugf("Getting output of dependency %s for config %s", targetConfig, ctx.TerragruntOptions.TerragruntConfigPath)
 
 	// Look up if we have already run terragrunt output for this target config
 	rawJsonBytes, hasRun := jsonOutputCache.Load(targetConfig)
 	if hasRun {
 		// Cache hit, so return cached output
-		ctx.TerragruntOptions.Logger.Debugf("%s was run before. Using cached output.", targetOpts.RelativeTerragruntConfigPath)
+		ctx.TerragruntOptions.Logger.Debugf("%s was run before. Using cached output.", targetConfig)
 		return rawJsonBytes.([]byte), nil
 	}
 
@@ -772,10 +762,10 @@ func getTerragruntOutputJson(ctx *ParsingContext, targetConfig string) ([]byte,
 			return nil, err
 		}
 
-		ctx.TerragruntOptions.Logger.Debugf("Could not parse remote_state block from target config %s", targetOpts.RelativeTerragruntConfigPath)
+		ctx.TerragruntOptions.Logger.Debugf("Could not parse remote_state block from target config %s", targetOpts.TerragruntConfigPath)
 		ctx.TerragruntOptions.Logger.Debugf("Falling back to terragrunt output.")
 
-		return runTerragruntOutputJson(ctx)
+		return runTerragruntOutputJson(ctx, targetConfig)
 	}
 
 	// In optimization mode, see if there is already an init-ed folder that terragrunt can use, and if so, run
@@ -848,7 +838,7 @@ func getTerragruntOutputJsonFromInitFolder(ctx *ParsingContext, terraformWorking
 		return nil, err
 	}
 
-	ctx.TerragruntOptions.Logger.Debugf("Detected module %s is already init-ed. Retrieving outputs directly from working directory.", targetTGOptions.RelativeTerragruntConfigPath)
+	ctx.TerragruntOptions.Logger.Debugf("Detected module %s is already init-ed. Retrieving outputs directly from working directory.", targetTGOptions.TerragruntConfigPath)
 
 	out, err := shell.RunTerraformCommandWithOutput(ctx, targetTGOptions, terraform.CommandNameOutput, "-json")
 	if err != nil {
@@ -858,7 +848,7 @@ func getTerragruntOutputJsonFromInitFolder(ctx *ParsingContext, terraformWorking
 	jsonString := strings.TrimSpace(out.Stdout)
 	jsonBytes := []byte(jsonString)
 
-	ctx.TerragruntOptions.Logger.Debugf("Json output: %s", jsonString)
+	ctx.TerragruntOptions.Logger.Debugf("Retrieved output from %s as json: %s", targetConfigPath, jsonString)
 
 	return jsonBytes, nil
 }
@@ -899,12 +889,7 @@ func getTerragruntOutputJsonFromRemoteState(
 		}
 	}(tempWorkDir)
 
-	relTempWorkDir, err := util.GetPathRelativeToWithSeparator(tempWorkDir, ctx.TerragruntOptions.RootWorkingDir)
-	if err != nil {
-		return nil, err
-	}
-
-	ctx.TerragruntOptions.Logger.Debugf("Setting dependency working directory to %s", relTempWorkDir)
+	ctx.TerragruntOptions.Logger.Debugf("Setting dependency working directory to %s", tempWorkDir)
 
 	targetTGOptions, err := setupTerragruntOptionsForBareTerraform(ctx, tempWorkDir, targetConfigPath, iamRoleOpts)
 	if err != nil {
@@ -925,7 +910,7 @@ func getTerragruntOutputJsonFromRemoteState(
 				return nil, err
 			}
 
-			ctx.TerragruntOptions.Logger.Debugf("Retrieved output from %s as json: %s using s3 bucket", targetTGOptions.RelativeTerragruntConfigPath, jsonBytes)
+			ctx.TerragruntOptions.Logger.Debugf("Retrieved output from %s as json: %s using s3 bucket", targetTGOptions.TerragruntConfigPath, jsonBytes)
 
 			return jsonBytes, nil
 		default:
@@ -946,7 +931,7 @@ func getTerragruntOutputJsonFromRemoteState(
 		return nil, err
 	}
 
-	ctx.TerragruntOptions.Logger.Debugf("Generated remote state configuration in working dir %s", relTempWorkDir)
+	ctx.TerragruntOptions.Logger.Debugf("Generated remote state configuration in working dir %s", tempWorkDir)
 
 	// Check for a provider lock file and copy it to the working dir if it exists.
 	terragruntDir := filepath.Dir(ctx.TerragruntOptions.TerragruntConfigPath)
@@ -969,7 +954,7 @@ func getTerragruntOutputJsonFromRemoteState(
 
 	jsonString := strings.TrimSpace(out.Stdout)
 	jsonBytes := []byte(jsonString)
-	ctx.TerragruntOptions.Logger.Debugf("Json output: %s", jsonString)
+	ctx.TerragruntOptions.Logger.Debugf("Retrieved output from %s as json: %s", targetConfigPath, jsonString)
 
 	return jsonBytes, nil
 }
@@ -1059,7 +1044,7 @@ func setupTerragruntOptionsForBareTerraform(ctx *ParsingContext, workingDir stri
 
 // runTerragruntOutputJson uses terragrunt running functions to extract the json output from the target config.
 // NOTE: targetTGOptions should be in the ctx of the targetConfig.
-func runTerragruntOutputJson(ctx *ParsingContext) ([]byte, error) {
+func runTerragruntOutputJson(ctx *ParsingContext, targetConfig string) ([]byte, error) {
 	// Update the stdout buffer so we can capture the output
 	var stdoutBuffer bytes.Buffer
 	stdoutBufferWriter := bufio.NewWriter(&stdoutBuffer)
@@ -1084,7 +1069,7 @@ func runTerragruntOutputJson(ctx *ParsingContext) ([]byte, error) {
 	jsonString := strings.TrimSpace(stdoutBuffer.String())
 	jsonBytes := []byte(jsonString)
 
-	ctx.TerragruntOptions.Logger.Debugf("Json output: %s", jsonString)
+	ctx.TerragruntOptions.Logger.Debugf("Retrieved output from %s as json: %s", targetConfig, jsonString)
 
 	return jsonBytes, nil
 }
diff --git a/config/hclparse/options.go b/config/hclparse/options.go
index 4307a83eda..c60de3be00 100644
--- a/config/hclparse/options.go
+++ b/config/hclparse/options.go
@@ -4,15 +4,23 @@ import (
 	"io"
 
 	"github.com/gruntwork-io/go-commons/errors"
-	"github.com/gruntwork-io/terragrunt/util"
+	"github.com/gruntwork-io/terragrunt/pkg/log"
 	"github.com/hashicorp/hcl/v2"
 )
 
 type Option func(*Parser) *Parser
 
+func WithLogger(logger log.Logger) Option {
+	return func(parser *Parser) *Parser {
+		parser.logger = logger
+
+		return parser
+	}
+}
+
 func WithDiagnosticsWriter(writer io.Writer, disableColor bool) Option {
 	return func(parser *Parser) *Parser {
-		diagsWriter := util.GetDiagnosticsWriter(writer, parser.Parser, disableColor)
+		diagsWriter := parser.GetDiagnosticsWriter(writer, disableColor)
 
 		parser.diagsWriterFunc = func(diags hcl.Diagnostics) error {
 			if !diags.HasErrors() {
diff --git a/config/hclparse/parser.go b/config/hclparse/parser.go
index 257b78dab1..5a0fe53fda 100644
--- a/config/hclparse/parser.go
+++ b/config/hclparse/parser.go
@@ -4,13 +4,15 @@
 package hclparse
 
 import (
+	"io"
 	"os"
 	"path/filepath"
 
 	"github.com/gruntwork-io/go-commons/errors"
-	"github.com/gruntwork-io/terragrunt/internal/log"
+	"github.com/gruntwork-io/terragrunt/pkg/log"
 	"github.com/hashicorp/hcl/v2"
 	"github.com/hashicorp/hcl/v2/hclparse"
+	"golang.org/x/term"
 )
 
 type Parser struct {
@@ -18,15 +20,17 @@ type Parser struct {
 	diagsWriterFunc       func(hcl.Diagnostics) error
 	handleDiagnosticsFunc func(*File, hcl.Diagnostics) (hcl.Diagnostics, error)
 	fileUpdateHandlerFunc func(*File) error
+	logger                log.Logger
 }
 
-func NewParser() *Parser {
-	return &Parser{
+func NewParser(opts ...Option) *Parser {
+	return (&Parser{
 		Parser: hclparse.NewParser(),
-	}
+		logger: log.Default(),
+	}).withOptions(opts...)
 }
 
-func (parser *Parser) WithOptions(opts ...Option) *Parser {
+func (parser *Parser) withOptions(opts ...Option) *Parser {
 	for _, opt := range opts {
 		parser = opt(parser)
 	}
@@ -37,7 +41,7 @@ func (parser *Parser) WithOptions(opts ...Option) *Parser {
 func (parser *Parser) ParseFromFile(configPath string) (*File, error) {
 	content, err := os.ReadFile(configPath)
 	if err != nil {
-		log.Warnf("Error reading file %s: %v", configPath, err)
+		parser.logger.Warnf("Error reading file %s: %v", configPath, err)
 		return nil, errors.WithStackTrace(err)
 	}
 
@@ -77,13 +81,25 @@ func (parser *Parser) ParseFromBytes(content []byte, configPath string) (file *F
 	}
 
 	if err := parser.handleDiagnostics(file, diags); err != nil {
-		log.Warnf("Failed to parse HCL in file %s: %v", configPath, diags)
+		parser.logger.Warnf("Failed to parse HCL in file %s: %v", configPath, diags)
 		return nil, errors.WithStackTrace(diags)
 	}
 
 	return file, nil
 }
 
+// GetDiagnosticsWriter returns a hcl2 parsing diagnostics emitter for the current terminal.
+func (parser *Parser) GetDiagnosticsWriter(writer io.Writer, disableColor bool) hcl.DiagnosticWriter {
+	termColor := !disableColor && term.IsTerminal(int(os.Stderr.Fd()))
+
+	termWidth, _, err := term.GetSize(int(os.Stdout.Fd()))
+	if err != nil {
+		termWidth = 80
+	}
+
+	return hcl.NewDiagnosticTextWriter(writer, parser.Files(), uint(termWidth), termColor)
+}
+
 func (parser *Parser) handleDiagnostics(file *File, diags hcl.Diagnostics) error {
 	if len(diags) == 0 {
 		return nil
diff --git a/config/include.go b/config/include.go
index 8958f278c8..4971192117 100644
--- a/config/include.go
+++ b/config/include.go
@@ -135,9 +135,9 @@ func handleInclude(ctx *ParsingContext, config *TerragruntConfig, isPartial bool
 		// TODO: Remove lint suppression
 		switch mergeStrategy { //nolint:exhaustive
 		case NoMerge:
-			ctx.TerragruntOptions.Logger.Debugf("%sIncluded config %s has strategy no merge: not merging config in.", logPrefix, includeConfig.RelativePath)
+			ctx.TerragruntOptions.Logger.Debugf("%sIncluded config %s has strategy no merge: not merging config in.", logPrefix, includeConfig.Path)
 		case ShallowMerge:
-			ctx.TerragruntOptions.Logger.Debugf("%sIncluded config %s has strategy shallow merge: merging config in (shallow).", logPrefix, includeConfig.RelativePath)
+			ctx.TerragruntOptions.Logger.Debugf("%sIncluded config %s has strategy shallow merge: merging config in (shallow).", logPrefix, includeConfig.Path)
 
 			if err := parsedIncludeConfig.Merge(baseConfig, ctx.TerragruntOptions); err != nil {
 				return nil, err
@@ -145,7 +145,7 @@ func handleInclude(ctx *ParsingContext, config *TerragruntConfig, isPartial bool
 
 			baseConfig = parsedIncludeConfig
 		case DeepMerge:
-			ctx.TerragruntOptions.Logger.Debugf("%sIncluded config %s has strategy deep merge: merging config in (deep).", logPrefix, includeConfig.RelativePath)
+			ctx.TerragruntOptions.Logger.Debugf("%sIncluded config %s has strategy deep merge: merging config in (deep).", logPrefix, includeConfig.Path)
 
 			if err := parsedIncludeConfig.DeepMerge(baseConfig, ctx.TerragruntOptions); err != nil {
 				return nil, err
@@ -189,14 +189,14 @@ func handleIncludeForDependency(ctx *ParsingContext, childDecodedDependency Terr
 		// TODO: Remove lint suppression
 		switch mergeStrategy { //nolint:exhaustive
 		case NoMerge:
-			ctx.TerragruntOptions.Logger.Debugf("Included config %s has strategy no merge: not merging config in for dependency.", includeConfig.RelativePath)
+			ctx.TerragruntOptions.Logger.Debugf("Included config %s has strategy no merge: not merging config in for dependency.", includeConfig.Path)
 		case ShallowMerge:
-			ctx.TerragruntOptions.Logger.Debugf("Included config %s has strategy shallow merge: merging config in (shallow) for dependency.", includeConfig.RelativePath)
+			ctx.TerragruntOptions.Logger.Debugf("Included config %s has strategy shallow merge: merging config in (shallow) for dependency.", includeConfig.Path)
 
 			mergedDependencyBlock := mergeDependencyBlocks(includedPartialParse.TerragruntDependencies, baseDependencyBlock)
 			baseDependencyBlock = mergedDependencyBlock
 		case DeepMerge:
-			ctx.TerragruntOptions.Logger.Debugf("Included config %s has strategy deep merge: merging config in (deep) for dependency.", includeConfig.RelativePath)
+			ctx.TerragruntOptions.Logger.Debugf("Included config %s has strategy deep merge: merging config in (deep) for dependency.", includeConfig.Path)
 
 			mergedDependencyBlock, err := deepMergeDependencyBlocks(includedPartialParse.TerragruntDependencies, baseDependencyBlock)
 			if err != nil {
diff --git a/config/locals.go b/config/locals.go
index 2cc2d2260c..9e2de8901c 100644
--- a/config/locals.go
+++ b/config/locals.go
@@ -64,7 +64,7 @@ func EvaluateLocalsBlock(ctx *ParsingContext, file *hclparse.File) (map[string]c
 		)
 
 		if err != nil {
-			ctx.TerragruntOptions.Logger.Debugf("Encountered error while evaluating locals in file %s", ctx.TerragruntOptions.RelativeTerragruntConfigPath)
+			ctx.TerragruntOptions.Logger.Debugf("Encountered error while evaluating locals in file %s", ctx.TerragruntOptions.TerragruntConfigPath)
 			return nil, err
 		}
 	}
diff --git a/config/util.go b/config/util.go
index 346e4b762c..1d920510f1 100644
--- a/config/util.go
+++ b/config/util.go
@@ -12,18 +12,8 @@ func CopyLockFile(opts *options.TerragruntOptions, sourceFolder, destinationFold
 	sourceLockFilePath := util.JoinPath(sourceFolder, terraform.TerraformLockFile)
 	destinationLockFilePath := util.JoinPath(destinationFolder, terraform.TerraformLockFile)
 
-	relDestinationFolder, err := util.GetPathRelativeToWithSeparator(destinationFolder, opts.RootWorkingDir)
-	if err != nil {
-		return err
-	}
-
-	relSourceLockFilePath, err := util.GetPathRelativeToWithSeparator(sourceLockFilePath, opts.RootWorkingDir)
-	if err != nil {
-		return err
-	}
-
 	if util.FileExists(sourceLockFilePath) {
-		opts.Logger.Debugf("Copying lock file from %s to %s", relSourceLockFilePath, relDestinationFolder)
+		opts.Logger.Debugf("Copying lock file from %s to %s", sourceLockFilePath, destinationFolder)
 		return util.CopyFile(sourceLockFilePath, destinationLockFilePath)
 	}
 
diff --git a/config/variable.go b/config/variable.go
index dff407292f..3e5eca09d2 100644
--- a/config/variable.go
+++ b/config/variable.go
@@ -31,7 +31,7 @@ func ParseVariables(opts *options.TerragruntOptions, directoryPath string) ([]*P
 		return nil, errors.WithStackTrace(err)
 	}
 
-	parser := hclparse.NewParser().WithOptions(DefaultParserOptions(opts)...)
+	parser := hclparse.NewParser(DefaultParserOptions(opts)...)
 
 	// iterate over files and parse variables.
 	var parsedInputs []*ParsedVariable
diff --git a/configstack/log.go b/configstack/log.go
index b7a00eb177..db3e1ca0e7 100644
--- a/configstack/log.go
+++ b/configstack/log.go
@@ -1,45 +1,48 @@
 package configstack
 
-import "github.com/sirupsen/logrus"
+import (
+	"github.com/gruntwork-io/terragrunt/pkg/log"
+	"github.com/sirupsen/logrus"
+)
 
-// ForceLogLevelHook - log hook which can change log level for messages which contains specific substrings
+// ForceLogLevelHook is a log hook which can change log level for messages which contains specific substrings
 type ForceLogLevelHook struct {
-	TriggerLevels []logrus.Level
-	ForcedLevel   logrus.Level
+	forcedLevel   logrus.Level
+	triggerLevels []logrus.Level
 }
 
-// NewForceLogLevelHook - create default log reduction hook
-func NewForceLogLevelHook(forcedLevel logrus.Level) *ForceLogLevelHook {
+// NewForceLogLevelHook creates default log reduction hook
+func NewForceLogLevelHook(forcedLevel log.Level) *ForceLogLevelHook {
 	return &ForceLogLevelHook{
-		ForcedLevel:   forcedLevel,
-		TriggerLevels: logrus.AllLevels,
+		forcedLevel:   forcedLevel.ToLogrusLevel(),
+		triggerLevels: log.AllLevels.ToLogrusLevels(),
 	}
 }
 
-// Levels - return log levels on which hook will be triggered
+// Levels implements logrus.Hook.Levels()
 func (hook *ForceLogLevelHook) Levels() []logrus.Level {
-	return hook.TriggerLevels
+	return hook.triggerLevels
 }
 
-// Fire - function invoked against log entries when entry will match loglevel from Levels()
+// Fire implements logrus.Hook.Fire()
 func (hook *ForceLogLevelHook) Fire(entry *logrus.Entry) error {
-	entry.Level = hook.ForcedLevel
+	entry.Level = hook.forcedLevel
 	// special formatter to skip printing of log entries since after hook evaluation, entries are printed directly
-	formatter := LogEntriesDropperFormatter{OriginalFormatter: entry.Logger.Formatter}
+	formatter := LogEntriesDropperFormatter{originalFormatter: entry.Logger.Formatter}
 	entry.Logger.Formatter = &formatter
 
 	return nil
 }
 
-// LogEntriesDropperFormatter - custom formatter which will ignore log entries which has lower level than preconfigured in logger
+// LogEntriesDropperFormatter is a custom formatter which will ignore log entries which has lower level than preconfigured in logger
 type LogEntriesDropperFormatter struct {
-	OriginalFormatter logrus.Formatter
+	originalFormatter logrus.Formatter
 }
 
-// Format - custom entry formatting function which will drop entries with lower level than set in logger
+// Format implements logrus.Formatter
 func (formatter *LogEntriesDropperFormatter) Format(entry *logrus.Entry) ([]byte, error) {
 	if entry.Logger.Level >= entry.Level {
-		return formatter.OriginalFormatter.Format(entry)
+		return formatter.originalFormatter.Format(entry)
 	}
 
 	return []byte(""), nil
diff --git a/configstack/module.go b/configstack/module.go
index 6f4193c674..207de677c6 100644
--- a/configstack/module.go
+++ b/configstack/module.go
@@ -10,10 +10,9 @@ import (
 	"strings"
 
 	"github.com/gruntwork-io/terragrunt/internal/cache"
+	"github.com/gruntwork-io/terragrunt/pkg/log"
 	"github.com/gruntwork-io/terragrunt/terraform"
 
-	"github.com/sirupsen/logrus"
-
 	"github.com/gruntwork-io/go-commons/errors"
 	"github.com/gruntwork-io/go-commons/files"
 	"github.com/gruntwork-io/terragrunt/config"
@@ -29,7 +28,6 @@ const existingModulesCacheName = "existingModules"
 // module and the list of other modules that this module depends on
 type TerraformModule struct {
 	Path                 string
-	RelativePath         string
 	Dependencies         TerraformModules
 	Config               config.TerragruntConfig
 	TerragruntOptions    *options.TerragruntOptions
@@ -41,12 +39,12 @@ type TerraformModule struct {
 func (module *TerraformModule) String() string {
 	dependencies := []string{}
 	for _, dependency := range module.Dependencies {
-		dependencies = append(dependencies, dependency.RelativePath)
+		dependencies = append(dependencies, dependency.Path)
 	}
 
 	return fmt.Sprintf(
 		"Module %s (excluded: %v, assume applied: %v, dependencies: [%s])",
-		module.RelativePath, module.FlagExcluded, module.AssumeAlreadyApplied, strings.Join(dependencies, ", "),
+		module.Path, module.FlagExcluded, module.AssumeAlreadyApplied, strings.Join(dependencies, ", "),
 	)
 }
 
@@ -143,26 +141,26 @@ func (module *TerraformModule) findModuleInPath(targetDirs []string) bool {
 // Note that we skip the prompt for `run-all destroy` calls. Given the destructive and irreversible nature of destroy, we don't
 // want to provide any risk to the user of accidentally destroying an external dependency unless explicitly included
 // with the --terragrunt-include-external-dependencies or --terragrunt-include-dir flags.
-func (module *TerraformModule) confirmShouldApplyExternalDependency(dependency *TerraformModule, terragruntOptions *options.TerragruntOptions) (bool, error) {
+func (module *TerraformModule) confirmShouldApplyExternalDependency(ctx context.Context, dependency *TerraformModule, terragruntOptions *options.TerragruntOptions) (bool, error) {
 	if terragruntOptions.IncludeExternalDependencies {
-		terragruntOptions.Logger.Debugf("The --terragrunt-include-external-dependencies flag is set, so automatically including all external dependencies, and will run this command against module %s, which is a dependency of module %s.", dependency.RelativePath, module.RelativePath)
+		terragruntOptions.Logger.Debugf("The --terragrunt-include-external-dependencies flag is set, so automatically including all external dependencies, and will run this command against module %s, which is a dependency of module %s.", dependency.Path, module.Path)
 		return true, nil
 	}
 
 	if terragruntOptions.NonInteractive {
-		terragruntOptions.Logger.Debugf("The --non-interactive flag is set. To avoid accidentally affecting external dependencies with a run-all command, will not run this command against module %s, which is a dependency of module %s.", dependency.RelativePath, module.RelativePath)
+		terragruntOptions.Logger.Debugf("The --non-interactive flag is set. To avoid accidentally affecting external dependencies with a run-all command, will not run this command against module %s, which is a dependency of module %s.", dependency.Path, module.Path)
 		return false, nil
 	}
 
 	stackCmd := terragruntOptions.TerraformCommand
 	if stackCmd == "destroy" {
-		terragruntOptions.Logger.Debugf("run-all command called with destroy. To avoid accidentally having destructive effects on external dependencies with run-all command, will not run this command against module %s, which is a dependency of module %s.", dependency.RelativePath, module.RelativePath)
+		terragruntOptions.Logger.Debugf("run-all command called with destroy. To avoid accidentally having destructive effects on external dependencies with run-all command, will not run this command against module %s, which is a dependency of module %s.", dependency.Path, module.Path)
 		return false, nil
 	}
 
-	prompt := fmt.Sprintf("Module: \t\t %s\nExternal dependency: \t %s\nShould Terragrunt apply the external dependency?", module.Path, dependency.Path)
+	terragruntOptions.Logger.Infof("Module %s has external dependency %s", module.Path, dependency.Path)
 
-	return shell.PromptUserForYesNo(prompt, terragruntOptions)
+	return shell.PromptUserForYesNo(ctx, "Should Terragrunt apply the external dependency?", terragruntOptions)
 }
 
 // Get the list of modules this module depends on
@@ -241,10 +239,7 @@ func FindWhereWorkingDirIsIncluded(ctx context.Context, terragruntOptions *optio
 		cfgOptions.OriginalTerragruntConfigPath = terragruntOptions.OriginalTerragruntConfigPath
 		cfgOptions.TerraformCommand = terragruntOptions.TerraformCommand
 		cfgOptions.NonInteractive = true
-
-		var hook = NewForceLogLevelHook(logrus.DebugLevel)
-
-		cfgOptions.Logger.Logger.AddHook(hook)
+		cfgOptions.Logger.SetOptions(log.WithHooks(NewForceLogLevelHook(log.DebugLevel)))
 
 		// build stack from config directory
 		stack, err := FindStackInSubfolders(ctx, cfgOptions, WithChildTerragruntConfig(terragruntConfig))
diff --git a/configstack/running_module.go b/configstack/running_module.go
index 87e5e9471f..328be158f5 100644
--- a/configstack/running_module.go
+++ b/configstack/running_module.go
@@ -88,7 +88,7 @@ func (module *RunningModule) runModuleWhenReady(ctx context.Context, opts *optio
 // Wait for all of this modules dependencies to finish executing. Return an error if any of those dependencies complete
 // with an error. Return immediately if this module has no dependencies.
 func (module *RunningModule) waitForDependencies() error {
-	module.Module.TerragruntOptions.Logger.Debugf("Module %s must wait for %d dependencies to finish", module.Module.RelativePath, len(module.Dependencies))
+	module.Module.TerragruntOptions.Logger.Debugf("Module %s must wait for %d dependencies to finish", module.Module.Path, len(module.Dependencies))
 
 	for len(module.Dependencies) > 0 {
 		doneDependency := <-module.DependencyDone
@@ -96,13 +96,13 @@ func (module *RunningModule) waitForDependencies() error {
 
 		if doneDependency.Err != nil {
 			if module.Module.TerragruntOptions.IgnoreDependencyErrors {
-				module.Module.TerragruntOptions.Logger.Errorf("Dependency %s of module %s just finished with an error. Module %s will have to return an error too. However, because of --terragrunt-ignore-dependency-errors, module %s will run anyway.", doneDependency.Module.RelativePath, module.Module.RelativePath, module.Module.RelativePath, module.Module.RelativePath)
+				module.Module.TerragruntOptions.Logger.Errorf("Dependency %s of module %s just finished with an error. Module %s will have to return an error too. However, because of --terragrunt-ignore-dependency-errors, module %s will run anyway.", doneDependency.Module.Path, module.Module.Path, module.Module.Path, module.Module.Path)
 			} else {
-				module.Module.TerragruntOptions.Logger.Errorf("Dependency %s of module %s just finished with an error. Module %s will have to return an error too.", doneDependency.Module.RelativePath, module.Module.RelativePath, module.Module.RelativePath)
+				module.Module.TerragruntOptions.Logger.Errorf("Dependency %s of module %s just finished with an error. Module %s will have to return an error too.", doneDependency.Module.Path, module.Module.Path, module.Module.Path)
 				return ProcessingModuleDependencyError{module.Module, doneDependency.Module, doneDependency.Err}
 			}
 		} else {
-			module.Module.TerragruntOptions.Logger.Debugf("Dependency %s of module %s just finished successfully. Module %s must wait on %d more dependencies.", doneDependency.Module.RelativePath, module.Module.RelativePath, module.Module.RelativePath, len(module.Dependencies))
+			module.Module.TerragruntOptions.Logger.Debugf("Dependency %s of module %s just finished successfully. Module %s must wait on %d more dependencies.", doneDependency.Module.Path, module.Module.Path, module.Module.Path, len(module.Dependencies))
 		}
 	}
 
@@ -114,10 +114,10 @@ func (module *RunningModule) runNow(ctx context.Context, rootOptions *options.Te
 	module.Status = Running
 
 	if module.Module.AssumeAlreadyApplied {
-		module.Module.TerragruntOptions.Logger.Debugf("Assuming module %s has already been applied and skipping it", module.Module.RelativePath)
+		module.Module.TerragruntOptions.Logger.Debugf("Assuming module %s has already been applied and skipping it", module.Module.Path)
 		return nil
 	} else {
-		module.Module.TerragruntOptions.Logger.Debugf("Running module %s now", module.Module.RelativePath)
+		module.Module.TerragruntOptions.Logger.Debugf("Running module %s now", module.Module.Path)
 
 		if err := module.Module.TerragruntOptions.RunTerragrunt(ctx, module.Module.TerragruntOptions); err != nil {
 			return err
@@ -133,7 +133,6 @@ func (module *RunningModule) runNow(ctx context.Context, rootOptions *options.Te
 			stdout := bytes.Buffer{}
 			jsonOptions.ForwardTFStdout = true
 			jsonOptions.TerraformLogsToJson = false
-			jsonOptions.OutputPrefix = ""
 			jsonOptions.Writer = &stdout
 			jsonOptions.TerraformCommand = terraform.CommandNameShow
 			jsonOptions.TerraformCliArgs = []string{terraform.CommandNameShow, "-json", module.Module.planFile(rootOptions)}
@@ -162,9 +161,9 @@ func (module *RunningModule) runNow(ctx context.Context, rootOptions *options.Te
 // Record that a module has finished executing and notify all of this module's dependencies
 func (module *RunningModule) moduleFinished(moduleErr error) {
 	if moduleErr == nil {
-		module.Module.TerragruntOptions.Logger.Debugf("Module %s has finished successfully!", module.Module.RelativePath)
+		module.Module.TerragruntOptions.Logger.Debugf("Module %s has finished successfully!", module.Module.Path)
 	} else {
-		module.Module.TerragruntOptions.Logger.Errorf("Module %s has finished with an error: %v", module.Module.RelativePath, moduleErr)
+		module.Module.TerragruntOptions.Logger.Errorf("Module %s has finished with an error: %v", module.Module.Path, moduleErr)
 	}
 
 	module.Status = Finished
diff --git a/configstack/stack.go b/configstack/stack.go
index d6eee53aff..b28d99f1fb 100644
--- a/configstack/stack.go
+++ b/configstack/stack.go
@@ -13,10 +13,10 @@ import (
 	"strings"
 
 	"github.com/gruntwork-io/go-commons/collections"
-
 	"github.com/gruntwork-io/terragrunt/cli/commands/terraform/creds"
 	"github.com/gruntwork-io/terragrunt/cli/commands/terraform/creds/providers/externalcmd"
 	"github.com/gruntwork-io/terragrunt/config/hclparse"
+	"github.com/gruntwork-io/terragrunt/pkg/log"
 	"github.com/gruntwork-io/terragrunt/telemetry"
 	"github.com/gruntwork-io/terragrunt/terraform"
 
@@ -24,7 +24,6 @@ import (
 	"github.com/gruntwork-io/terragrunt/config"
 	"github.com/gruntwork-io/terragrunt/options"
 	"github.com/gruntwork-io/terragrunt/util"
-	"github.com/sirupsen/logrus"
 )
 
 // Represents a stack of Terraform modules (i.e. folders with Terraform templates) that you can "spin up" or
@@ -98,7 +97,7 @@ func (stack *Stack) String() string {
 // LogModuleDeployOrder will log the modules that will be deployed by this operation, in the order that the operations
 // happen. For plan and apply, the order will be bottom to top (dependencies first), while for destroy the order will be
 // in reverse.
-func (stack *Stack) LogModuleDeployOrder(logger *logrus.Entry, terraformCommand string) error {
+func (stack *Stack) LogModuleDeployOrder(logger log.Logger, terraformCommand string) error {
 	outStr := fmt.Sprintf("The stack at %s will be processed in the following order for command %s:\n", stack.terragruntOptions.WorkingDir, terraformCommand)
 
 	runGraph, err := stack.GetModuleRunGraph(terraformCommand)
@@ -109,7 +108,7 @@ func (stack *Stack) LogModuleDeployOrder(logger *logrus.Entry, terraformCommand
 	for i, group := range runGraph {
 		outStr += fmt.Sprintf("Group %d\n", i+1)
 		for _, module := range group {
-			outStr += fmt.Sprintf("- Module %s\n", module.RelativePath)
+			outStr += fmt.Sprintf("- Module %s\n", module.Path)
 		}
 
 		outStr += "\n"
@@ -235,7 +234,7 @@ func (stack *Stack) summarizePlanAllErrors(terragruntOptions *options.Terragrunt
 
 			terragruntOptions.Logger.Infof("%v%v refers to remote state "+
 				"you may have to apply your changes in the dependencies prior running terragrunt run-all plan.\n",
-				stack.Modules[i].RelativePath,
+				stack.Modules[i].Path,
 				dependenciesMsg,
 			)
 		}
@@ -250,7 +249,7 @@ func (stack *Stack) syncTerraformCliArgs(terragruntOptions *options.TerragruntOp
 		planFile := module.planFile(terragruntOptions)
 
 		if planFile != "" {
-			terragruntOptions.Logger.Debugf("Using output file %s for module %s", planFile, module.TerragruntOptions.RelativeTerragruntConfigPath)
+			terragruntOptions.Logger.Debugf("Using output file %s for module %s", planFile, module.TerragruntOptions.TerragruntConfigPath)
 
 			if module.TerragruntOptions.TerraformCommand == terraform.CommandNamePlan {
 				// for plan command add -out=<file> to the terraform cli args
@@ -303,15 +302,6 @@ func (stack *Stack) createStackForTerragruntConfigPaths(ctx context.Context, ter
 			return errors.WithStackTrace(err)
 		}
 
-		for _, module := range modules {
-			relPath, err := util.GetPathRelativeToWithSeparator(module.Path, stack.terragruntOptions.RootWorkingDir)
-			if err != nil {
-				return err
-			}
-
-			module.RelativePath = relPath
-		}
-
 		stack.Modules = modules
 
 		return nil
@@ -541,14 +531,8 @@ func (stack *Stack) resolveTerraformModule(ctx context.Context, terragruntConfig
 	var includeConfig *config.IncludeConfig
 
 	if stack.childTerragruntConfig != nil && stack.childTerragruntConfig.ProcessedIncludes.ContainsPath(terragruntConfigPath) {
-		relTerragruntConfigPath, err := util.GetPathRelativeToWithSeparator(terragruntConfigPath, stack.terragruntOptions.RootWorkingDir)
-		if err != nil {
-			return nil, err
-		}
-
 		includeConfig = &config.IncludeConfig{
-			Path:         terragruntConfigPath,
-			RelativePath: relTerragruntConfigPath,
+			Path: terragruntConfigPath,
 		}
 		opts.TerragruntConfigPath = stack.terragruntOptions.OriginalTerragruntConfigPath
 	}
@@ -610,12 +594,7 @@ func (stack *Stack) resolveTerraformModule(ctx context.Context, terragruntConfig
 			return nil, err
 		}
 
-		downloadDirRelativePath, err := util.GetPathRelativeTo(downloadDir, stack.terragruntOptions.RootWorkingDir)
-		if err != nil {
-			return nil, err
-		}
-
-		opts.Logger.Debugf("Setting download directory for module %s to %s", filepath.Dir(opts.RelativeTerragruntConfigPath), downloadDirRelativePath)
+		opts.Logger.Debugf("Setting download directory for module %s to %s", filepath.Dir(opts.TerragruntConfigPath), downloadDir)
 		opts.DownloadDir = downloadDir
 	}
 
@@ -713,7 +692,7 @@ func (stack *Stack) resolveExternalDependenciesForModules(ctx context.Context, m
 
 			shouldApply := false
 			if !stack.terragruntOptions.IgnoreExternalDependencies {
-				shouldApply, err = module.confirmShouldApplyExternalDependency(externalDependency, moduleOpts)
+				shouldApply, err = module.confirmShouldApplyExternalDependency(ctx, externalDependency, moduleOpts)
 				if err != nil {
 					return externalDependencies, err
 				}
diff --git a/docs/_docs/04_reference/cli-options.md b/docs/_docs/04_reference/cli-options.md
index ea0c2c4336..462b715638 100644
--- a/docs/_docs/04_reference/cli-options.md
+++ b/docs/_docs/04_reference/cli-options.md
@@ -59,6 +59,8 @@ This page documents the CLI commands and options available with Terragrunt:
   - [terragrunt-parallelism](#terragrunt-parallelism)
   - [terragrunt-debug](#terragrunt-debug)
   - [terragrunt-log-level](#terragrunt-log-level)
+  - [terragrunt-log-disable](#terragrunt-log-disable)
+  - [terragrunt-log-show-abs-paths](#terragrunt-log-show-abs-paths)
   - [terragrunt-no-color](#terragrunt-no-color)
   - [terragrunt-check](#terragrunt-check)
   - [terragrunt-diff](#terragrunt-diff)
@@ -761,6 +763,8 @@ prefix `--terragrunt-` (e.g., `--terragrunt-config`). The currently available op
   - [terragrunt-parallelism](#terragrunt-parallelism)
   - [terragrunt-debug](#terragrunt-debug)
   - [terragrunt-log-level](#terragrunt-log-level)
+  - [terragrunt-log-disable](#terragrunt-log-disable)
+  - [terragrunt-log-show-abs-paths](#terragrunt-log-show-abs-paths)
   - [terragrunt-no-color](#terragrunt-no-color)
   - [terragrunt-check](#terragrunt-check)
   - [terragrunt-diff](#terragrunt-diff)
@@ -1082,14 +1086,31 @@ that Terragrunt invokes the module, so that you can debug issues with the terrag
 
 When passed it, sets logging level for terragrunt. All supported levels are:
 
-- `panic`
-- `fatal`
+- `stderr`
+- `stdout`
 - `error`
 - `warn`
 - `info` (this is the default)
 - `debug`
 - `trace`
 
+Where the first two control the logging of Terraform/OpenTofu output.
+
+### terragrunt-log-disable
+
+**CLI Arg**: `--terragrunt-log-disable`<br/>
+**Environment Variable**: `TERRAGRUNT_LOG_DISABLE`<br/>
+**Requires an argument**: `--terragrunt-log-disable`<br/>
+
+Disable logging. This flag also enables [terragrunt-forward-tf-stdout](#terragrunt-forward-tf-stdout).
+
+### terragrunt-log-show-abs-paths
+
+**CLI Arg**: `--terragrunt-log-show-abs-paths`<br/>
+**Environment Variable**: `TERRAGRUNT_LOG_SHOW_ABS_PATHS`<br/>
+
+If specified, Terragrunt paths in logs will be absolute. By default, the paths are relative to the working directory.
+
 ### terragrunt-no-color
 
 **CLI Arg**: `--terragrunt-no-color`<br/>
diff --git a/engine/engine.go b/engine/engine.go
index 9ffe846c1e..c1d4a18779 100644
--- a/engine/engine.go
+++ b/engine/engine.go
@@ -534,7 +534,7 @@ func invoke(ctx context.Context, runOptions *ExecutionOptions, client *proto.Eng
 	if resultCode != 0 {
 		err = util.ProcessExecutionError{
 			Err:        fmt.Errorf("command failed with exit code %d", resultCode),
-			StdOut:     stdoutBuf.String(),
+			Stdout:     stdoutBuf.String(),
 			Stderr:     stderrBuf.String(),
 			WorkingDir: terragruntOptions.WorkingDir,
 		}
diff --git a/internal/log/logger.go b/internal/log/logger.go
deleted file mode 100644
index 2bfb2b9966..0000000000
--- a/internal/log/logger.go
+++ /dev/null
@@ -1,36 +0,0 @@
-package log
-
-import (
-	"sync"
-
-	"github.com/gruntwork-io/terragrunt/internal/log/formatter"
-	"github.com/sirupsen/logrus"
-)
-
-var (
-	logger       *logrus.Logger
-	logLevelLock = sync.Mutex{}
-)
-
-func SetLogLevel(level logrus.Level) {
-	// We need to lock here as this function may be called from multiple threads concurrently (e.g. especially at test time)
-	defer logLevelLock.Unlock()
-	logLevelLock.Lock()
-
-	logger.Level = level
-}
-
-// Logger returns logger
-func Logger() *logrus.Logger {
-	return logger
-}
-
-// Logger returns logger
-func SetLogger(l *logrus.Logger) {
-	logger = l
-}
-
-func init() {
-	logger = logrus.New()
-	logger.Formatter = formatter.NewFormatter()
-}
diff --git a/internal/log/tf_writer.go b/internal/log/tf_writer.go
deleted file mode 100644
index 2a5413747f..0000000000
--- a/internal/log/tf_writer.go
+++ /dev/null
@@ -1,136 +0,0 @@
-package log
-
-import (
-	"bytes"
-	"io"
-	"path/filepath"
-	"regexp"
-	"strings"
-	"time"
-
-	"github.com/gruntwork-io/go-commons/errors"
-	"github.com/gruntwork-io/terragrunt/internal/log/formatter"
-	"github.com/sirupsen/logrus"
-)
-
-const (
-	tfTimestampFormat = "2006-01-02T15:04:05.000-0700"
-
-	// start is the ANSI start sequence
-	start = "\033["
-	// reset is the ANSI reset escape sequence
-	reset = "\033[0m"
-)
-
-var extractTimeAndLevelReg = regexp.MustCompile(`(\S+)\s*\[(\S+)\]\s*(.+\S)`)
-
-type tfWriter struct {
-	io.Writer
-	formatter logrus.Formatter
-	prefix    string
-	tfPath    string
-	isStdout  bool
-}
-
-func TFStdoutWriter(writer io.Writer, formatter logrus.Formatter, prefix, tfpath string) io.Writer {
-	return &tfWriter{
-		Writer:    writer,
-		formatter: formatter,
-		prefix:    prefix,
-		tfPath:    tfpath,
-		isStdout:  true,
-	}
-}
-
-func TFStderrWriter(writer io.Writer, formatter logrus.Formatter, prefix, tfpath string) io.Writer {
-	return &tfWriter{
-		Writer:    writer,
-		formatter: formatter,
-		prefix:    prefix,
-		tfPath:    tfpath,
-		isStdout:  false,
-	}
-}
-
-func (writer *tfWriter) Write(p []byte) (int, error) {
-	var (
-		msgs  []byte
-		lines = bytes.Split(p, []byte{'\n'})
-	)
-
-	for _, line := range lines {
-		if len(line) == 0 {
-			continue
-		}
-
-		var (
-			timeStr, levelStr string
-			msg               = string(line)
-		)
-
-		if !writer.isStdout {
-			timeStr, levelStr, msg = extractTimeAndLevel(msg)
-		}
-
-		// Reset ANSI styles at the end of a line so that TG log does not inherit them on a new line
-		if strings.Contains(msg, start) {
-			msg += reset
-		}
-
-		entry := &logrus.Entry{
-			Logger:  &logrus.Logger{Out: writer.Writer},
-			Time:    time.Now(),
-			Data:    make(map[string]any),
-			Message: msg,
-		}
-
-		if writer.prefix != "" {
-			entry.Data[formatter.PrefixKeyName] = writer.prefix
-		}
-
-		if writer.tfPath != "" {
-			entry.Data[formatter.TFBinaryKeyName] = filepath.Base(writer.tfPath)
-		}
-
-		level, err := logrus.ParseLevel(strings.ToLower(levelStr))
-		if err != nil {
-			level = formatter.NoneLevel
-		}
-
-		entry.Level = level
-
-		if timeStr != "" {
-			t, err := time.Parse(tfTimestampFormat, timeStr)
-			if err != nil {
-				return 0, errors.WithStackTrace(err)
-			}
-
-			entry.Time = t
-		}
-
-		b, err := writer.formatter.Format(entry)
-		if err != nil {
-			return 0, err
-		}
-
-		msgs = append(msgs, b...)
-	}
-
-	if _, err := writer.Writer.Write(msgs); err != nil {
-		return 0, errors.WithStackTrace(err)
-	}
-
-	return len(p), nil
-}
-
-func extractTimeAndLevel(msg string) (string, string, string) {
-	const numberOfValues = 4
-
-	if extractTimeAndLevelReg.MatchString(msg) {
-		if match := extractTimeAndLevelReg.FindStringSubmatch(msg); len(match) == numberOfValues {
-			return match[1], match[2], match[3]
-		}
-	}
-
-	return "", "", msg
-}
diff --git a/main.go b/main.go
index 6fb878bc8a..e7b127199b 100644
--- a/main.go
+++ b/main.go
@@ -7,6 +7,9 @@ import (
 
 	"github.com/gruntwork-io/go-commons/errors"
 	"github.com/gruntwork-io/terragrunt/cli"
+	"github.com/gruntwork-io/terragrunt/cli/commands"
+	"github.com/gruntwork-io/terragrunt/options"
+	"github.com/gruntwork-io/terragrunt/pkg/log"
 	"github.com/gruntwork-io/terragrunt/shell"
 	"github.com/gruntwork-io/terragrunt/util"
 	"github.com/hashicorp/go-multierror"
@@ -14,41 +17,46 @@ import (
 
 // The main entrypoint for Terragrunt
 func main() {
-	defer errors.Recover(checkForErrorsAndExit)
+	opts := options.NewTerragruntOptions()
+	parseAndSetLogEnvs(opts)
 
-	app := cli.NewApp(os.Stdout, os.Stderr)
+	defer errors.Recover(checkForErrorsAndExit(opts.Logger))
+
+	app := cli.NewApp(opts)
 	err := app.Run(os.Args)
 
-	checkForErrorsAndExit(err)
+	checkForErrorsAndExit(opts.Logger)(err)
 }
 
 // If there is an error, display it in the console and exit with a non-zero exit code. Otherwise, exit 0.
-func checkForErrorsAndExit(err error) {
-	if err == nil {
-		os.Exit(0)
-	} else {
-		util.GlobalFallbackLogEntry.Debugf(printErrorWithStackTrace(err))
-		util.GlobalFallbackLogEntry.Errorf(err.Error())
-
-		// exit with the underlying error code
-		exitCode, exitCodeErr := util.GetExitCode(err)
-		if exitCodeErr != nil {
-			exitCode = 1
-
-			util.GlobalFallbackLogEntry.Errorf("Unable to determine underlying exit code, so Terragrunt will exit with error code 1")
-		}
+func checkForErrorsAndExit(logger log.Logger) func(error) {
+	return func(err error) {
+		if err == nil {
+			os.Exit(0)
+		} else {
+			logger.Debugf(printErrorWithStackTrace(err))
+			logger.Errorf(err.Error())
 
-		if explain := shell.ExplainError(err); len(explain) > 0 {
-			util.GlobalFallbackLogEntry.Errorf("Suggested fixes: \n%s", explain)
-		}
+			// exit with the underlying error code
+			exitCode, exitCodeErr := util.GetExitCode(err)
+			if exitCodeErr != nil {
+				exitCode = 1
 
-		os.Exit(exitCode)
+				logger.Errorf("Unable to determine underlying exit code, so Terragrunt will exit with error code 1")
+			}
+
+			if explain := shell.ExplainError(err); len(explain) > 0 {
+				logger.Errorf("Suggested fixes: \n%s", explain)
+			}
+
+			os.Exit(exitCode)
+		}
 	}
 }
 
 func printErrorWithStackTrace(err error) string {
 	var multierror *multierror.Error
-	// if err, ok := err.(*multierror.Error); ok {
+
 	if goErrors.As(err, &multierror) {
 		var errsStr []string
 		for _, err := range multierror.Errors {
@@ -60,3 +68,15 @@ func printErrorWithStackTrace(err error) string {
 
 	return errors.PrintErrorWithStackTrace(err)
 }
+
+func parseAndSetLogEnvs(opts *options.TerragruntOptions) {
+	if levelStr := os.Getenv(commands.TerragruntLogLevelEnvName); levelStr != "" {
+		level, err := log.ParseLevel(levelStr)
+		if err != nil {
+			err := errors.Errorf("Could not parse log level from environment variable %s=%s, %w", commands.TerragruntLogLevelEnvName, levelStr, err)
+			checkForErrorsAndExit(opts.Logger)(err)
+		}
+
+		opts.Logger.SetOptions(log.WithLevel(level))
+	}
+}
diff --git a/options/options.go b/options/options.go
index 84c79bc39f..439ee5a433 100644
--- a/options/options.go
+++ b/options/options.go
@@ -11,10 +11,10 @@ import (
 	"time"
 
 	"github.com/gruntwork-io/go-commons/errors"
-	"github.com/gruntwork-io/terragrunt/internal/log/formatter"
+	"github.com/gruntwork-io/terragrunt/pkg/log"
+	"github.com/gruntwork-io/terragrunt/pkg/log/format"
 	"github.com/gruntwork-io/terragrunt/util"
 	"github.com/hashicorp/go-version"
-	"github.com/sirupsen/logrus"
 )
 
 const ContextKey ctxKey = iota
@@ -41,6 +41,8 @@ const (
 	minCommandLength = 2
 
 	defaultExcludesFile = ".terragrunt-excludes"
+
+	defaultLogLevel = log.InfoLevel
 )
 
 var (
@@ -73,9 +75,6 @@ type TerragruntOptions struct {
 	// Location of the Terragrunt config file
 	TerragruntConfigPath string
 
-	// Relative path to `RootWorkingDir`. We use this path for logs to shorten the path length.
-	RelativeTerragruntConfigPath string
-
 	// Location of the original Terragrunt config file. This is primarily useful when one Terragrunt config is being
 	// read from another: e.g., if /terraform-code/terragrunt.hcl calls read_terragrunt_config("/foo/bar.hcl"),
 	// and within bar.hcl, you call get_original_terragrunt_dir(), you'll get back /terraform-code.
@@ -124,7 +123,7 @@ type TerragruntOptions struct {
 	RootWorkingDir string
 
 	// Basic log entry
-	Logger *logrus.Entry
+	Logger log.Logger
 
 	// Disable Terragrunt colors
 	DisableLogColors bool
@@ -132,18 +131,21 @@ type TerragruntOptions struct {
 	// Output Terragrunt logs in JSON format
 	JsonLogFormat bool
 
-	// Wrap Terraform logs in JSON format
-	TerraformLogsToJson bool
+	// Disable replacing full paths in logs with short relative paths
+	LogShowAbsPaths bool
 
 	// Log level
-	LogLevel logrus.Level
+	LogLevel log.Level
 
-	// Raw log level value
-	LogLevelStr string
+	// Log formatter
+	LogFormatter *format.Formatter
 
 	// If true, logs will be displayed in format key/value, by default logs are formatted in human-readable format.
 	DisableLogFormatting bool
 
+	// Wrap Terraform logs in JSON format
+	TerraformLogsToJson bool
+
 	// ValidateStrict mode for the validate-inputs command
 	ValidateStrict bool
 
@@ -267,9 +269,6 @@ type TerragruntOptions struct {
 	// Include fields metadata in render-json
 	RenderJsonWithMetadata bool
 
-	// Prefix for shell commands' outputs
-	OutputPrefix string
-
 	// Disable TF output formatting
 	ForwardTFStdout bool
 
@@ -327,10 +326,6 @@ type TerragruntOptions struct {
 
 	// Options to use engine for running IaC operations.
 	Engine *EngineOptions
-
-	// LogPrefixStyle stores unique prefixes with their color schemes. When we clone the TerragruntOptions instance and create a new Logger we need to pass this cache to assign the same color to the prefix if it has been already discovered before.
-	// Since TerragruntOptions can be cloned multiple times and branched as a tree, we always need to have access to the same value from all instances, so we use a pointer.
-	LogPrefixStyle formatter.PrefixStyle
 }
 
 // TerragruntOptionsFunc is a functional option type used to pass options in certain integration tests
@@ -389,6 +384,12 @@ func MergeIAMRoleOptions(target IAMRoleOptions, source IAMRoleOptions) IAMRoleOp
 
 // Create a new TerragruntOptions object with reasonable defaults for real usage
 func NewTerragruntOptions() *TerragruntOptions {
+	return NewTerragruntOptionsWithWriters(os.Stdout, os.Stderr)
+}
+
+func NewTerragruntOptionsWithWriters(stdout, stderr io.Writer) *TerragruntOptions {
+	var logFormatter = format.NewFormatter()
+
 	return &TerragruntOptions{
 		TerraformPath:                  DefaultWrappedPath,
 		ExcludesFile:                   defaultExcludesFile,
@@ -398,8 +399,9 @@ func NewTerragruntOptions() *TerragruntOptions {
 		RunAllAutoApprove:              true,
 		NonInteractive:                 false,
 		TerraformCliArgs:               []string{},
-		LogLevelStr:                    util.GetDefaultLogLevel().String(),
-		Logger:                         util.GlobalFallbackLogEntry,
+		LogLevel:                       defaultLogLevel,
+		LogFormatter:                   logFormatter,
+		Logger:                         log.New(log.WithOutput(stderr), log.WithLevel(defaultLogLevel), log.WithFormatter(logFormatter)),
 		Env:                            map[string]string{},
 		Source:                         "",
 		SourceMap:                      map[string]string{},
@@ -408,8 +410,8 @@ func NewTerragruntOptions() *TerragruntOptions {
 		IgnoreDependencyOrder:          false,
 		IgnoreExternalDependencies:     false,
 		IncludeExternalDependencies:    false,
-		Writer:                         os.Stdout,
-		ErrWriter:                      os.Stderr,
+		Writer:                         stdout,
+		ErrWriter:                      stderr,
 		MaxFoldersToCheck:              DefaultMaxFoldersToCheck,
 		AutoRetry:                      true,
 		RetryMaxAttempts:               DEFAULT_RETRY_MAX_ATTEMPTS,
@@ -424,11 +426,9 @@ func NewTerragruntOptions() *TerragruntOptions {
 		Diff:                           false,
 		FetchDependencyOutputFromState: false,
 		UsePartialParseConfigCache:     false,
-		OutputPrefix:                   "",
 		ForwardTFStdout:                false,
 		JSONOut:                        DefaultJSONOutName,
 		TerraformImplementation:        UnknownImpl,
-		JsonLogFormat:                  false,
 		TerraformLogsToJson:            false,
 		JsonDisableDependentModules:    false,
 		RunTerragrunt: func(ctx context.Context, opts *TerragruntOptions) error {
@@ -437,7 +437,6 @@ func NewTerragruntOptions() *TerragruntOptions {
 		ProviderCacheRegistryNames: defaultProviderCacheRegistryNames,
 		OutputFolder:               "",
 		JsonOutputFolder:           "",
-		LogPrefixStyle:             formatter.NewPrefixStyle(),
 	}
 }
 
@@ -451,6 +450,7 @@ func NewTerragruntOptionsWithConfigPath(terragruntConfigPath string) (*Terragrun
 	}
 
 	opts.WorkingDir = workingDir
+	opts.RootWorkingDir = workingDir
 	opts.DownloadDir = downloadDir
 
 	return opts, nil
@@ -477,15 +477,14 @@ func GetDefaultIAMAssumeRoleSessionName() string {
 func NewTerragruntOptionsForTest(terragruntConfigPath string, options ...TerragruntOptionsFunc) (*TerragruntOptions, error) {
 	opts, err := NewTerragruntOptionsWithConfigPath(terragruntConfigPath)
 	if err != nil {
-		logger := util.CreateLogEntry("", util.GetDefaultLogLevel(), nil, opts.DisableLogColors, opts.DisableLogFormatting)
-		logger.Errorf("%v\n", errors.WithStackTrace(err))
+		log.WithOptions(log.WithLevel(log.DebugLevel)).Errorf("%v\n", errors.WithStackTrace(err))
 
 		return nil, err
 	}
 
 	opts.NonInteractive = true
-	opts.Logger = util.CreateLogEntry("", logrus.DebugLevel, nil, opts.DisableLogColors, opts.DisableLogFormatting)
-	opts.LogLevel = logrus.DebugLevel
+	opts.Logger.SetOptions(log.WithLevel(log.DebugLevel))
+	opts.LogLevel = log.DebugLevel
 
 	for _, opt := range options {
 		opt(opts)
@@ -510,24 +509,11 @@ func (opts *TerragruntOptions) OptionsFromContext(ctx context.Context) *Terragru
 func (opts *TerragruntOptions) Clone(terragruntConfigPath string) (*TerragruntOptions, error) {
 	workingDir := filepath.Dir(terragruntConfigPath)
 
-	relTerragruntConfigPath, err := util.GetPathRelativeToWithSeparator(terragruntConfigPath, opts.RootWorkingDir)
-	if err != nil {
-		return nil, err
-	}
-
-	var outputPrefix string
-	if filepath.Dir(terragruntConfigPath) != opts.RootWorkingDir {
-		outputPrefix = filepath.Dir(relTerragruntConfigPath)
-	}
-
-	logger := util.CreateLogEntryWithWriter(opts.ErrWriter, outputPrefix, opts.LogLevel, opts.Logger.Logger.Hooks, opts.LogPrefixStyle, opts.DisableLogColors, opts.DisableLogFormatting)
-
 	// Note that we clone lists and maps below as TerragruntOptions may be used and modified concurrently in the code
 	// during xxx-all commands (e.g., apply-all, plan-all). See https://github.com/gruntwork-io/terragrunt/issues/367
 	// for more info.
 	return &TerragruntOptions{
 		TerragruntConfigPath:           terragruntConfigPath,
-		RelativeTerragruntConfigPath:   relTerragruntConfigPath,
 		OriginalTerragruntConfigPath:   opts.OriginalTerragruntConfigPath,
 		TerraformPath:                  opts.TerraformPath,
 		OriginalTerraformCommand:       opts.OriginalTerraformCommand,
@@ -540,9 +526,9 @@ func (opts *TerragruntOptions) Clone(terragruntConfigPath string) (*TerragruntOp
 		TerraformCliArgs:               util.CloneStringList(opts.TerraformCliArgs),
 		WorkingDir:                     workingDir,
 		RootWorkingDir:                 opts.RootWorkingDir,
-		Logger:                         logger,
+		Logger:                         opts.Logger.WithField(format.PrefixKeyName, workingDir),
 		LogLevel:                       opts.LogLevel,
-		LogPrefixStyle:                 opts.LogPrefixStyle,
+		LogFormatter:                   opts.LogFormatter,
 		ValidateStrict:                 opts.ValidateStrict,
 		Env:                            util.CloneStringMap(opts.Env),
 		Source:                         opts.Source,
@@ -574,17 +560,15 @@ func (opts *TerragruntOptions) Clone(terragruntConfigPath string) (*TerragruntOp
 		AwsProviderPatchOverrides:      opts.AwsProviderPatchOverrides,
 		HclFile:                        opts.HclFile,
 		JSONOut:                        opts.JSONOut,
+		JsonLogFormat:                  opts.JsonLogFormat,
 		Check:                          opts.Check,
 		CheckDependentModules:          opts.CheckDependentModules,
 		FetchDependencyOutputFromState: opts.FetchDependencyOutputFromState,
 		UsePartialParseConfigCache:     opts.UsePartialParseConfigCache,
-		OutputPrefix:                   outputPrefix,
 		ForwardTFStdout:                opts.ForwardTFStdout,
-		DisableLogFormatting:           opts.DisableLogFormatting,
 		FailIfBucketCreationRequired:   opts.FailIfBucketCreationRequired,
 		DisableBucketUpdate:            opts.DisableBucketUpdate,
 		TerraformImplementation:        opts.TerraformImplementation,
-		JsonLogFormat:                  opts.JsonLogFormat,
 		TerraformLogsToJson:            opts.TerraformLogsToJson,
 		GraphRoot:                      opts.GraphRoot,
 		ScaffoldVars:                   opts.ScaffoldVars,
diff --git a/pkg/cli/args.go b/pkg/cli/args.go
index 274429050d..ffd9951286 100644
--- a/pkg/cli/args.go
+++ b/pkg/cli/args.go
@@ -7,9 +7,7 @@ import (
 
 const (
 	SingleDashFlag NormalizeActsType = iota
-	DoubleDashFlag NormalizeActsType = iota
-
-	minTailLen = 2
+	DoubleDashFlag
 )
 
 var (
@@ -43,16 +41,18 @@ func (args Args) Last() string {
 
 // Tail returns the rest of the arguments (not the first one)
 // or else an empty string slice
-func (args Args) Tail() []string {
-	if args.Len() >= minTailLen {
-		tail := []string((args)[1:])
-		ret := make([]string, len(tail))
-		copy(ret, tail)
+func (args Args) Tail() Args {
+	const minArgsLen = 2
 
-		return ret
+	if args.Len() < minArgsLen {
+		return []string{}
 	}
 
-	return []string{}
+	tail := []string((args)[1:])
+	ret := make([]string, len(tail))
+	copy(ret, tail)
+
+	return ret
 }
 
 // Len returns the length of the wrapped slice
@@ -111,3 +111,14 @@ func (args Args) CommandName() string {
 
 	return ""
 }
+
+// Contains returns true if args contains the given `target` arg.
+func (args Args) Contains(target string) bool {
+	for _, arg := range args {
+		if arg == target {
+			return true
+		}
+	}
+
+	return false
+}
diff --git a/pkg/cli/bool_flag.go b/pkg/cli/bool_flag.go
index 16671c0e82..0730c6bf2b 100644
--- a/pkg/cli/bool_flag.go
+++ b/pkg/cli/bool_flag.go
@@ -9,6 +9,9 @@ import (
 // BoolFlag implements Flag
 var _ Flag = new(BoolFlag)
 
+// BoolActionFunc is the action to execute when the flag has been set either via a flag or via an environment variable.
+type BoolActionFunc[T bool] func(ctx *Context, value T) error
+
 type BoolFlag struct {
 	flag
 
@@ -23,7 +26,7 @@ type BoolFlag struct {
 	// The name of the env variable that is parsed and assigned to `Destination` before the flag value.
 	EnvVar string
 	// The action to execute when flag is specified
-	Action ActionFunc
+	Action BoolActionFunc[bool]
 	// The pointer to which the value of the flag or env var is assigned.
 	// It also uses as the default value displayed in the help.
 	Destination *bool
@@ -35,6 +38,10 @@ type BoolFlag struct {
 
 // Apply applies Flag settings to the given flag set.
 func (flag *BoolFlag) Apply(set *libflag.FlagSet) error {
+	if flag.Destination == nil {
+		flag.Destination = new(bool)
+	}
+
 	var err error
 
 	valType := FlagType[bool](&boolFlagType{negative: flag.Negative})
@@ -91,7 +98,7 @@ func (flag *BoolFlag) Names() []string {
 // RunAction implements ActionableFlag.RunAction
 func (flag *BoolFlag) RunAction(ctx *Context) error {
 	if flag.Action != nil {
-		return flag.Action(ctx)
+		return flag.Action(ctx, *flag.Destination)
 	}
 
 	return nil
diff --git a/pkg/cli/generic_flag.go b/pkg/cli/generic_flag.go
index c1dba4d9c0..b86dd0cb87 100644
--- a/pkg/cli/generic_flag.go
+++ b/pkg/cli/generic_flag.go
@@ -16,6 +16,9 @@ type GenericType interface {
 	string | int | int64 | uint
 }
 
+// GenericActionFunc is the action to execute when the flag has been set either via a flag or via an environment variable.
+type GenericActionFunc[T GenericType] func(ctx *Context, value T) error
+
 type GenericFlag[T GenericType] struct {
 	flag
 
@@ -30,7 +33,7 @@ type GenericFlag[T GenericType] struct {
 	// The name of the env variable that is parsed and assigned to `Destination` before the flag value.
 	EnvVar string
 	// The action to execute when flag is specified
-	Action ActionFunc
+	Action GenericActionFunc[T]
 	// The pointer to which the value of the flag or env var is assigned.
 	// It also uses as the default value displayed in the help.
 	Destination *T
@@ -40,6 +43,10 @@ type GenericFlag[T GenericType] struct {
 
 // Apply applies Flag settings to the given flag set.
 func (flag *GenericFlag[T]) Apply(set *libflag.FlagSet) error {
+	if flag.Destination == nil {
+		flag.Destination = new(T)
+	}
+
 	var err error
 
 	valType := FlagType[T](new(genericType[T]))
@@ -96,7 +103,7 @@ func (flag *GenericFlag[T]) Names() []string {
 // RunAction implements ActionableFlag.RunAction
 func (flag *GenericFlag[T]) RunAction(ctx *Context) error {
 	if flag.Action != nil {
-		return flag.Action(ctx)
+		return flag.Action(ctx, *flag.Destination)
 	}
 
 	return nil
@@ -104,9 +111,10 @@ func (flag *GenericFlag[T]) RunAction(ctx *Context) error {
 
 // -- generic Value
 type genericValue[T comparable] struct {
-	value       FlagType[T]
-	defaultText string
-	hasBeenSet  bool
+	value         FlagType[T]
+	defaultText   string
+	hasBeenSet    bool
+	envHasBeenSet bool
 }
 
 func newGenericValue[T comparable](value FlagType[T], envValue *string, dest *T) (FlagValue, error) {
@@ -118,15 +126,20 @@ func newGenericValue[T comparable](value FlagType[T], envValue *string, dest *T)
 	defaultText := value.Clone(dest).String()
 	value = value.Clone(dest)
 
+	var envHasBeenSet bool
+
 	if envValue != nil {
 		if err := value.Set(*envValue); err != nil {
 			return nil, err
 		}
+
+		envHasBeenSet = true
 	}
 
 	return &genericValue[T]{
-		value:       value,
-		defaultText: defaultText,
+		value:         value,
+		defaultText:   defaultText,
+		envHasBeenSet: envHasBeenSet,
 	}, nil
 }
 
@@ -150,7 +163,7 @@ func (flag *genericValue[T]) IsBoolFlag() bool {
 }
 
 func (flag *genericValue[T]) IsSet() bool {
-	return flag.hasBeenSet
+	return flag.hasBeenSet || flag.envHasBeenSet
 }
 
 func (flag *genericValue[T]) String() string {
diff --git a/pkg/cli/map_flag.go b/pkg/cli/map_flag.go
index c96c37fbf6..4a7ac691e1 100644
--- a/pkg/cli/map_flag.go
+++ b/pkg/cli/map_flag.go
@@ -26,6 +26,9 @@ type MapFlagValueType interface {
 	GenericType | bool
 }
 
+// MapActionFunc is the action to execute when the flag has been set either via a flag or via an environment variable.
+type MapActionFunc[K MapFlagKeyType, V MapFlagValueType] func(ctx *Context, value map[K]V) error
+
 // MapFlag is a key value flag.
 type MapFlag[K MapFlagKeyType, V MapFlagValueType] struct {
 	flag
@@ -39,7 +42,7 @@ type MapFlag[K MapFlagKeyType, V MapFlagValueType] struct {
 	// Aliases are usually used for the short flag name, like `-h`.
 	Aliases []string
 	// The action to execute when flag is specified
-	Action ActionFunc
+	Action MapActionFunc[K, V]
 	// The name of the env variable that is parsed and assigned to `Destination` before the flag value.
 	EnvVar string
 	// The pointer to which the value of the flag or env var is assigned.
@@ -57,6 +60,10 @@ type MapFlag[K MapFlagKeyType, V MapFlagValueType] struct {
 
 // Apply applies Flag settings to the given flag set.
 func (flag *MapFlag[K, V]) Apply(set *libflag.FlagSet) error {
+	if flag.Destination == nil {
+		flag.Destination = new(map[K]V)
+	}
+
 	if flag.Splitter == nil {
 		flag.Splitter = FlagSplitter
 	}
@@ -126,7 +133,7 @@ func (flag *MapFlag[K, V]) Names() []string {
 // RunAction implements ActionableFlag.RunAction
 func (flag *MapFlag[K, V]) RunAction(ctx *Context) error {
 	if flag.Action != nil {
-		return flag.Action(ctx)
+		return flag.Action(ctx, *flag.Destination)
 	}
 
 	return nil
@@ -140,6 +147,7 @@ type mapValue[K, V comparable] struct {
 	argSep, valSep string
 	splitter       SplitterFunc
 	hasBeenSet     bool
+	envHasBeenSet  bool
 }
 
 func newMapValue[K, V comparable](keyType FlagType[K], valType FlagType[V], envValue *string, argSep, valSep string, splitter SplitterFunc, dest *map[K]V) (FlagValue, error) {
@@ -151,6 +159,8 @@ func newMapValue[K, V comparable](keyType FlagType[K], valType FlagType[V], envV
 
 	defaultText := (&mapValue[K, V]{values: dest, keyType: keyType, valType: valType, argSep: argSep, valSep: valSep, splitter: splitter}).String()
 
+	var envHasBeenSet bool
+
 	if envValue != nil && splitter != nil {
 		value := mapValue[K, V]{values: dest, keyType: keyType, valType: valType, argSep: argSep, valSep: valSep, splitter: splitter}
 
@@ -159,17 +169,20 @@ func newMapValue[K, V comparable](keyType FlagType[K], valType FlagType[V], envV
 			if err := value.Set(strings.TrimSpace(arg)); err != nil {
 				return nil, err
 			}
+
+			envHasBeenSet = true
 		}
 	}
 
 	return &mapValue[K, V]{
-		values:      dest,
-		keyType:     keyType,
-		valType:     valType,
-		defaultText: defaultText,
-		argSep:      argSep,
-		valSep:      valSep,
-		splitter:    splitter,
+		values:        dest,
+		keyType:       keyType,
+		valType:       valType,
+		defaultText:   defaultText,
+		argSep:        argSep,
+		valSep:        valSep,
+		splitter:      splitter,
+		envHasBeenSet: envHasBeenSet,
 	}, nil
 }
 
@@ -214,7 +227,7 @@ func (flag *mapValue[K, V]) IsBoolFlag() bool {
 }
 
 func (flag *mapValue[K, V]) IsSet() bool {
-	return flag.hasBeenSet
+	return flag.hasBeenSet || flag.envHasBeenSet
 }
 
 func (flag *mapValue[K, V]) Get() any {
diff --git a/pkg/cli/slice_flag.go b/pkg/cli/slice_flag.go
index 85290cffa2..3c355fe069 100644
--- a/pkg/cli/slice_flag.go
+++ b/pkg/cli/slice_flag.go
@@ -18,6 +18,9 @@ type SliceFlagType interface {
 	GenericType
 }
 
+// SliceActionFunc is the action to execute when the flag has been set either via a flag or via an environment variable.
+type SliceActionFunc[T SliceFlagType] func(ctx *Context, value []T) error
+
 // SliceFlag is a multiple flag.
 type SliceFlag[T SliceFlagType] struct {
 	flag
@@ -33,7 +36,7 @@ type SliceFlag[T SliceFlagType] struct {
 	// The name of the env variable that is parsed and assigned to `Destination` before the flag value.
 	EnvVar string
 	// The action to execute when flag is specified
-	Action ActionFunc
+	Action SliceActionFunc[T]
 	// The pointer to which the value of the flag or env var is assigned.
 	// It also uses as the default value displayed in the help.
 	Destination *[]T
@@ -47,6 +50,10 @@ type SliceFlag[T SliceFlagType] struct {
 
 // Apply applies Flag settings to the given flag set.
 func (flag *SliceFlag[T]) Apply(set *libflag.FlagSet) error {
+	if flag.Destination == nil {
+		flag.Destination = new([]T)
+	}
+
 	if flag.Splitter == nil {
 		flag.Splitter = FlagSplitter
 	}
@@ -111,7 +118,7 @@ func (flag *SliceFlag[T]) Names() []string {
 // RunAction implements ActionableFlag.RunAction
 func (flag *SliceFlag[T]) RunAction(ctx *Context) error {
 	if flag.Action != nil {
-		return flag.Action(ctx)
+		return flag.Action(ctx, *flag.Destination)
 	}
 
 	return nil
@@ -119,11 +126,12 @@ func (flag *SliceFlag[T]) RunAction(ctx *Context) error {
 
 // -- slice Value
 type sliceValue[T comparable] struct {
-	values      *[]T
-	valueType   FlagType[T]
-	defaultText string
-	valSep      string
-	hasBeenSet  bool
+	values        *[]T
+	valueType     FlagType[T]
+	defaultText   string
+	valSep        string
+	hasBeenSet    bool
+	envHasBeenSet bool
 }
 
 func newSliceValue[T comparable](valueType FlagType[T], envValue *string, valSep string, splitter SplitterFunc, dest *[]T) (FlagValue, error) {
@@ -134,6 +142,8 @@ func newSliceValue[T comparable](valueType FlagType[T], envValue *string, valSep
 
 	defaultText := (&sliceValue[T]{values: dest, valueType: valueType, valSep: valSep}).String()
 
+	var envHasBeenSet bool
+
 	if envValue != nil && splitter != nil {
 		value := sliceValue[T]{values: dest, valueType: valueType}
 
@@ -142,14 +152,17 @@ func newSliceValue[T comparable](valueType FlagType[T], envValue *string, valSep
 			if err := value.Set(val); err != nil {
 				return nil, err
 			}
+
+			envHasBeenSet = true
 		}
 	}
 
 	return &sliceValue[T]{
-		values:      dest,
-		valueType:   valueType,
-		defaultText: defaultText,
-		valSep:      valSep,
+		values:        dest,
+		valueType:     valueType,
+		defaultText:   defaultText,
+		valSep:        valSep,
+		envHasBeenSet: envHasBeenSet,
 	}, nil
 }
 
@@ -184,7 +197,7 @@ func (flag *sliceValue[T]) IsBoolFlag() bool {
 }
 
 func (flag *sliceValue[T]) IsSet() bool {
-	return flag.hasBeenSet
+	return flag.hasBeenSet || flag.envHasBeenSet
 }
 
 func (flag *sliceValue[T]) Get() any {
diff --git a/pkg/log/fields.go b/pkg/log/fields.go
new file mode 100644
index 0000000000..2c526731ce
--- /dev/null
+++ b/pkg/log/fields.go
@@ -0,0 +1,29 @@
+package log
+
+import "sort"
+
+// Fields is the type used to pass arguments to `WithFields`.
+type Fields map[string]interface{}
+
+func (fields Fields) Keys(removeKeys ...string) []string {
+	var keys []string
+
+	for key := range fields {
+		var skip bool
+
+		for _, removeKey := range removeKeys {
+			if key == removeKey {
+				skip = true
+				break
+			}
+		}
+
+		if !skip {
+			keys = append(keys, key)
+		}
+	}
+
+	sort.Strings(keys)
+
+	return keys
+}
diff --git a/internal/log/formatter/color.go b/pkg/log/format/color.go
similarity index 79%
rename from internal/log/formatter/color.go
rename to pkg/log/format/color.go
index 30d94db3f0..226176f8ea 100644
--- a/internal/log/formatter/color.go
+++ b/pkg/log/format/color.go
@@ -1,8 +1,8 @@
-package formatter
+package format
 
 import (
+	"github.com/gruntwork-io/terragrunt/pkg/log"
 	"github.com/mgutz/ansi"
-	"github.com/sirupsen/logrus"
 )
 
 var (
@@ -56,22 +56,22 @@ func (scheme ColorScheme) Compile() compiledColorScheme {
 
 type compiledColorScheme map[ColorStyleName]ColorFunc
 
-func (scheme compiledColorScheme) LevelColorFunc(level logrus.Level) ColorFunc {
+func (scheme compiledColorScheme) LevelColorFunc(level log.Level) ColorFunc {
 	switch level {
-	case logrus.InfoLevel:
+	case log.TraceLevel:
+		return scheme.ColorFunc(TraceLevelStyle)
+	case log.DebugLevel:
+		return scheme.ColorFunc(DebugLevelStyle)
+	case log.InfoLevel:
 		return scheme.ColorFunc(InfoLevelStyle)
-	case logrus.WarnLevel:
+	case log.WarnLevel:
 		return scheme.ColorFunc(WarnLevelStyle)
-	case logrus.ErrorLevel:
+	case log.ErrorLevel:
 		return scheme.ColorFunc(ErrorLevelStyle)
-	case logrus.FatalLevel:
-		return scheme.ColorFunc(FatalLevelStyle)
-	case logrus.PanicLevel:
-		return scheme.ColorFunc(PanicLevelStyle)
-	case logrus.DebugLevel:
-		return scheme.ColorFunc(DebugLevelStyle)
-	case logrus.TraceLevel:
+	case log.StdoutLevel:
 		return scheme.ColorFunc(TraceLevelStyle)
+	case log.StderrLevel:
+		return scheme.ColorFunc(ErrorLevelStyle)
 	default:
 		return scheme.ColorFunc(None)
 	}
diff --git a/internal/log/formatter/formatter.go b/pkg/log/format/formatter.go
similarity index 90%
rename from internal/log/formatter/formatter.go
rename to pkg/log/format/formatter.go
index 79d1f8df00..44e61fa88c 100644
--- a/internal/log/formatter/formatter.go
+++ b/pkg/log/format/formatter.go
@@ -1,4 +1,4 @@
-package formatter
+package format
 
 import (
 	"bytes"
@@ -9,6 +9,7 @@ import (
 	"time"
 
 	"github.com/gruntwork-io/go-commons/errors"
+	"github.com/gruntwork-io/terragrunt/pkg/log"
 	"github.com/sirupsen/logrus"
 	"golang.org/x/exp/maps"
 )
@@ -19,7 +20,6 @@ const (
 
 	PrefixKeyName   = "prefix"
 	TFBinaryKeyName = "tfBinary"
-	NoneLevel       = logrus.Level(10)
 )
 
 // Formatter implements logrus.Formatter
@@ -105,12 +105,12 @@ func (formatter *Formatter) printKeyValue(buf *bytes.Buffer, entry *logrus.Entry
 		return err
 	}
 
-	if err := formatter.appendKeyValue(buf, "level", formatter.levelText(entry.Level), true); err != nil {
+	if err := formatter.appendKeyValue(buf, "level", log.FromLogrusLevel(entry.Level), true); err != nil {
 		return err
 	}
 
 	if val, ok := entry.Data[PrefixKeyName]; ok && val != nil {
-		if val := val.(string); val != "" {
+		if val, ok := val.(string); ok && val != "" {
 			if err := formatter.appendKeyValue(buf, "prefix", val, true); err != nil {
 				return err
 			}
@@ -118,7 +118,7 @@ func (formatter *Formatter) printKeyValue(buf *bytes.Buffer, entry *logrus.Entry
 	}
 
 	if val, ok := entry.Data[TFBinaryKeyName]; ok && val != nil {
-		if val := val.(string); val != "" {
+		if val, ok := val.(string); ok && val != "" {
 			if err := formatter.appendKeyValue(buf, "binary", filepath.Base(val), true); err != nil {
 				return err
 			}
@@ -142,7 +142,7 @@ func (formatter *Formatter) printKeyValue(buf *bytes.Buffer, entry *logrus.Entry
 }
 
 func (formatter *Formatter) printFormatted(buf *bytes.Buffer, entry *logrus.Entry) error {
-	level := fmt.Sprintf("%-6s ", formatter.levelText(entry.Level))
+	level := fmt.Sprintf("%-6s ", log.FromLogrusLevel(entry.Level))
 	if !formatter.DisableUppercase {
 		level = strings.ToUpper(level)
 	}
@@ -153,7 +153,7 @@ func (formatter *Formatter) printFormatted(buf *bytes.Buffer, entry *logrus.Entr
 		timestamp string
 	)
 
-	if val, ok := entry.Data[PrefixKeyName]; ok && val != nil {
+	if val, ok := entry.Data[PrefixKeyName]; ok && val != nil && val != "." {
 		if val, ok := val.(string); ok && val != "" {
 			prefix = fmt.Sprintf("[%s] ", val)
 		}
@@ -173,7 +173,7 @@ func (formatter *Formatter) printFormatted(buf *bytes.Buffer, entry *logrus.Entr
 	timestamp = entry.Time.Format(timestampFormat) + " "
 
 	if !formatter.DisableColors {
-		level = formatter.colorScheme.LevelColorFunc(entry.Level)(level)
+		level = formatter.colorScheme.LevelColorFunc(log.FromLogrusLevel(entry.Level))(level)
 		prefix = formatter.PrefixStyle.ColorFunc(prefix)(prefix)
 		tfBinary = formatter.colorScheme.ColorFunc(TFBinaryStyle)(tfBinary)
 		timestamp = formatter.colorScheme.ColorFunc(TimestampStyle)(timestamp)
@@ -239,19 +239,6 @@ func (formatter *Formatter) appendValue(buf *bytes.Buffer, value interface{}) er
 	return nil
 }
 
-func (formatter *Formatter) levelText(level logrus.Level) string {
-	levelText := level.String()
-	if level == logrus.WarnLevel {
-		levelText = "warn"
-	}
-
-	if levelText == "unknown" {
-		levelText = "stdout"
-	}
-
-	return levelText
-}
-
 func (formatter *Formatter) keys(data logrus.Fields, removeKeys ...string) []string {
 	var (
 		keys []string
diff --git a/pkg/log/format/json_formatter.go b/pkg/log/format/json_formatter.go
new file mode 100644
index 0000000000..c278227785
--- /dev/null
+++ b/pkg/log/format/json_formatter.go
@@ -0,0 +1,154 @@
+package format
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"time"
+
+	"github.com/gruntwork-io/terragrunt/pkg/log"
+	"github.com/sirupsen/logrus"
+)
+
+// Default key names for the default fields.
+const (
+	reservedFourFields     = 4
+	defaultTimestampFormat = time.RFC3339
+	FieldKeyMsg            = "msg"
+	FieldKeyLevel          = "level"
+	FieldKeyTime           = "time"
+	FieldKeyLogrusError    = "logrus_error"
+	FieldKeyFunc           = "func"
+	FieldKeyFile           = "file"
+)
+
+type fieldKey string
+
+// FieldMap allows customization of the key names for default fields.
+type FieldMap map[fieldKey]string
+
+func (f FieldMap) resolve(key fieldKey) string {
+	if k, ok := f[key]; ok {
+		return k
+	}
+
+	return string(key)
+}
+
+// JSONFormatter formats logs into parsable json.
+type JSONFormatter struct {
+	// TimestampFormat sets the format used for marshaling timestamps.
+	TimestampFormat string
+
+	// DisableTimestamp allows disabling automatic timestamps in output.
+	DisableTimestamp bool
+
+	// DisableHTMLEscape allows disabling html escaping in output.
+	DisableHTMLEscape bool
+
+	// DataKey allows users to put all the log entry parameters into a nested dictionary at a given key.
+	DataKey string
+
+	// FieldMap allows users to customize the names of keys for default fields.
+	FieldMap FieldMap
+
+	// PrettyPrint will indent all json logs.
+	PrettyPrint bool
+}
+
+// Format implements logrus.Formatter interface.
+func (f *JSONFormatter) Format(entry *logrus.Entry) ([]byte, error) {
+	data := make(logrus.Fields, len(entry.Data)+reservedFourFields)
+
+	for k, v := range entry.Data {
+		switch v := v.(type) {
+		case error:
+			// Otherwise errors are ignored by `encoding/json`
+			// https://github.com/sirupsen/logrus/issues/137
+			data[k] = v.Error()
+		default:
+			data[k] = v
+		}
+	}
+
+	if f.DataKey != "" {
+		newData := make(logrus.Fields, reservedFourFields)
+		newData[f.DataKey] = data
+		data = newData
+	}
+
+	prefixFieldClashes(data, f.FieldMap, entry.HasCaller())
+
+	timestampFormat := f.TimestampFormat
+	if timestampFormat == "" {
+		timestampFormat = defaultTimestampFormat
+	}
+
+	if !f.DisableTimestamp {
+		data[f.FieldMap.resolve(FieldKeyTime)] = entry.Time.Format(timestampFormat)
+	}
+
+	data[f.FieldMap.resolve(FieldKeyMsg)] = entry.Message
+	data[f.FieldMap.resolve(FieldKeyLevel)] = log.FromLogrusLevel(entry.Level).String()
+
+	var b *bytes.Buffer
+	if entry.Buffer != nil {
+		b = entry.Buffer
+	} else {
+		b = &bytes.Buffer{}
+	}
+
+	encoder := json.NewEncoder(b)
+	encoder.SetEscapeHTML(!f.DisableHTMLEscape)
+
+	if f.PrettyPrint {
+		encoder.SetIndent("", "  ")
+	}
+
+	if err := encoder.Encode(data); err != nil {
+		return nil, fmt.Errorf("failed to marshal fields to JSON, %w", err)
+	}
+
+	return b.Bytes(), nil
+}
+
+// This is to not silently overwrite `time`, `msg`, `func` and `level` fields when dumping it.
+func prefixFieldClashes(data logrus.Fields, fieldMap FieldMap, reportCaller bool) {
+	timeKey := fieldMap.resolve(FieldKeyTime)
+	if t, ok := data[timeKey]; ok {
+		data["fields."+timeKey] = t
+		delete(data, timeKey)
+	}
+
+	msgKey := fieldMap.resolve(FieldKeyMsg)
+	if m, ok := data[msgKey]; ok {
+		data["fields."+msgKey] = m
+		delete(data, msgKey)
+	}
+
+	levelKey := fieldMap.resolve(FieldKeyLevel)
+	if l, ok := data[levelKey]; ok {
+		data["fields."+levelKey] = l
+		delete(data, levelKey)
+	}
+
+	logrusErrKey := fieldMap.resolve(FieldKeyLogrusError)
+	if l, ok := data[logrusErrKey]; ok {
+		data["fields."+logrusErrKey] = l
+		delete(data, logrusErrKey)
+	}
+
+	// If reportCaller is not set, 'func' will not conflict.
+	if reportCaller {
+		funcKey := fieldMap.resolve(FieldKeyFunc)
+		if l, ok := data[funcKey]; ok {
+			data["fields."+funcKey] = l
+		}
+
+		fileKey := fieldMap.resolve(FieldKeyFile)
+
+		if l, ok := data[fileKey]; ok {
+			data["fields."+fileKey] = l
+		}
+	}
+}
diff --git a/internal/log/formatter/prefix_style.go b/pkg/log/format/prefix_style.go
similarity index 98%
rename from internal/log/formatter/prefix_style.go
rename to pkg/log/format/prefix_style.go
index effc1e1320..0686efc798 100644
--- a/internal/log/formatter/prefix_style.go
+++ b/pkg/log/format/prefix_style.go
@@ -1,4 +1,4 @@
-package formatter
+package format
 
 import (
 	"github.com/puzpuzpuz/xsync/v3"
diff --git a/pkg/log/format/silent_formatter.go b/pkg/log/format/silent_formatter.go
new file mode 100644
index 0000000000..ce5c8c56e9
--- /dev/null
+++ b/pkg/log/format/silent_formatter.go
@@ -0,0 +1,13 @@
+package format
+
+import (
+	"github.com/sirupsen/logrus"
+)
+
+// SilentFormatter disables logging by not outputting anything.
+type SilentFormatter struct{}
+
+// Format implements logrus.Formatter interface.
+func (f *SilentFormatter) Format(entry *logrus.Entry) ([]byte, error) {
+	return nil, nil
+}
diff --git a/pkg/log/hooks/relative_path.go b/pkg/log/hooks/relative_path.go
new file mode 100644
index 0000000000..187811c0e7
--- /dev/null
+++ b/pkg/log/hooks/relative_path.go
@@ -0,0 +1,97 @@
+package hooks
+
+import (
+	"os"
+	"path/filepath"
+	"regexp"
+	"strings"
+
+	"github.com/gruntwork-io/go-commons/errors"
+	"github.com/gruntwork-io/terragrunt/pkg/log"
+	"github.com/gruntwork-io/terragrunt/pkg/log/format"
+	"github.com/sirupsen/logrus"
+)
+
+// RelativePathHook represents a hook for logrus logger.
+// The purpose is to replace all absolute paths found in the main message or data fields with paths relative to `baseDir`.
+// For better performance, during instance creation, we creating a cache of relative paths for each subdirectory of baseDir.
+//
+// Example of cache:
+// /path/to/dir ./
+// /path/to     ../
+// /path        ../..
+type RelativePathHook struct {
+	relPaths      []string
+	absPathsReg   []*regexp.Regexp
+	triggerLevels []logrus.Level
+}
+
+// NewRelativePathHook returns a new RelativePathHook instance.
+// It returns an error if the cache of relative paths could not be created for the given `baseDir`.
+func NewRelativePathHook(baseDir string) (*RelativePathHook, error) {
+	baseDir = filepath.Clean(baseDir)
+
+	pathSeparator := string(os.PathSeparator)
+	dirs := strings.Split(baseDir, pathSeparator)
+	absPath := dirs[0]
+	dirs = dirs[1:]
+
+	relPaths := make([]string, len(dirs))
+	absPathsReg := make([]*regexp.Regexp, len(dirs))
+	reversIndex := len(dirs)
+
+	for _, dir := range dirs {
+		absPath = filepath.Join(absPath, pathSeparator, dir)
+
+		relPath, err := filepath.Rel(baseDir, absPath)
+		if err != nil {
+			return nil, errors.WithStackTrace(err)
+		}
+
+		reversIndex--
+		relPaths[reversIndex] = relPath
+		absPathsReg[reversIndex] = regexp.MustCompile(regexp.QuoteMeta(absPath) + `([` + regexp.QuoteMeta(pathSeparator) + `"'\s]|$)`)
+	}
+
+	return &RelativePathHook{
+		absPathsReg:   absPathsReg,
+		relPaths:      relPaths,
+		triggerLevels: log.AllLevels.ToLogrusLevels(),
+	}, nil
+}
+
+// Levels implements logrus.Hook.Levels()
+func (hook *RelativePathHook) Levels() []logrus.Level {
+	return hook.triggerLevels
+}
+
+// Fire implements logrus.Hook.Fire()
+func (hook *RelativePathHook) Fire(entry *logrus.Entry) error {
+	entry.Message = hook.replaceAbsPathsWithRel(entry.Message)
+
+	for key, field := range entry.Data {
+		if val, ok := field.(string); ok {
+			newVal := hook.replaceAbsPathsWithRel(val)
+
+			if newVal == val {
+				continue
+			}
+
+			if key == format.PrefixKeyName && strings.HasPrefix(newVal, log.CurDirWithSeparator) {
+				newVal = newVal[len(log.CurDirWithSeparator):]
+			}
+
+			entry.Data[key] = newVal
+		}
+	}
+
+	return nil
+}
+
+func (hook *RelativePathHook) replaceAbsPathsWithRel(text string) string {
+	for i, absPath := range hook.absPathsReg {
+		text = absPath.ReplaceAllString(text, hook.relPaths[i]+"$1")
+	}
+
+	return text
+}
diff --git a/pkg/log/level.go b/pkg/log/level.go
new file mode 100644
index 0000000000..567d9650a9
--- /dev/null
+++ b/pkg/log/level.go
@@ -0,0 +1,203 @@
+package log
+
+import (
+	"strings"
+
+	"github.com/gruntwork-io/go-commons/errors"
+	"github.com/sirupsen/logrus"
+)
+
+// These are the different logging levels.
+const (
+	// StderrLevel level. Used to log error messages that we get from OpenTofu/Terraform stderr.
+	StderrLevel Level = iota
+	// StdoutLevel level. Used to log messages that we get from OpenTofu/Terraform stdout.
+	StdoutLevel
+	// ErrorLevel level. Logs. Used for errors that should definitely be noted.
+	ErrorLevel
+	// WarnLevel level. Non-critical entries that deserve eyes.
+	WarnLevel
+	// InfoLevel level. General operational entries about what's going on inside the application.
+	InfoLevel
+	// DebugLevel level. Usually only enabled when debugging. Very verbose logging.
+	DebugLevel
+	// TraceLevel level. Designates finer-grained informational events than the Debug.
+	TraceLevel
+)
+
+// Since the first two logrus levels are Panic and Fatal, which cause an exit or panic when called, we need to shift all of our levels by two bytes.
+const shiftLogrusLevel = 2
+
+var logrusLevels = map[Level]logrus.Level{
+	StderrLevel: logrus.Level(StderrLevel + shiftLogrusLevel),
+	StdoutLevel: logrus.Level(StdoutLevel + shiftLogrusLevel),
+	ErrorLevel:  logrus.Level(ErrorLevel + shiftLogrusLevel),
+	WarnLevel:   logrus.Level(WarnLevel + shiftLogrusLevel),
+	InfoLevel:   logrus.Level(InfoLevel + shiftLogrusLevel),
+	DebugLevel:  logrus.Level(DebugLevel + shiftLogrusLevel),
+	TraceLevel:  logrus.Level(TraceLevel + shiftLogrusLevel),
+}
+
+// AllLevels exposes all logging levels
+var AllLevels = Levels{
+	StderrLevel,
+	StdoutLevel,
+	ErrorLevel,
+	WarnLevel,
+	InfoLevel,
+	DebugLevel,
+	TraceLevel,
+}
+
+var levelNames = map[Level]string{
+	StderrLevel: "stderr",
+	StdoutLevel: "stdout",
+	ErrorLevel:  "error",
+	WarnLevel:   "warn",
+	InfoLevel:   "info",
+	DebugLevel:  "debug",
+	TraceLevel:  "trace",
+}
+
+var levelShortNames = map[Level]string{
+	StderrLevel: "std",
+	StdoutLevel: "std",
+	ErrorLevel:  "err",
+	WarnLevel:   "wrn",
+	InfoLevel:   "inf",
+	DebugLevel:  "deb",
+	TraceLevel:  "trc",
+}
+
+var levelTinyNames = map[Level]string{
+	StderrLevel: "s",
+	StdoutLevel: "s",
+	ErrorLevel:  "e",
+	WarnLevel:   "w",
+	InfoLevel:   "i",
+	DebugLevel:  "d",
+	TraceLevel:  "t",
+}
+
+// Level type
+type Level uint32
+
+// ParseLevel takes a string and returns the Level constant.
+func ParseLevel(str string) (Level, error) {
+	for level, name := range levelNames {
+		if strings.EqualFold(name, str) {
+			return level, nil
+		}
+	}
+
+	return Level(0), errors.Errorf("invalid level %q, supported levels: %s", str, AllLevels)
+}
+
+// String implements fmt.Stringer.
+func (level Level) String() string {
+	if name, ok := levelNames[level]; ok {
+		return name
+	}
+
+	return ""
+}
+
+// TinyName returns the level name in one character.
+func (level Level) TinyName() string {
+	if name, ok := levelTinyNames[level]; ok {
+		return name
+	}
+
+	return ""
+}
+
+// TinyName returns the level name in third characters.
+func (level Level) ShortName() string {
+	if name, ok := levelShortNames[level]; ok {
+		return name
+	}
+
+	return ""
+}
+
+// UnmarshalText implements encoding.TextUnmarshaler.
+func (level *Level) UnmarshalText(text []byte) error {
+	lvl, err := ParseLevel(string(text))
+	if err != nil {
+		return errors.Errorf("invalid: %q", string(text))
+	}
+
+	*level = lvl
+
+	return nil
+}
+
+// MarshalText implements encoding.MarshalText.
+func (level Level) MarshalText() ([]byte, error) {
+	if name := level.String(); name != "" {
+		return []byte(name), nil
+	}
+
+	return nil, errors.Errorf("invalid: %q", level)
+}
+
+// Levels is a slice of `Level` type.
+type Levels []Level
+
+// Contains returns true if the `Levels` list contains the given search `Level`.
+func (levels Levels) Contains(search Level) bool {
+	for _, level := range levels {
+		if level == search {
+			return true
+		}
+	}
+
+	return false
+}
+
+// ToLogrusLevels converts our `Levels` to `logrus.Levels`.
+func (levels Levels) ToLogrusLevels() []logrus.Level {
+	logrusLevels := make([]logrus.Level, len(levels))
+
+	for i, level := range levels {
+		logrusLevels[i] = level.ToLogrusLevel()
+	}
+
+	return logrusLevels
+}
+
+// Names returns a list of full level names.
+func (levels Levels) Names() []string {
+	strs := make([]string, len(levels))
+
+	for i, level := range levels {
+		strs[i] = level.String()
+	}
+
+	return strs
+}
+
+// String implements the `fmt.Stringer` interface.
+func (levels Levels) String() string {
+	return strings.Join(levels.Names(), ", ")
+}
+
+// ToLogrusLevels converts our `Level` to `logrus.Level`.
+func (level Level) ToLogrusLevel() logrus.Level {
+	if logrusLevel, ok := logrusLevels[level]; ok {
+		return logrusLevel
+	}
+
+	return logrus.Level(0)
+}
+
+// FromLogrusLevel converts `logrus.Level` to our `Level`.
+func FromLogrusLevel(lvl logrus.Level) Level {
+	for level, logrusLevel := range logrusLevels {
+		if logrusLevel == lvl {
+			return level
+		}
+	}
+
+	return Level(0)
+}
diff --git a/internal/log/exported.go b/pkg/log/log.go
similarity index 51%
rename from internal/log/exported.go
rename to pkg/log/log.go
index 77bd65d85a..4cf32b8e96 100644
--- a/internal/log/exported.go
+++ b/pkg/log/log.go
@@ -1,138 +1,123 @@
 package log
 
-import (
-	"fmt"
-
-	"github.com/sirupsen/logrus"
+var (
+	// std is the name of the default logger.
+	std = New()
 )
 
-// WithError adds an error to log entry, using the value defined in ErrorKey as key.
-func WithError(err error) *logrus.Entry {
-	return logger.WithError(err)
+// Default returns the standard logger used by the package-level output functions.
+// Typically used as the default logger for various packages.
+// It is highly recommended not to use it to avoid conflicts in tests.
+func Default() Logger {
+	return std
 }
 
 // Debug logs a message at level Debug on the standard logger.
 func Debug(args ...any) {
-	logger.Debug(args...)
+	std.Debug(args...)
 }
 
 // Trace logs a message at level Trace on the standard logger.
 func Trace(args ...any) {
-	logger.Trace(args...)
+	std.Trace(args...)
 }
 
 // Info logs a message at level Info on the standard logger.
 func Info(args ...any) {
-	logger.Info(args...)
+	std.Info(args...)
 }
 
 // Print logs a message at level Info on the standard logger.
 func Print(args ...any) {
-	logger.Print(args...)
+	std.Print(args...)
 }
 
 // Warn logs a message at level Warn on the standard logger.
 func Warn(args ...any) {
-	logger.Warn(args...)
+	std.Warn(args...)
 }
 
 // Error logs a message at level Error on the standard logger.
 func Error(args ...any) {
-	logger.Error(args...)
-}
-
-// Panic logs a message at level Panic on the standard logger.
-func Panic(args ...any) {
-	logger.Panic(args...)
-}
-
-// Fatal logs a message at level Fatal on the standard logger then the process will exit with status set to 1.
-func Fatal(args ...any) {
-	logger.Fatal(args...)
+	std.Error(args...)
 }
 
 // Debugln logs a message at level Debug on the standard logger.
 func Debugln(args ...any) {
-	logger.Debugln(args...)
+	std.Debugln(args...)
 }
 
 // Infoln logs a message at level Info on the standard logger.
 func Infoln(args ...any) {
-	logger.Infoln(args...)
+	std.Infoln(args...)
 }
 
 // Println logs a message at level Info on the standard logger.
 func Println(args ...any) {
-	logger.Println(args...)
+	std.Println(args...)
 }
 
 // Warnln logs a message at level Warn on the standard logger.
 func Warnln(args ...any) {
-	logger.Warnln(args...)
+	std.Warnln(args...)
 }
 
 // Errorln logs a message at level Error on the standard logger.
 func Errorln(args ...any) {
-	logger.Errorln(args...)
-}
-
-// Panicln logs a message at level Panic on the standard logger.
-func Panicln(args ...any) {
-	logger.Panicln(args...)
-}
-
-// Fatalln logs a message at level Fatal on the standard logger then the process will exit with status set to 1.
-func Fatalln(args ...any) {
-	logger.Fatalln(args...)
+	std.Errorln(args...)
 }
 
 // Debugf logs a message at level Debug on the standard logger.
 func Debugf(format string, args ...any) {
-	logger.Debugf(format, args...)
+	std.Debugf(format, args...)
 }
 
 // Tracef logs a message at level Trace on the standard logger.
 func Tracef(format string, args ...any) {
-	logger.Tracef(format, args...)
+	std.Tracef(format, args...)
 }
 
 // Infof logs a message at level Info on the standard logger.
 func Infof(format string, args ...any) {
-	logger.Infof(format, args...)
+	std.Infof(format, args...)
 }
 
 // Printf logs a message at level Info on the standard logger.
 func Printf(args ...any) {
-	logger.Print(args...)
+	std.Print(args...)
 }
 
 // Warnf logs a message at level Warn on the standard logger.
 func Warnf(format string, args ...any) {
-	logger.Warnf(format, args...)
+	std.Warnf(format, args...)
 }
 
 // Errorf logs a message at level Error on the standard logger.
 func Errorf(format string, args ...any) {
-	logger.Errorf(format, args...)
+	std.Errorf(format, args...)
 }
 
-// Panicf logs a message at level Panic on the standard logger.
-func Panicf(format string, args ...any) {
-	logger.Panicf(format, args...)
+// WithField allocates a new entry and adds a field to it.
+func WithField(key string, value interface{}) Logger {
+	return std.WithField(key, value)
 }
 
-// Fatalf logs a message at level Fatal on the standard logger then the process will exit with status set to 1.
-func Fatalf(format string, args ...any) {
-	logger.Fatalf(format, args...)
+// WithFields adds a struct of fields to the logger. All it does is call `WithField` for each `Field`.
+func WithFields(fields Fields) Logger {
+	return std.WithFields(fields)
 }
 
-func Logf(level logrus.Level, format string, args ...interface{}) {
-	if logger.IsLevelEnabled(level) {
-		logger.Log(level, fmt.Sprintf(format, args...))
-	}
+// WithError adds an error to log entry, using the value defined in ErrorKey as key.
+func WithError(err error) Logger {
+	return std.WithError(err)
 }
 
-// WithField allocates a new entry and adds a field to it.
-func WithField(key string, value interface{}) *logrus.Entry {
-	return logger.WithField(key, value)
+// WithOptions
+func WithOptions(opts ...Option) Logger {
+	return std.WithOptions(opts...)
+}
+
+// SetOptions
+func SetOptions(opts ...Option) {
+	std.SetOptions(opts...)
 }
diff --git a/pkg/log/logger.go b/pkg/log/logger.go
new file mode 100644
index 0000000000..993180fc40
--- /dev/null
+++ b/pkg/log/logger.go
@@ -0,0 +1,308 @@
+package log
+
+import (
+	"context"
+	"io"
+	"time"
+
+	"github.com/sirupsen/logrus"
+)
+
+// Logger wraps the logrus package to have full control over implementing the required functionality,
+// such as adding or removing log levels etc. This also provides developers with an easier way to clone and set parameters.
+type Logger interface {
+	// Clone creates a new Logger instance with a copy of the fields from the current one.
+	Clone() Logger
+
+	// SetOptions sets the given options to the instance.
+	SetOptions(opts ...Option)
+
+	// WithOptions clones and sets the given options for the new instance.
+	// In other words, it is a combination of two methods, `log.Clone().SetOptions(...)`, but
+	// unlike `SetOptions(...)`, it returns the instance, which is convenient for further actions.
+	WithOptions(opts ...Option) Logger
+
+	// WithField adds a single field to the Logger and returns partly cloning instance, the `Entry` structure.
+	// This way the field is added to the returned instance only.
+	WithField(key string, value any) Logger
+
+	// WithFields adds a struct of fields to the Logger. All it does is call `WithField` for each `Field`.
+	WithFields(fields Fields) Logger
+
+	// WithError adds an error as single field to the Logger. The error is added to the returned instance only.
+	WithError(err error) Logger
+
+	// WithContext adds a context to the Logger. The context is added to the returned instance only.
+	WithContext(ctx context.Context) Logger
+
+	// WithTime overrides the time of the Logger. This only affects the returned instance.
+	WithTime(t time.Time) Logger
+
+	// Writer returns an io.Writer that writes to the Logger at the info log level.
+	Writer() *io.PipeWriter
+
+	// WriterLevel returns an io.Writer that writes to the Logger at the given log level.
+	WriterLevel(level Level) *io.PipeWriter
+
+	// Logf logs a message at the level given as parameter on the Logger.
+	Logf(level Level, format string, args ...any)
+
+	// Tracef logs a message at level Trace on the Logger.
+	Tracef(format string, args ...any)
+
+	// Debugf logs a message at level Debug on the Logger.
+	Debugf(format string, args ...any)
+
+	// Infof logs a message at level Info on the Logger.
+	Infof(format string, args ...any)
+
+	// Printf logs a message at level Info on the Logger.
+	Printf(format string, args ...any)
+
+	// Warnf logs a message at level Warn on the Logger.
+	Warnf(format string, args ...any)
+
+	// Errorf logs a message at level Error on the Logger.
+	Errorf(format string, args ...any)
+
+	// Log logs a message at the level given as parameter on the Logger.
+	Log(level Level, args ...any)
+
+	// Trace logs a message at level Trace on the Logger.
+	Trace(args ...any)
+
+	// Debug logs a message at level Debug on the Logger.
+	Debug(args ...any)
+
+	// Info logs a message at level Info on the Logger.
+	Info(args ...any)
+
+	// Print logs a message at level Info on the Logger.
+	Print(args ...any)
+
+	// Warn logs a message at level Warn on the Logger.
+	Warn(args ...any)
+
+	// Error logs a message at level Error on the Logger.
+	Error(args ...any)
+
+	// Logln logs a message at the level given as parameter on the Logger.
+	Logln(level Level, args ...any)
+
+	// Traceln logs a message at level Trace on the Logger.
+	Traceln(args ...any)
+
+	// Debugln logs a message at level Debug on the Logger.
+	Debugln(args ...any)
+
+	// Infoln logs a message at level Info on the Logger.
+	Infoln(args ...any)
+
+	// Println logs a message at level Info on the Logger.
+	Println(args ...any)
+
+	// Warnln logs a message at level Warn on the Logger.
+	Warnln(args ...any)
+
+	// Errorln logs a message at level Error on the Logger.
+	Errorln(args ...any)
+}
+
+type logger struct {
+	*logrus.Entry
+}
+
+// New returns a new Logger instance.
+func New(opts ...Option) Logger {
+	logger := &logger{
+		Entry: logrus.NewEntry(logrus.New()),
+	}
+	logger.SetOptions(opts...)
+
+	return logger
+}
+
+// Clone implements the Logger interface method.
+func (logger *logger) Clone() Logger {
+	return logger.clone()
+}
+
+// SetOptions implements the Logger interface method.
+func (logger *logger) SetOptions(opts ...Option) {
+	if len(opts) == 0 {
+		return
+	}
+
+	for _, opt := range opts {
+		opt(logger)
+	}
+}
+
+// WithOptions implements the Logger interface method.
+func (logger *logger) WithOptions(opts ...Option) Logger {
+	if len(opts) == 0 {
+		return logger
+	}
+
+	logger = logger.clone()
+	logger.SetOptions(opts...)
+
+	return logger
+}
+
+// WriterLevel implements the Logger interface method.
+func (logger *logger) WriterLevel(level Level) *io.PipeWriter {
+	return logger.Logger.WriterLevel(level.ToLogrusLevel())
+}
+
+// // WithField implements the Logger interface method.
+func (logger *logger) WithField(key string, value any) Logger {
+	return logger.WithFields(Fields{key: value})
+}
+
+// WithFields implements the Logger interface method.
+func (logger *logger) WithFields(fields Fields) Logger {
+	return logger.setEntry(logger.Entry.WithFields(logrus.Fields(fields)))
+}
+
+// WithError implements the Logger interface method.
+func (logger *logger) WithError(err error) Logger {
+	return logger.setEntry(logger.Entry.WithError(err))
+}
+
+// WithContext implements the Logger interface method.
+func (logger *logger) WithContext(ctx context.Context) Logger {
+	return logger.setEntry(logger.Entry.WithContext(ctx))
+}
+
+// WithTime implements the Logger interface method.
+func (logger *logger) WithTime(t time.Time) Logger {
+	return logger.setEntry(logger.Entry.WithTime(t))
+}
+
+// Logf implements the Logger interface method.
+func (logger *logger) Logf(level Level, format string, args ...any) {
+	logger.Entry.Logf(level.ToLogrusLevel(), format, args...)
+}
+
+// Log implements the Logger interface method.
+func (logger *logger) Log(level Level, args ...any) {
+	logger.Entry.Log(level.ToLogrusLevel(), args...)
+}
+
+// Logln implements the Logger interface method.
+func (logger *logger) Logln(level Level, args ...any) {
+	logger.Entry.Logln(level.ToLogrusLevel(), args...)
+}
+
+// Trace implements the Logger interface method.
+func (logger *logger) Trace(args ...interface{}) {
+	logger.Log(TraceLevel, args...)
+}
+
+// Debug implements the Logger interface method.
+func (logger *logger) Debug(args ...interface{}) {
+	logger.Log(DebugLevel, args...)
+}
+
+// Print implements the Logger interface method.
+func (logger *logger) Print(args ...interface{}) {
+	logger.Info(args...)
+}
+
+// Info implements the Logger interface method.
+func (logger *logger) Info(args ...interface{}) {
+	logger.Log(InfoLevel, args...)
+}
+
+// Warn implements the Logger interface method.
+func (logger *logger) Warn(args ...interface{}) {
+	logger.Log(WarnLevel, args...)
+}
+
+// Error implements the Logger interface method.
+func (logger *logger) Error(args ...interface{}) {
+	logger.Log(ErrorLevel, args...)
+}
+
+// Entry Printf family functions.
+
+// Tracef implements the Logger interface method.
+func (logger *logger) Tracef(format string, args ...interface{}) {
+	logger.Logf(TraceLevel, format, args...)
+}
+
+// Debugf implements the Logger interface method.
+func (logger *logger) Debugf(format string, args ...interface{}) {
+	logger.Logf(DebugLevel, format, args...)
+}
+
+// Infof implements the Logger interface method.
+func (logger *logger) Infof(format string, args ...interface{}) {
+	logger.Logf(InfoLevel, format, args...)
+}
+
+// Printf implements the Logger interface method.
+func (logger *logger) Printf(format string, args ...interface{}) {
+	logger.Infof(format, args...)
+}
+
+// Warnf implements the Logger interface method.
+func (logger *logger) Warnf(format string, args ...interface{}) {
+	logger.Logf(WarnLevel, format, args...)
+}
+
+// Errorf implements the Logger interface method.
+func (logger *logger) Errorf(format string, args ...interface{}) {
+	logger.Logf(ErrorLevel, format, args...)
+}
+
+// Entry Println family functions
+
+// Traceln implements the Logger interface method.
+func (logger *logger) Traceln(args ...interface{}) {
+	logger.Logln(TraceLevel, args...)
+}
+
+// Debugln implements the Logger interface method.
+func (logger *logger) Debugln(args ...interface{}) {
+	logger.Logln(DebugLevel, args...)
+}
+
+// Infoln implements the Logger interface method.
+func (logger *logger) Infoln(args ...interface{}) {
+	logger.Logln(InfoLevel, args...)
+}
+
+// Println implements the Logger interface method.
+func (logger *logger) Println(args ...interface{}) {
+	logger.Infoln(args...)
+}
+
+// Warnln implements the Logger interface method.
+func (logger *logger) Warnln(args ...interface{}) {
+	logger.Logln(WarnLevel, args...)
+}
+
+// Errorln implements the Logger interface method.
+func (logger *logger) Errorln(args ...interface{}) {
+	logger.Logln(ErrorLevel, args...)
+}
+
+func (logger logger) setEntry(entry *logrus.Entry) *logger {
+	logger.Entry = entry
+	return &logger
+}
+
+func (logger logger) clone() *logger {
+	parentLogger := logger.Logger
+
+	logger.Logger = logrus.New()
+	logger.Logger.SetOutput(parentLogger.Out)
+	logger.Logger.SetLevel(parentLogger.Level)
+	logger.Logger.SetFormatter(parentLogger.Formatter)
+	logger.Logger.ReplaceHooks(parentLogger.Hooks)
+	logger.Entry = logger.Entry.Dup()
+
+	return &logger
+}
diff --git a/pkg/log/options.go b/pkg/log/options.go
new file mode 100644
index 0000000000..796ca242c2
--- /dev/null
+++ b/pkg/log/options.go
@@ -0,0 +1,40 @@
+package log
+
+import (
+	"io"
+
+	"github.com/sirupsen/logrus"
+)
+
+// Option is a function to set options for logger.
+type Option func(logger *logger)
+
+// WithLevel sets the logger level.
+func WithLevel(level Level) Option {
+	return func(logger *logger) {
+		logger.Logger.SetLevel(level.ToLogrusLevel())
+	}
+}
+
+// WithOutput sets the logger output.
+func WithOutput(output io.Writer) Option {
+	return func(logger *logger) {
+		logger.Logger.SetOutput(output)
+	}
+}
+
+// WithFormatter sets the logger formatter.
+func WithFormatter(formatter logrus.Formatter) Option {
+	return func(logger *logger) {
+		logger.Logger.SetFormatter(formatter)
+	}
+}
+
+// WithHooks adds hooks to the logger hooks.
+func WithHooks(hooks ...logrus.Hook) Option {
+	return func(logger *logger) {
+		for _, hook := range hooks {
+			logger.Logger.AddHook(hook)
+		}
+	}
+}
diff --git a/pkg/log/util.go b/pkg/log/util.go
new file mode 100644
index 0000000000..071d0880d5
--- /dev/null
+++ b/pkg/log/util.go
@@ -0,0 +1,42 @@
+package log
+
+import (
+	"os"
+	"regexp"
+	"strings"
+)
+
+const (
+	CurDir              = "."
+	CurDirWithSeparator = CurDir + string(os.PathSeparator)
+
+	// startASNISeq is the ANSI start escape sequence
+	startASNISeq = "\033["
+	// resetANSISeq is the ANSI reset escape sequence
+	resetANSISeq = "\033[0m"
+
+	ansiSeq = "[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))"
+)
+
+var (
+	// regexp matches ansi characters getting from a shell output, used for colors etc.
+	ansiReg = regexp.MustCompile(ansiSeq)
+)
+
+// RemoveAllASCISeq returns a string with all ASCII color characters removed.
+func RemoveAllASCISeq(str string) string {
+	if strings.Contains(str, startASNISeq) {
+		str = ansiReg.ReplaceAllString(str, "")
+	}
+
+	return str
+}
+
+// ResetASCISeq returns a string with the ASCI color reset to the default one.
+func ResetASCISeq(str string) string {
+	if strings.Contains(str, startASNISeq) {
+		str += resetANSISeq
+	}
+
+	return str
+}
diff --git a/pkg/log/writer/options.go b/pkg/log/writer/options.go
new file mode 100644
index 0000000000..87219bd007
--- /dev/null
+++ b/pkg/log/writer/options.go
@@ -0,0 +1,34 @@
+package writer
+
+import "github.com/gruntwork-io/terragrunt/pkg/log"
+
+// Option is a function to set options for Writer.
+type Option func(writer *Writer)
+
+// WithLogger sets Logger to the Writer.
+func WithLogger(logger log.Logger) Option {
+	return func(writer *Writer) {
+		writer.logger = logger
+	}
+}
+
+// WithLogger sets the default log level for Writer in case the log level cannot be extracted from the message.
+func WithDefaultLevel(level log.Level) Option {
+	return func(writer *Writer) {
+		writer.defaultLevel = level
+	}
+}
+
+// WithMsgSeparator configures Writer to split the received text into string and log them as separate records.
+func WithMsgSeparator(sep string) Option {
+	return func(writer *Writer) {
+		writer.msgSeparator = sep
+	}
+}
+
+// WithParseFunc sets the parser func.
+func WithParseFunc(fn WriterParseFunc) Option {
+	return func(writer *Writer) {
+		writer.parseFunc = fn
+	}
+}
diff --git a/pkg/log/writer/writer.go b/pkg/log/writer/writer.go
new file mode 100644
index 0000000000..ca2ccf0fb6
--- /dev/null
+++ b/pkg/log/writer/writer.go
@@ -0,0 +1,78 @@
+package writer
+
+import (
+	"strings"
+	"time"
+
+	"github.com/gruntwork-io/terragrunt/pkg/log"
+)
+
+// WriterParseFunc is a function used to parse records to extract the time and level from them.
+type WriterParseFunc func(str string) (msg string, time *time.Time, level *log.Level, err error)
+
+// Writer redirects Write requests to configured logger and level
+type Writer struct {
+	logger       log.Logger
+	defaultLevel log.Level
+	msgSeparator string
+	parseFunc    WriterParseFunc
+}
+
+// New returns a new Writer instance with fields assigned to default values.
+func New(opts ...Option) *Writer {
+	writer := &Writer{
+		logger:       log.Default(),
+		defaultLevel: log.InfoLevel,
+		parseFunc:    func(str string) (msg string, time *time.Time, level *log.Level, err error) { return str, nil, nil, nil },
+	}
+	writer.SetOption(opts...)
+
+	return writer
+}
+
+// SetOption sets options to the `Writer`.
+func (writer *Writer) SetOption(opts ...Option) {
+	for _, opt := range opts {
+		opt(writer)
+	}
+}
+
+// Write implements `io.Writer` interafce.
+func (writer *Writer) Write(p []byte) (n int, err error) {
+	var (
+		str  = string(p)
+		strs = []string{str}
+	)
+
+	if writer.msgSeparator != "" {
+		strs = strings.Split(str, writer.msgSeparator)
+	}
+
+	for _, str := range strs {
+		if len(str) == 0 {
+			continue
+		}
+
+		msg, time, level, err := writer.parseFunc(str)
+		if err != nil {
+			return 0, err
+		}
+
+		// Reset ANSI styles at the end of a line so that the new line does not inherit them
+		msg = log.ResetASCISeq(msg)
+
+		logger := writer.logger
+
+		if time != nil {
+			logger = logger.WithTime(*time)
+		}
+
+		if level == nil {
+			level = &writer.defaultLevel
+		}
+
+		logger.Log(*level, msg)
+	}
+
+	return len(p), nil
+}
diff --git a/remote/remote_state_gcs.go b/remote/remote_state_gcs.go
index 8db8e478dc..b34b5b84b0 100644
--- a/remote/remote_state_gcs.go
+++ b/remote/remote_state_gcs.go
@@ -15,10 +15,10 @@ import (
 	"cloud.google.com/go/storage"
 	"github.com/gruntwork-io/go-commons/errors"
 	"github.com/gruntwork-io/terragrunt/options"
+	"github.com/gruntwork-io/terragrunt/pkg/log"
 	"github.com/gruntwork-io/terragrunt/shell"
 	"github.com/gruntwork-io/terragrunt/util"
 	"github.com/mitchellh/mapstructure"
-	"github.com/sirupsen/logrus"
 	"golang.org/x/oauth2"
 	"golang.org/x/oauth2/jwt"
 	"google.golang.org/api/option"
@@ -301,7 +301,7 @@ func createGCSBucketIfNecessary(ctx context.Context, gcsClient *storage.Client,
 
 		prompt := fmt.Sprintf("Remote state GCS bucket %s does not exist or you don't have permissions to access it. Would you like Terragrunt to create it?", config.remoteStateConfigGCS.Bucket)
 
-		shouldCreateBucket, err := shell.PromptUserForYesNo(prompt, terragruntOptions)
+		shouldCreateBucket, err := shell.PromptUserForYesNo(ctx, prompt, terragruntOptions)
 		if err != nil {
 			return err
 		}
@@ -310,7 +310,7 @@ func createGCSBucketIfNecessary(ctx context.Context, gcsClient *storage.Client,
 			// To avoid any eventual consistency issues with creating a GCS bucket we use a retry loop.
 			description := "Create GCS bucket " + config.remoteStateConfigGCS.Bucket
 
-			return util.DoWithRetry(ctx, description, gcpMaxRetries, gcpSleepBetweenRetries, logrus.DebugLevel, func(ctx context.Context) error {
+			return util.DoWithRetry(ctx, description, gcpMaxRetries, gcpSleepBetweenRetries, terragruntOptions.Logger, log.DebugLevel, func(ctx context.Context) error {
 				// TODO: Remove lint suppression
 				return CreateGCSBucketWithVersioning(gcsClient, config, terragruntOptions) //nolint:contextcheck
 			})
diff --git a/remote/remote_state_s3.go b/remote/remote_state_s3.go
index cf53c5a7da..4fd39164e1 100644
--- a/remote/remote_state_s3.go
+++ b/remote/remote_state_s3.go
@@ -15,10 +15,10 @@ import (
 	"github.com/gruntwork-io/terragrunt/aws_helper"
 	"github.com/gruntwork-io/terragrunt/dynamodb"
 	"github.com/gruntwork-io/terragrunt/options"
+	"github.com/gruntwork-io/terragrunt/pkg/log"
 	"github.com/gruntwork-io/terragrunt/shell"
 	"github.com/gruntwork-io/terragrunt/util"
 	"github.com/mitchellh/mapstructure"
-	"github.com/sirupsen/logrus"
 )
 
 const (
@@ -386,7 +386,7 @@ func (s3Initializer S3Initializer) Initialize(ctx context.Context, remoteState *
 		}
 
 		if !terragruntOptions.DisableBucketUpdate && !s3ConfigExtended.DisableBucketUpdate {
-			if err := updateS3BucketIfNecessary(s3Client, s3ConfigExtended, terragruntOptions); err != nil {
+			if err := updateS3BucketIfNecessary(ctx, s3Client, s3ConfigExtended, terragruntOptions); err != nil {
 				return errors.WithStackTrace(err)
 			}
 		}
@@ -506,7 +506,7 @@ func createS3BucketIfNecessary(ctx context.Context, s3Client *s3.S3, config *Ext
 
 	prompt := fmt.Sprintf("Remote state S3 bucket %s does not exist or you don't have permissions to access it. Would you like Terragrunt to create it?", config.RemoteStateConfigS3.Bucket)
 
-	shouldCreateBucket, err := shell.PromptUserForYesNo(prompt, terragruntOptions)
+	shouldCreateBucket, err := shell.PromptUserForYesNo(ctx, prompt, terragruntOptions)
 	if err != nil {
 		return err
 	}
@@ -520,8 +520,8 @@ func createS3BucketIfNecessary(ctx context.Context, s3Client *s3.S3, config *Ext
 		// been performed should be a no-op.
 		description := "Create S3 bucket with retry " + config.RemoteStateConfigS3.Bucket
 
-		return util.DoWithRetry(ctx, description, s3MaxRetries, s3SleepBetweenRetries, logrus.DebugLevel, func(ctx context.Context) error {
-			err := CreateS3BucketWithVersioningSSEncryptionAndAccessLogging(s3Client, config, terragruntOptions)
+		return util.DoWithRetry(ctx, description, s3MaxRetries, s3SleepBetweenRetries, terragruntOptions.Logger, log.DebugLevel, func(ctx context.Context) error {
+			err := CreateS3BucketWithVersioningSSEncryptionAndAccessLogging(ctx, s3Client, config, terragruntOptions)
 			if err != nil {
 				if isBucketCreationErrorRetriable(err) {
 					return err
@@ -537,7 +537,7 @@ func createS3BucketIfNecessary(ctx context.Context, s3Client *s3.S3, config *Ext
 	return nil
 }
 
-func updateS3BucketIfNecessary(s3Client *s3.S3, config *ExtendedRemoteStateConfigS3, terragruntOptions *options.TerragruntOptions) error {
+func updateS3BucketIfNecessary(ctx context.Context, s3Client *s3.S3, config *ExtendedRemoteStateConfigS3, terragruntOptions *options.TerragruntOptions) error {
 	if !DoesS3BucketExist(s3Client, &config.RemoteStateConfigS3.Bucket) {
 		if terragruntOptions.FailIfBucketCreationRequired {
 			return BucketCreationNotAllowed(config.RemoteStateConfigS3.Bucket)
@@ -558,7 +558,7 @@ func updateS3BucketIfNecessary(s3Client *s3.S3, config *ExtendedRemoteStateConfi
 
 	prompt := fmt.Sprintf("Remote state S3 bucket %s is out of date. Would you like Terragrunt to update it?", config.RemoteStateConfigS3.Bucket)
 
-	shouldUpdateBucket, err := shell.PromptUserForYesNo(prompt, terragruntOptions)
+	shouldUpdateBucket, err := shell.PromptUserForYesNo(ctx, prompt, terragruntOptions)
 	if err != nil {
 		return err
 	}
@@ -618,7 +618,7 @@ func updateS3BucketIfNecessary(s3Client *s3.S3, config *ExtendedRemoteStateConfi
 			terragruntOptions.Logger.Debugf("Access logging is disabled for the remote state AWS S3 bucket %s using 'skip_bucket_access_logging' config.", config.RemoteStateConfigS3.Bucket)
 		} else {
 			if config.AccessLoggingBucketName != "" {
-				if err := configureAccessLogBucket(terragruntOptions, s3Client, config); err != nil {
+				if err := configureAccessLogBucket(ctx, terragruntOptions, s3Client, config); err != nil {
 					// TODO: Remove lint suppression
 					return nil //nolint:nilerr
 				}
@@ -640,10 +640,10 @@ func updateS3BucketIfNecessary(s3Client *s3.S3, config *ExtendedRemoteStateConfi
 }
 
 // configureAccessLogBucket - configure access log bucket.
-func configureAccessLogBucket(terragruntOptions *options.TerragruntOptions, s3Client *s3.S3, config *ExtendedRemoteStateConfigS3) error {
+func configureAccessLogBucket(ctx context.Context, terragruntOptions *options.TerragruntOptions, s3Client *s3.S3, config *ExtendedRemoteStateConfigS3) error {
 	terragruntOptions.Logger.Debugf("Enabling bucket-wide Access Logging on AWS S3 bucket %s - using as TargetBucket %s", config.RemoteStateConfigS3.Bucket, config.AccessLoggingBucketName)
 
-	if err := CreateLogsS3BucketIfNecessary(s3Client, aws.String(config.AccessLoggingBucketName), terragruntOptions); err != nil {
+	if err := CreateLogsS3BucketIfNecessary(ctx, s3Client, aws.String(config.AccessLoggingBucketName), terragruntOptions); err != nil {
 		terragruntOptions.Logger.Errorf("Could not create logs bucket %s for AWS S3 bucket %s\n%s", config.AccessLoggingBucketName, config.RemoteStateConfigS3.Bucket, err.Error())
 		return errors.WithStackTrace(err)
 	}
@@ -804,7 +804,7 @@ func checkIfVersioningEnabled(s3Client *s3.S3, config *RemoteStateConfigS3, terr
 }
 
 // Create the given S3 bucket and enable versioning for it
-func CreateS3BucketWithVersioningSSEncryptionAndAccessLogging(s3Client *s3.S3, config *ExtendedRemoteStateConfigS3, terragruntOptions *options.TerragruntOptions) error {
+func CreateS3BucketWithVersioningSSEncryptionAndAccessLogging(ctx context.Context, s3Client *s3.S3, config *ExtendedRemoteStateConfigS3, terragruntOptions *options.TerragruntOptions) error {
 	terragruntOptions.Logger.Debugf("Create S3 bucket %s with versioning, SSE encryption, and access logging.", config.RemoteStateConfigS3.Bucket)
 
 	err := CreateS3Bucket(s3Client, aws.String(config.RemoteStateConfigS3.Bucket), terragruntOptions)
@@ -865,7 +865,7 @@ func CreateS3BucketWithVersioningSSEncryptionAndAccessLogging(s3Client *s3.S3, c
 	}
 
 	if config.AccessLoggingBucketName != "" {
-		if err := configureAccessLogBucket(terragruntOptions, s3Client, config); err != nil {
+		if err := configureAccessLogBucket(ctx, terragruntOptions, s3Client, config); err != nil {
 			// TODO: Remove lint suppression
 			return nil //nolint:nilerr
 		}
@@ -880,7 +880,7 @@ func CreateS3BucketWithVersioningSSEncryptionAndAccessLogging(s3Client *s3.S3, c
 	return nil
 }
 
-func CreateLogsS3BucketIfNecessary(s3Client *s3.S3, logsBucketName *string, terragruntOptions *options.TerragruntOptions) error {
+func CreateLogsS3BucketIfNecessary(ctx context.Context, s3Client *s3.S3, logsBucketName *string, terragruntOptions *options.TerragruntOptions) error {
 	if !DoesS3BucketExist(s3Client, logsBucketName) {
 		if terragruntOptions.FailIfBucketCreationRequired {
 			return BucketCreationNotAllowed(*logsBucketName)
@@ -888,7 +888,7 @@ func CreateLogsS3BucketIfNecessary(s3Client *s3.S3, logsBucketName *string, terr
 
 		prompt := fmt.Sprintf("Logs S3 bucket %s for the remote state does not exist or you don't have permissions to access it. Would you like Terragrunt to create it?", *logsBucketName)
 
-		shouldCreateBucket, err := shell.PromptUserForYesNo(prompt, terragruntOptions)
+		shouldCreateBucket, err := shell.PromptUserForYesNo(ctx, prompt, terragruntOptions)
 		if err != nil {
 			return err
 		}
diff --git a/shell/error_explainer.go b/shell/error_explainer.go
index 301f5f24aa..58451942f5 100644
--- a/shell/error_explainer.go
+++ b/shell/error_explainer.go
@@ -56,7 +56,7 @@ func ExplainError(err error) string {
 		var processError util.ProcessExecutionError
 		if ok := goErrors.As(originalError, &processError); ok {
 			errorOutput := processError.Stderr
-			stdOut := processError.StdOut
+			stdOut := processError.Stdout
 			message = fmt.Sprintf("%s\n%s", stdOut, errorOutput)
 		}
 
diff --git a/shell/prompt.go b/shell/prompt.go
index 632aee99a5..509cfad053 100644
--- a/shell/prompt.go
+++ b/shell/prompt.go
@@ -2,6 +2,7 @@ package shell
 
 import (
 	"bufio"
+	"context"
 	"os"
 	"strings"
 
@@ -10,7 +11,7 @@ import (
 )
 
 // Prompt the user for text in the CLI. Returns the text entered by the user.
-func PromptUserForInput(prompt string, terragruntOptions *options.TerragruntOptions) (string, error) {
+func PromptUserForInput(ctx context.Context, prompt string, terragruntOptions *options.TerragruntOptions) (string, error) {
 	// We are writing directly to ErrWriter so the prompt is always visible
 	// no matter what logLevel is configured. If `--non-interactive` is set, we log both prompt and
 	// a message about assuming `yes` to Debug, so
@@ -34,18 +35,31 @@ func PromptUserForInput(prompt string, terragruntOptions *options.TerragruntOpti
 
 	reader := bufio.NewReader(os.Stdin)
 
-	text, err := reader.ReadString('\n')
-	if err != nil {
-		return "", errors.WithStackTrace(err)
-	}
+	inputCh := make(chan string)
+	errCh := make(chan error)
 
-	return strings.TrimSpace(text), nil
+	go func() {
+		input, err := reader.ReadString('\n')
+		if err != nil {
+			errCh <- errors.WithStackTrace(err)
+			return
+		}
+		inputCh <- strings.TrimSpace(input)
+	}()
+
+	select {
+	case <-ctx.Done():
+		return "", ctx.Err()
+	case err := <-errCh:
+		return "", err
+	case input := <-inputCh:
+		return input, nil
+	}
 }
 
 // Prompt the user for a yes/no response and return true if they entered yes.
-func PromptUserForYesNo(prompt string, terragruntOptions *options.TerragruntOptions) (bool, error) {
-	resp, err := PromptUserForInput(prompt+" (y/n) ", terragruntOptions)
-
+func PromptUserForYesNo(ctx context.Context, prompt string, terragruntOptions *options.TerragruntOptions) (bool, error) {
+	resp, err := PromptUserForInput(ctx, prompt+" (y/n) ", terragruntOptions)
 	if err != nil {
 		return false, errors.WithStackTrace(err)
 	}
diff --git a/shell/run_shell_cmd.go b/shell/run_shell_cmd.go
index 9599365e34..1a368a1b50 100644
--- a/shell/run_shell_cmd.go
+++ b/shell/run_shell_cmd.go
@@ -9,23 +9,27 @@ import (
 	"os"
 	"os/exec"
 	"os/signal"
+	"path/filepath"
 	"strings"
 	"time"
 
 	"github.com/gruntwork-io/terragrunt/internal/cache"
 
 	"github.com/gruntwork-io/terragrunt/engine"
-	"github.com/gruntwork-io/terragrunt/internal/log"
+	"github.com/gruntwork-io/terragrunt/pkg/cli"
+	"github.com/gruntwork-io/terragrunt/pkg/log"
+	"github.com/gruntwork-io/terragrunt/pkg/log/format"
+	"github.com/gruntwork-io/terragrunt/pkg/log/writer"
 	"github.com/gruntwork-io/terragrunt/terraform"
 
 	"github.com/gruntwork-io/terragrunt/telemetry"
 
 	"github.com/hashicorp/go-version"
 
+	"github.com/gruntwork-io/go-commons/collections"
 	"github.com/gruntwork-io/go-commons/errors"
 	"github.com/gruntwork-io/terragrunt/options"
 	"github.com/gruntwork-io/terragrunt/util"
-	"github.com/sirupsen/logrus"
 )
 
 // The signal can be sent to the main process (only `terragrunt`) as well as the process group (`terragrunt` and `terraform`), for example:
@@ -39,6 +43,16 @@ const (
 	refsTags  = "refs/tags/"
 
 	tagSplitPart = 2
+
+	logMsgSeparator = "\n"
+)
+
+const (
+	// tfLogMsgPrefix is a message prefix that is prepended to each TF_LOG output lines when the output is integrated in TG log, for example:
+	//
+	// TF_LOG: using github.com/zclconf/go-cty v1.14.3
+	// TF_LOG: Go runtime version: go1.22.1
+	tfLogMsgPrefix = "TF_LOG: "
 )
 
 // Commands that implement a REPL need a pseudo TTY when run as a subprocess in order for the readline properties to be
@@ -82,16 +96,16 @@ func RunTerraformCommandWithOutput(ctx context.Context, terragruntOptions *optio
 // `workingDir`. Terragrunt working directory will be assumed if empty string.
 func RunShellCommandWithOutput(
 	ctx context.Context,
-	terragruntOptions *options.TerragruntOptions,
+	opts *options.TerragruntOptions,
 	workingDir string,
 	suppressStdout bool,
 	allocatePseudoTty bool,
 	command string,
 	args ...string,
 ) (*util.CmdOutput, error) {
-	if command == terragruntOptions.TerraformPath {
+	if command == opts.TerraformPath {
 		if fn := TerraformCommandHookFromContext(ctx); fn != nil {
-			return fn(ctx, terragruntOptions, args)
+			return fn(ctx, opts, args)
 		}
 	}
 
@@ -101,54 +115,54 @@ func RunShellCommandWithOutput(
 	)
 
 	if workingDir == "" {
-		commandDir = terragruntOptions.WorkingDir
+		commandDir = opts.WorkingDir
 	}
 
-	err := telemetry.Telemetry(ctx, terragruntOptions, "run_"+command, map[string]interface{}{
+	err := telemetry.Telemetry(ctx, opts, "run_"+command, map[string]interface{}{
 		"command": command,
 		"args":    fmt.Sprintf("%v", args),
 		"dir":     commandDir,
 	}, func(childCtx context.Context) error {
-		terragruntOptions.Logger.Debugf("Running command: %s %s", command, strings.Join(args, " "))
+		opts.Logger.Debugf("Running command: %s %s", command, strings.Join(args, " "))
 
 		cmd := exec.Command(command, args...)
 
-		// TODO: consider adding prefix from terragruntOptions logger to stdout and stderr
-		cmd.Env = toEnvVarsList(terragruntOptions.Env)
+		// TODO: consider adding prefix from opts logger to stdout and stderr
+		cmd.Env = toEnvVarsList(opts.Env)
 		cmd.Dir = commandDir
 
 		var (
-			outWriter = terragruntOptions.Writer
-			errWriter = terragruntOptions.ErrWriter
+			outWriter = opts.Writer
+			errWriter = opts.ErrWriter
 		)
 
 		// redirect output through logger with json wrapping
-		if terragruntOptions.JsonLogFormat && terragruntOptions.TerraformLogsToJson {
-			jsonWriter := terragruntOptions.Logger.Logger.WithField("workingDir", terragruntOptions.WorkingDir).WithField("executedCommandArgs", args)
-			jsonWriter.Logger.Out = outWriter
-			outWriter = jsonWriter.Writer()
-
-			jsonErrorWriter := terragruntOptions.Logger.Logger.WithField("workingDir", terragruntOptions.WorkingDir).WithField("executedCommandArgs", args)
-			jsonErrorWriter.Logger.Out = errWriter
-			errWriter = jsonErrorWriter.WriterLevel(logrus.ErrorLevel)
-		} else {
-			errWriter = log.TFStderrWriter(
-				errWriter,
-				terragruntOptions.Logger.Logger.Formatter,
-				terragruntOptions.OutputPrefix,
-				terragruntOptions.TerraformPath,
-			)
-
-			if terragruntOptions.ForwardTFStdout || shouldForceForwardTFStdout(args) {
-				outWriter = util.WriterNotifier(outWriter, func(p []byte) {
-					terragruntOptions.Logger.Infof("Retrieved output from %s", terragruntOptions.RelativeTerragruntConfigPath)
-				})
+		if opts.JsonLogFormat && opts.TerraformLogsToJson {
+			logger := opts.Logger.WithField("workingDir", opts.WorkingDir).WithField("executedCommandArgs", args)
+			outWriter = logger.WithOptions(log.WithOutput(errWriter)).Writer()
+			errWriter = logger.WithOptions(log.WithOutput(errWriter)).WriterLevel(log.ErrorLevel)
+		} else if command == opts.TerraformPath {
+			if opts.ForwardTFStdout || shouldForceForwardTFStdout(args) {
+				// We only display the output receipt notification when we show it to the user, and do nothing when we hide it, for example when `outWriter` is io.Discard.
+				if _, ok := outWriter.(*os.File); ok {
+					outWriter = util.WriterNotifier(outWriter, func(p []byte) {
+						opts.Logger.Infof("Retrieved output from %s", opts.TerraformPath)
+					})
+				}
 			} else {
-				outWriter = log.TFStdoutWriter(
-					outWriter,
-					terragruntOptions.Logger.Logger.Formatter,
-					terragruntOptions.OutputPrefix,
-					terragruntOptions.TerraformPath,
+				logger := opts.Logger.WithField(format.TFBinaryKeyName, filepath.Base(opts.TerraformPath))
+
+				outWriter = writer.New(
+					writer.WithLogger(logger.WithOptions(log.WithOutput(outWriter))),
+					writer.WithDefaultLevel(log.StdoutLevel),
+					writer.WithMsgSeparator(logMsgSeparator),
+				)
+
+				errWriter = writer.New(
+					writer.WithLogger(logger.WithOptions(log.WithOutput(errWriter))),
+					writer.WithDefaultLevel(log.StderrLevel),
+					writer.WithMsgSeparator(logMsgSeparator),
+					writer.WithParseFunc(terraform.ParseLogFunc(tfLogMsgPrefix)),
 				)
 			}
 		}
@@ -162,23 +176,23 @@ func RunShellCommandWithOutput(
 		)
 
 		if suppressStdout {
-			terragruntOptions.Logger.Debugf("Command output will be suppressed.")
+			opts.Logger.Debugf("Command output will be suppressed.")
 
 			cmdStdout = io.MultiWriter(&stdoutBuf)
 		}
 
-		if command == terragruntOptions.TerraformPath && terragruntOptions.Engine != nil && !engine.IsEngineEnabled() {
-			terragruntOptions.Logger.Debugf("Engine is not enabled, running command directly in %s", commandDir)
+		if command == opts.TerraformPath && opts.Engine != nil && !engine.IsEngineEnabled() {
+			opts.Logger.Debugf("Engine is not enabled, running command directly in %s", commandDir)
 		}
 
-		useEngine := terragruntOptions.Engine != nil && engine.IsEngineEnabled()
+		useEngine := opts.Engine != nil && engine.IsEngineEnabled()
 
 		// If the engine is enabled and the command is IaC executable, use the engine to run the command.
-		if useEngine && command == terragruntOptions.TerraformPath {
-			terragruntOptions.Logger.Debugf("Using engine to run command: %s %s", command, strings.Join(args, " "))
+		if useEngine && command == opts.TerraformPath {
+			opts.Logger.Debugf("Using engine to run command: %s %s", command, strings.Join(args, " "))
 
 			cmdOutput, err := engine.Run(ctx, &engine.ExecutionOptions{
-				TerragruntOptions: terragruntOptions,
+				TerragruntOptions: opts,
 				CmdStdout:         cmdStdout,
 				CmdStderr:         cmdStderr,
 				WorkingDir:        cmd.Dir,
@@ -199,7 +213,7 @@ func RunShellCommandWithOutput(
 		// If we need to allocate a ptty for the command, route through the ptty routine. Otherwise, directly call the
 		// command.
 		if allocatePseudoTty {
-			if err := runCommandWithPTTY(terragruntOptions, cmd, cmdStdout, cmdStderr); err != nil {
+			if err := runCommandWithPTTY(opts, cmd, cmdStdout, cmdStderr); err != nil {
 				return err
 			}
 		} else {
@@ -215,12 +229,12 @@ func RunShellCommandWithOutput(
 
 		// Make sure to forward signals to the subcommand.
 		cmdChannel := make(chan error) // used for closing the signals forwarder goroutine
-		signalChannel := NewSignalsForwarder(InterruptSignals, cmd, terragruntOptions.Logger, cmdChannel)
+		signalChannel := NewSignalsForwarder(InterruptSignals, cmd, opts.Logger, cmdChannel)
 
 		defer func(signalChannel *SignalsForwarder) {
 			err := signalChannel.Close()
 			if err != nil {
-				terragruntOptions.Logger.Warnf("Error closing signal channel: %v", err)
+				opts.Logger.Warnf("Error closing signal channel: %v", err)
 			}
 		}(&signalChannel)
 
@@ -235,7 +249,7 @@ func RunShellCommandWithOutput(
 		if err != nil {
 			err = util.ProcessExecutionError{
 				Err:        err,
-				StdOut:     stdoutBuf.String(),
+				Stdout:     stdoutBuf.String(),
 				Stderr:     stderrBuf.String(),
 				WorkingDir: cmd.Dir,
 			}
@@ -278,7 +292,7 @@ func isTerraformCommandThatNeedsPty(args []string) (bool, error) {
 type SignalsForwarder chan os.Signal
 
 // NewSignalsForwarder Forwards signals to a command, waiting for the command to finish.
-func NewSignalsForwarder(signals []os.Signal, c *exec.Cmd, logger *logrus.Entry, cmdChannel chan error) SignalsForwarder {
+func NewSignalsForwarder(signals []os.Signal, c *exec.Cmd, logger log.Logger, cmdChannel chan error) SignalsForwarder {
 	signalChannel := make(chan os.Signal, 1)
 	signal.Notify(signalChannel, signals...)
 
@@ -430,13 +444,14 @@ func extractSemVerTags(tags []string) []*version.Version {
 	return semverTags
 }
 
-// shouldForceForwardTFStdout returns true if args contains `-json` flag or `output` command is specified as the first arg.
-func shouldForceForwardTFStdout(args []string) bool {
-	for i, arg := range args {
-		if (i == 0 && strings.EqualFold(arg, terraform.CommandNameOutput)) || strings.EqualFold(arg, terraform.FlagNameJSON) {
-			return true
-		}
+// shouldForceForwardTFStdout returns true if at least one of the conditions is met, args contains the `-json` flag or the `output` or `state` command.
+func shouldForceForwardTFStdout(args cli.Args) bool {
+	tfCommands := []string{
+		terraform.CommandNameOutput,
+		terraform.CommandNameState,
+		terraform.CommandNameVersion,
+		terraform.CommandNameConsole,
 	}
 
-	return false
+	return collections.ListContainsElement(tfCommands, args.CommandName()) || args.Tail().Contains(terraform.FlagNameJSON)
 }
diff --git a/terraform/cache/config.go b/terraform/cache/config.go
index 81ceb7b067..0963c1b500 100644
--- a/terraform/cache/config.go
+++ b/terraform/cache/config.go
@@ -5,6 +5,7 @@ import (
 	"strconv"
 	"time"
 
+	"github.com/gruntwork-io/terragrunt/pkg/log"
 	"github.com/gruntwork-io/terragrunt/terraform/cache/handlers"
 	"github.com/gruntwork-io/terragrunt/terraform/cache/services"
 )
@@ -57,6 +58,13 @@ func WithProviderHandlers(handlers ...handlers.ProviderHandler) Option {
 	}
 }
 
+func WithLogger(logger log.Logger) Option {
+	return func(cfg Config) Config {
+		cfg.logger = logger
+		return cfg
+	}
+}
+
 type Config struct {
 	hostname        string
 	port            int
@@ -65,12 +73,15 @@ type Config struct {
 
 	services         []services.Service
 	providerHandlers []handlers.ProviderHandler
+
+	logger log.Logger
 }
 
 func NewConfig(opts ...Option) *Config {
 	cfg := &Config{
 		hostname:        defaultHostname,
 		shutdownTimeout: defaultShutdownTimeout,
+		logger:          log.Default(),
 	}
 
 	return cfg.WithOptions(opts...)
diff --git a/terraform/cache/handlers/provider_direct.go b/terraform/cache/handlers/provider_direct.go
index 94c29486c3..f515bcdc9c 100644
--- a/terraform/cache/handlers/provider_direct.go
+++ b/terraform/cache/handlers/provider_direct.go
@@ -9,7 +9,6 @@ import (
 	"strconv"
 	"strings"
 
-	"github.com/gruntwork-io/terragrunt/internal/log"
 	"github.com/gruntwork-io/terragrunt/terraform/cache/helpers"
 	"github.com/gruntwork-io/terragrunt/terraform/cache/models"
 	"github.com/gruntwork-io/terragrunt/terraform/cache/router"
@@ -47,7 +46,7 @@ type ProviderDirectHandler struct {
 func NewProviderDirectHandler(providerService *services.ProviderService, cacheProviderHTTPStatusCode int, method *cliconfig.ProviderInstallationDirect, credsSource *cliconfig.CredentialsSource) *ProviderDirectHandler {
 	return &ProviderDirectHandler{
 		CommonProviderHandler:       NewCommonProviderHandler(method.Include, method.Exclude),
-		ReverseProxy:                &ReverseProxy{CredsSource: credsSource},
+		ReverseProxy:                &ReverseProxy{CredsSource: credsSource, logger: providerService.Logger()},
 		providerService:             providerService,
 		cacheProviderHTTPStatusCode: cacheProviderHTTPStatusCode,
 	}
@@ -97,7 +96,7 @@ func (handler *ProviderDirectHandler) GetPlatform(ctx echo.Context, provider *mo
 func (handler *ProviderDirectHandler) Download(ctx echo.Context, provider *models.Provider) error {
 	if cache := handler.providerService.GetProviderCache(provider); cache != nil {
 		if path := cache.ArchivePath(); path != "" {
-			log.Debugf("Download cached provider %s", cache.Provider)
+			handler.providerService.Logger().Debugf("Download cached provider %s", cache.Provider)
 			return ctx.File(path)
 		}
 	}
diff --git a/terraform/cache/handlers/reverse_proxy.go b/terraform/cache/handlers/reverse_proxy.go
index 90afc0bdd4..d224841169 100644
--- a/terraform/cache/handlers/reverse_proxy.go
+++ b/terraform/cache/handlers/reverse_proxy.go
@@ -5,7 +5,7 @@ import (
 	"net/http/httputil"
 	"net/url"
 
-	"github.com/gruntwork-io/terragrunt/internal/log"
+	"github.com/gruntwork-io/terragrunt/pkg/log"
 	"github.com/gruntwork-io/terragrunt/terraform/cliconfig"
 	svchost "github.com/hashicorp/terraform-svchost"
 	"github.com/labstack/echo/v4"
@@ -18,6 +18,8 @@ type ReverseProxy struct {
 	Rewrite        func(*httputil.ProxyRequest)
 	ModifyResponse func(resp *http.Response) error
 	ErrorHandler   func(http.ResponseWriter, *http.Request, error)
+
+	logger log.Logger
 }
 
 func (reverseProxy ReverseProxy) WithRewrite(fn func(req *httputil.ProxyRequest)) *ReverseProxy {
@@ -54,7 +56,7 @@ func (reverseProxy *ReverseProxy) NewRequest(ctx echo.Context, targetURL *url.UR
 			return nil
 		},
 		ErrorHandler: func(resp http.ResponseWriter, req *http.Request, err error) {
-			log.Errorf("remote %s unreachable, could not forward: %v", targetURL, err)
+			reverseProxy.logger.Errorf("remote %s unreachable, could not forward: %v", targetURL, err)
 			ctx.Error(echo.NewHTTPError(http.StatusServiceUnavailable))
 
 			if reverseProxy.ErrorHandler != nil {
diff --git a/terraform/cache/middleware/key_auth.go b/terraform/cache/middleware/key_auth.go
index 3108b92467..3e581e920c 100644
--- a/terraform/cache/middleware/key_auth.go
+++ b/terraform/cache/middleware/key_auth.go
@@ -1,9 +1,9 @@
 package middleware
 
 import (
+	"github.com/gruntwork-io/go-commons/errors"
 	"github.com/labstack/echo/v4"
 	"github.com/labstack/echo/v4/middleware"
-	"github.com/pkg/errors"
 )
 
 type Authorization struct {
diff --git a/terraform/cache/middleware/logger.go b/terraform/cache/middleware/logger.go
index 9ce83a01c7..14e5184d12 100644
--- a/terraform/cache/middleware/logger.go
+++ b/terraform/cache/middleware/logger.go
@@ -1,23 +1,23 @@
 package middleware
 
 import (
-	"github.com/gruntwork-io/terragrunt/internal/log"
+	"github.com/gruntwork-io/terragrunt/pkg/log"
 	"github.com/labstack/echo/v4"
 	"github.com/labstack/echo/v4/middleware"
 )
 
-func Logger() echo.MiddlewareFunc {
+func Logger(logger log.Logger) echo.MiddlewareFunc {
 	return middleware.RequestLoggerWithConfig(middleware.RequestLoggerConfig{
 		LogStatus:   true,
 		LogURI:      true,
 		LogError:    true,
 		HandleError: true, // forwards error to the global error handler, so it can decide appropriate status code
 		LogValuesFunc: func(c echo.Context, v middleware.RequestLoggerValues) error {
-			log := log.WithField("uri", v.URI).WithField("status", v.Status)
+			logger := logger.WithField("uri", v.URI).WithField("status", v.Status)
 			if v.Error != nil {
-				log.Errorf("Cache server was unable to process the received request, %s", v.Error.Error())
+				logger.Errorf("Cache server was unable to process the received request, %s", v.Error.Error())
 			} else {
-				log.Tracef("Cache server received request")
+				logger.Tracef("Cache server received request")
 			}
 			return nil
 		},
diff --git a/terraform/cache/middleware/recover.go b/terraform/cache/middleware/recover.go
index 591ffad82d..d53ed2bdc7 100644
--- a/terraform/cache/middleware/recover.go
+++ b/terraform/cache/middleware/recover.go
@@ -2,15 +2,15 @@ package middleware
 
 import (
 	"github.com/gruntwork-io/go-commons/errors"
-	"github.com/gruntwork-io/terragrunt/internal/log"
+	"github.com/gruntwork-io/terragrunt/pkg/log"
 	"github.com/labstack/echo/v4"
 )
 
-func Recover() echo.MiddlewareFunc {
+func Recover(logger log.Logger) echo.MiddlewareFunc {
 	return func(next echo.HandlerFunc) echo.HandlerFunc {
 		return func(ctx echo.Context) (er error) {
 			defer errors.Recover(func(err error) {
-				log.Debugf(errors.PrintErrorWithStackTrace(err))
+				logger.Debugf(errors.PrintErrorWithStackTrace(err))
 				er = err
 			})
 
diff --git a/terraform/cache/server.go b/terraform/cache/server.go
index 7e53f53bdd..fc684fb14c 100644
--- a/terraform/cache/server.go
+++ b/terraform/cache/server.go
@@ -6,7 +6,6 @@ import (
 	"net/http"
 
 	"github.com/gruntwork-io/go-commons/errors"
-	"github.com/gruntwork-io/terragrunt/internal/log"
 	"github.com/gruntwork-io/terragrunt/terraform/cache/controllers"
 	"github.com/gruntwork-io/terragrunt/terraform/cache/middleware"
 	"github.com/gruntwork-io/terragrunt/terraform/cache/router"
@@ -17,7 +16,7 @@ import (
 // Server is a private Terraform cache for provider caching.
 type Server struct {
 	*router.Router
-	config *Config
+	*Config
 
 	services           []services.Service
 	ProviderController *controllers.ProviderController
@@ -44,8 +43,8 @@ func NewServer(opts ...Option) *Server {
 	}
 
 	rootRouter := router.New()
-	rootRouter.Use(middleware.Logger())
-	rootRouter.Use(middleware.Recover())
+	rootRouter.Use(middleware.Logger(cfg.logger))
+	rootRouter.Use(middleware.Recover(cfg.logger))
 	rootRouter.Register(discoveryController, downloaderController)
 
 	v1Group := rootRouter.Group("v1")
@@ -53,7 +52,7 @@ func NewServer(opts ...Option) *Server {
 
 	return &Server{
 		Router:             rootRouter,
-		config:             cfg,
+		Config:             cfg,
 		services:           cfg.services,
 		ProviderController: providerController,
 	}
@@ -61,21 +60,21 @@ func NewServer(opts ...Option) *Server {
 
 // Listen starts listening to the given configuration address. It also automatically chooses a free port if not explicitly specified.
 func (server *Server) Listen() (net.Listener, error) {
-	ln, err := net.Listen("tcp", server.config.Addr())
+	ln, err := net.Listen("tcp", server.Addr())
 	if err != nil {
 		return nil, errors.WithStackTrace(err)
 	}
 
 	server.Server.Addr = ln.Addr().String()
 
-	log.Infof("Terragrunt Cache server is listening on %s", ln.Addr())
+	server.logger.Infof("Terragrunt Cache server is listening on %s", ln.Addr())
 
 	return ln, nil
 }
 
 // Run starts the webserver and workers.
 func (server *Server) Run(ctx context.Context, ln net.Listener) error {
-	log.Infof("Start Terragrunt Cache server")
+	server.logger.Infof("Start Terragrunt Cache server")
 
 	errGroup, ctx := errgroup.WithContext(ctx)
 
@@ -89,9 +88,9 @@ func (server *Server) Run(ctx context.Context, ln net.Listener) error {
 
 	errGroup.Go(func() error {
 		<-ctx.Done()
-		log.Infof("Shutting down Terragrunt Cache server...")
+		server.logger.Infof("Shutting down Terragrunt Cache server...")
 
-		ctx, cancel := context.WithTimeout(ctx, server.config.shutdownTimeout)
+		ctx, cancel := context.WithTimeout(ctx, server.shutdownTimeout)
 		defer cancel()
 
 		if err := server.Shutdown(ctx); err != nil {
@@ -105,7 +104,7 @@ func (server *Server) Run(ctx context.Context, ln net.Listener) error {
 		return errors.Errorf("error starting terragrunt cache server: %w", err)
 	}
 
-	defer log.Infof("Terragrunt Cache server stopped")
+	defer server.logger.Infof("Terragrunt Cache server stopped")
 
 	return errGroup.Wait()
 }
diff --git a/terraform/cache/services/provider_cache.go b/terraform/cache/services/provider_cache.go
index 4f857130c2..e594606bba 100644
--- a/terraform/cache/services/provider_cache.go
+++ b/terraform/cache/services/provider_cache.go
@@ -14,7 +14,7 @@ import (
 	"time"
 
 	"github.com/gruntwork-io/go-commons/errors"
-	"github.com/gruntwork-io/terragrunt/internal/log"
+	"github.com/gruntwork-io/terragrunt/pkg/log"
 	"github.com/gruntwork-io/terragrunt/terraform/cache/helpers"
 	"github.com/gruntwork-io/terragrunt/terraform/cache/models"
 	"github.com/gruntwork-io/terragrunt/terraform/cliconfig"
@@ -23,7 +23,6 @@ import (
 	"github.com/hashicorp/go-getter/v2"
 	"github.com/hashicorp/go-multierror"
 	svchost "github.com/hashicorp/terraform-svchost"
-	"github.com/sirupsen/logrus"
 	"golang.org/x/sync/errgroup"
 )
 
@@ -75,6 +74,7 @@ func (caches ProviderCaches) removeArchive() error {
 }
 
 type ProviderCache struct {
+	*ProviderService
 	*models.Provider
 	requestIDs []string
 
@@ -89,8 +89,6 @@ type ProviderCache struct {
 	packageDir      string
 	lockfilePath    string
 	archivePath     string
-
-	credsSource *cliconfig.CredentialsSource
 }
 
 func (cache *ProviderCache) DocumentSHA256Sums(ctx context.Context) ([]byte, error) {
@@ -176,7 +174,7 @@ func (cache *ProviderCache) AuthenticatePackage(ctx context.Context) (*getprovid
 		checks = append(checks, getproviders.NewSignatureAuthentication(documentSHA256Sums, signature, cache.SigningKeys.Keys()))
 	} else {
 		// `registry.opentofu.org` does not have signatures for some providers.
-		log.Warnf("Signature validation was skipped due to the registry not containing GPG keys for the provider %s", cache.Provider)
+		cache.logger.Warnf("Signature validation was skipped due to the registry not containing GPG keys for the provider %s", cache.Provider)
 	}
 
 	return getproviders.PackageAuthenticationAll(checks...).Authenticate(cache.archivePath)
@@ -207,13 +205,13 @@ func (cache *ProviderCache) warmUp(ctx context.Context) error {
 	}
 
 	if util.FileExists(cache.userProviderDir) {
-		log.Debugf("Create symlink file %s to %s", cache.packageDir, cache.userProviderDir)
+		cache.logger.Debugf("Create symlink file %s to %s", cache.packageDir, cache.userProviderDir)
 
 		if err := os.Symlink(cache.userProviderDir, cache.packageDir); err != nil {
 			return errors.WithStackTrace(err)
 		}
 
-		log.Infof("Cached %s from user plugins directory", cache.Provider)
+		cache.logger.Infof("Cached %s from user plugins directory", cache.Provider)
 
 		return nil
 	}
@@ -225,7 +223,7 @@ func (cache *ProviderCache) warmUp(ctx context.Context) error {
 	if util.FileExists(cache.DownloadURL) {
 		cache.archivePath = cache.DownloadURL
 	} else {
-		if err := util.DoWithRetry(ctx, fmt.Sprintf("Fetching provider %s", cache.Provider), maxRetriesFetchFile, retryDelayFetchFile, logrus.DebugLevel, func(ctx context.Context) error {
+		if err := util.DoWithRetry(ctx, fmt.Sprintf("Fetching provider %s", cache.Provider), maxRetriesFetchFile, retryDelayFetchFile, cache.logger, log.DebugLevel, func(ctx context.Context) error {
 			req, err := cache.newRequest(ctx, cache.DownloadURL)
 			if err != nil {
 				return err
@@ -238,7 +236,7 @@ func (cache *ProviderCache) warmUp(ctx context.Context) error {
 		cache.archiveCached = true
 	}
 
-	log.Debugf("Unpack provider archive %s", cache.archivePath)
+	cache.logger.Debugf("Unpack provider archive %s", cache.archivePath)
 
 	if err := unzip.Decompress(cache.packageDir, cache.archivePath, true, unzipFileMode); err != nil {
 		return errors.WithStackTrace(err)
@@ -249,7 +247,7 @@ func (cache *ProviderCache) warmUp(ctx context.Context) error {
 		return err
 	}
 
-	log.Infof("Cached %s (%s)", cache.Provider, auth)
+	cache.logger.Infof("Cached %s (%s)", cache.Provider, auth)
 
 	return nil
 }
@@ -274,7 +272,7 @@ func (cache *ProviderCache) newRequest(ctx context.Context, url string) (*http.R
 
 func (cache *ProviderCache) removeArchive() error {
 	if cache.archiveCached && util.FileExists(cache.archivePath) {
-		log.Debugf("Remove provider cached archive %s", cache.archivePath)
+		cache.logger.Debugf("Remove provider cached archive %s", cache.archivePath)
 
 		if err := os.Remove(cache.archivePath); err != nil {
 			return errors.WithStackTrace(err)
@@ -291,7 +289,7 @@ func (cache *ProviderCache) acquireLockFile(ctx context.Context) (*util.Lockfile
 		return nil, errors.WithStackTrace(err)
 	}
 
-	if err := util.DoWithRetry(ctx, "Acquiring lock file "+cache.lockfilePath, maxRetriesLockFile, retryDelayLockFile, logrus.DebugLevel, func(ctx context.Context) error {
+	if err := util.DoWithRetry(ctx, "Acquiring lock file "+cache.lockfilePath, maxRetriesLockFile, retryDelayLockFile, cache.logger, log.DebugLevel, func(ctx context.Context) error {
 		return lockfile.TryLock()
 	}); err != nil {
 		return nil, errors.Errorf("unable to acquire lock file %s (already locked?) try to remove the file manually: %w", cache.lockfilePath, err)
@@ -317,17 +315,24 @@ type ProviderService struct {
 	cacheReadyMu sync.RWMutex
 
 	credsSource *cliconfig.CredentialsSource
+
+	logger log.Logger
 }
 
-func NewProviderService(cacheDir, userCacheDir string, credsSource *cliconfig.CredentialsSource) *ProviderService {
+func NewProviderService(cacheDir, userCacheDir string, credsSource *cliconfig.CredentialsSource, logger log.Logger) *ProviderService {
 	return &ProviderService{
 		cacheDir:              cacheDir,
 		userCacheDir:          userCacheDir,
 		providerCacheWarmUpCh: make(chan *ProviderCache),
 		credsSource:           credsSource,
+		logger:                logger,
 	}
 }
 
+func (service *ProviderService) Logger() log.Logger {
+	return service.logger
+}
+
 // WaitForCacheReady returns cached providers that were requested by `terraform init` from the cache server, with an  URL containing the given `requestID` value.
 // The function returns the value only when all cache requests have been processed.
 func (service *ProviderService) WaitForCacheReady(requestID string) ([]getproviders.Provider, error) {
@@ -365,9 +370,9 @@ func (service *ProviderService) CacheProvider(ctx context.Context, requestID str
 	packageName := fmt.Sprintf("%s-%s-%s-%s-%s", provider.RegistryName, provider.Namespace, provider.Name, provider.Version, provider.Platform())
 
 	cache := &ProviderCache{
-		Provider:    provider,
-		credsSource: service.credsSource,
-		started:     make(chan struct{}, 1),
+		ProviderService: service,
+		Provider:        provider,
+		started:         make(chan struct{}, 1),
 
 		userProviderDir: filepath.Join(service.userCacheDir, provider.Address(), provider.Version, provider.Platform()),
 		packageDir:      filepath.Join(service.cacheDir, provider.Address(), provider.Version, provider.Platform()),
@@ -406,7 +411,7 @@ func (service *ProviderService) Run(ctx context.Context) error {
 		return errors.Errorf("provider cache directory not specified")
 	}
 
-	log.Debugf("Provider cache dir %q", service.cacheDir)
+	service.logger.Debugf("Provider cache dir %q", service.cacheDir)
 
 	if err := os.MkdirAll(service.cacheDir, os.ModePerm); err != nil {
 		return errors.WithStackTrace(err)
diff --git a/terraform/getproviders/lock.go b/terraform/getproviders/lock.go
index d3fb137791..273d1c1e6b 100644
--- a/terraform/getproviders/lock.go
+++ b/terraform/getproviders/lock.go
@@ -13,7 +13,6 @@ import (
 	"unicode"
 
 	"github.com/gruntwork-io/go-commons/errors"
-	"github.com/gruntwork-io/terragrunt/internal/log"
 	"github.com/gruntwork-io/terragrunt/terraform"
 	"github.com/gruntwork-io/terragrunt/util"
 	"github.com/hashicorp/hcl/v2"
@@ -135,7 +134,7 @@ func getExistingHashes(providerBlock *hclwrite.Block, provider Provider) ([]Hash
 
 	// a version attribute found
 	versionVal := getAttributeValueAsUnquotedString(versionAttr)
-	log.Debugf("Check provider version in lock file: address = %s, lock = %s, config = %s", provider.Address(), versionVal, provider.Version())
+	provider.Logger().Debugf("Check provider version in lock file: address = %s, lock = %s, config = %s", provider.Address(), versionVal, provider.Version())
 
 	if versionVal == provider.Version() {
 		// if version is equal, get already existing hashes from lock file to merge.
diff --git a/terraform/getproviders/package_authentication.go b/terraform/getproviders/package_authentication.go
index 64b632ee15..6062ba4423 100644
--- a/terraform/getproviders/package_authentication.go
+++ b/terraform/getproviders/package_authentication.go
@@ -9,10 +9,10 @@ import (
 	"os"
 	"strings"
 
+	"github.com/gruntwork-io/terragrunt/pkg/log"
 	"github.com/gruntwork-io/terragrunt/util"
 
 	"github.com/gruntwork-io/go-commons/errors"
-	"github.com/gruntwork-io/terragrunt/internal/log"
 
 	"github.com/ProtonMail/go-crypto/openpgp"
 	openpgpArmor "github.com/ProtonMail/go-crypto/openpgp/armor"
diff --git a/terraform/getproviders/provider.go b/terraform/getproviders/provider.go
index 77dd7ebe3a..34aa71cfc8 100644
--- a/terraform/getproviders/provider.go
+++ b/terraform/getproviders/provider.go
@@ -2,7 +2,11 @@
 
 package getproviders
 
-import "context"
+import (
+	"context"
+
+	"github.com/gruntwork-io/terragrunt/pkg/log"
+)
 
 type Provider interface {
 	// Address returns a source address of the provider. e.g.: registry.terraform.io/hashicorp/aws
@@ -16,4 +20,7 @@ type Provider interface {
 
 	// PackageDir returns a directory with the unpacked provider.
 	PackageDir() string
+
+	// Logger returns logger
+	Logger() log.Logger
 }
diff --git a/terraform/getter.go b/terraform/getter.go
index fb4571010d..c39760fc8f 100644
--- a/terraform/getter.go
+++ b/terraform/getter.go
@@ -15,7 +15,7 @@ import (
 	"strings"
 
 	"github.com/gruntwork-io/terragrunt/options"
-
+	"github.com/gruntwork-io/terragrunt/pkg/log"
 	"github.com/hashicorp/go-cleanhttp"
 	"github.com/hashicorp/go-getter"
 	safetemp "github.com/hashicorp/go-safetemp"
@@ -139,7 +139,7 @@ func (tfrGetter *RegistryGetter) Get(dstPath string, srcURL *url.URL) error {
 
 	version := versionList[0]
 
-	moduleRegistryBasePath, err := GetModuleRegistryURLBasePath(ctx, registryDomain)
+	moduleRegistryBasePath, err := GetModuleRegistryURLBasePath(ctx, tfrGetter.TerragruntOptions.Logger, registryDomain)
 	if err != nil {
 		return err
 	}
@@ -149,7 +149,7 @@ func (tfrGetter *RegistryGetter) Get(dstPath string, srcURL *url.URL) error {
 		return err
 	}
 
-	terraformGet, err := GetTerraformGetHeader(ctx, *moduleURL)
+	terraformGet, err := GetTerraformGetHeader(ctx, tfrGetter.TerragruntOptions.Logger, *moduleURL)
 	if err != nil {
 		return err
 	}
@@ -192,7 +192,7 @@ func (tfrGetter *RegistryGetter) getSubdir(_ context.Context, dstPath, sourceURL
 	defer func(tempdirCloser io.Closer) {
 		err := tempdirCloser.Close()
 		if err != nil {
-			util.GlobalFallbackLogEntry.Warnf("Error closing temporary directory %s: %v", tempdirPath, err)
+			tfrGetter.TerragruntOptions.Logger.Warnf("Error closing temporary directory %s: %v", tempdirPath, err)
 		}
 	}(tempdirCloser)
 
@@ -236,25 +236,25 @@ func (tfrGetter *RegistryGetter) getSubdir(_ context.Context, dstPath, sourceURL
 	defer func(name string) {
 		err := os.Remove(name)
 		if err != nil {
-			util.GlobalFallbackLogEntry.Warnf("Error removing temporary directory %s: %v", name, err)
+			tfrGetter.TerragruntOptions.Logger.Warnf("Error removing temporary directory %s: %v", name, err)
 		}
 	}(manifestPath)
 
-	return util.CopyFolderContentsWithFilter(sourcePath, dstPath, manifestFname, func(path string) bool { return true })
+	return util.CopyFolderContentsWithFilter(tfrGetter.TerragruntOptions.Logger, sourcePath, dstPath, manifestFname, func(path string) bool { return true })
 }
 
 // GetModuleRegistryURLBasePath uses the service discovery protocol
 // (https://www.terraform.io/docs/internals/remote-service-discovery.html)
 // to figure out where the modules are stored. This will return the base
 // path where the modules can be accessed
-func GetModuleRegistryURLBasePath(ctx context.Context, domain string) (string, error) {
+func GetModuleRegistryURLBasePath(ctx context.Context, logger log.Logger, domain string) (string, error) {
 	sdURL := url.URL{
 		Scheme: "https",
 		Host:   domain,
 		Path:   serviceDiscoveryPath,
 	}
 
-	bodyData, _, err := httpGETAndGetResponse(ctx, sdURL)
+	bodyData, _, err := httpGETAndGetResponse(ctx, logger, sdURL)
 	if err != nil {
 		return "", err
 	}
@@ -270,8 +270,8 @@ func GetModuleRegistryURLBasePath(ctx context.Context, domain string) (string, e
 
 // GetTerraformGetHeader makes an http GET call to the given registry URL and return the contents of location json
 // body or the header X-Terraform-Get. This function will return an error if the response does not contain the header.
-func GetTerraformGetHeader(ctx context.Context, url url.URL) (string, error) {
-	body, header, err := httpGETAndGetResponse(ctx, url)
+func GetTerraformGetHeader(ctx context.Context, logger log.Logger, url url.URL) (string, error) {
+	body, header, err := httpGETAndGetResponse(ctx, logger, url)
 	if err != nil {
 		details := "error receiving HTTP data"
 		return "", errors.WithStackTrace(ModuleDownloadErr{sourceURL: url.String(), details: details})
@@ -324,7 +324,7 @@ func GetDownloadURLFromHeader(moduleURL url.URL, terraformGet string) (string, e
 
 // httpGETAndGetResponse is a helper function to make a GET request to the given URL using the http client. This
 // function will then read the response and return the contents + the response header.
-func httpGETAndGetResponse(ctx context.Context, getURL url.URL) ([]byte, *http.Header, error) {
+func httpGETAndGetResponse(ctx context.Context, logger log.Logger, getURL url.URL) ([]byte, *http.Header, error) {
 	req, err := http.NewRequestWithContext(ctx, "GET", getURL.String(), nil)
 	if err != nil {
 		return nil, nil, errors.WithStackTrace(err)
@@ -345,7 +345,7 @@ func httpGETAndGetResponse(ctx context.Context, getURL url.URL) ([]byte, *http.H
 	defer func() {
 		err := resp.Body.Close()
 		if err != nil {
-			util.GlobalFallbackLogEntry.Warnf("Error closing response body: %v", err)
+			logger.Warnf("Error closing response body: %v", err)
 		}
 	}()
 
diff --git a/terraform/log.go b/terraform/log.go
new file mode 100644
index 0000000000..944f8245f8
--- /dev/null
+++ b/terraform/log.go
@@ -0,0 +1,62 @@
+package terraform
+
+import (
+	"regexp"
+	"strings"
+	"time"
+
+	"github.com/gruntwork-io/terragrunt/pkg/log"
+	"github.com/gruntwork-io/terragrunt/pkg/log/writer"
+)
+
+var (
+	// logTimestampFormat is TF_LOG timestamp formats.
+	logTimestampFormat = "2006-01-02T15:04:05.000Z0700"
+)
+
+var (
+	// tfLogTimeLevelMsgReg is a regular expression that matches TF_LOG output, example output:
+	//
+	// 2024-09-08T13:44:31.229+0300 [DEBUG] using github.com/zclconf/go-cty v1.14.3
+	// 2024-09-08T13:44:31.229+0300 [INFO]  Go runtime version: go1.22.1
+	tfLogTimeLevelMsgReg = regexp.MustCompile(`(?i)(^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}\S*)\s*\[(trace|debug|warn|info|error)\]\s*(.+\S)$`)
+)
+
+func ParseLogFunc(msgPrefix string) writer.WriterParseFunc {
+	return func(str string) (msg string, ptrTime *time.Time, ptrLevel *log.Level, err error) {
+		return ParseLog(msgPrefix, str)
+	}
+}
+
+func ParseLog(msgPrefix, str string) (msg string, ptrTime *time.Time, ptrLevel *log.Level, err error) {
+	const numberOfValues = 4
+
+	if !tfLogTimeLevelMsgReg.MatchString(str) {
+		return str, nil, nil, nil
+	}
+
+	match := tfLogTimeLevelMsgReg.FindStringSubmatch(str)
+	if len(match) != numberOfValues {
+		return str, nil, nil, nil
+	}
+
+	timeStr, levelStr, msg := match[1], match[2], match[3]
+
+	if levelStr != "" {
+		if level, err := log.ParseLevel(strings.ToLower(levelStr)); err == nil {
+			ptrLevel = &level
+		} else {
+			msg = "[" + levelStr + "] " + msg
+		}
+	}
+
+	if timeStr != "" {
+		if time, err := time.Parse(logTimestampFormat, timeStr); err == nil {
+			ptrTime = &time
+		} else {
+			msg = timeStr + " " + msg
+		}
+	}
+
+	return msgPrefix + msg, ptrTime, ptrLevel, nil
+}
diff --git a/terraform/source.go b/terraform/source.go
index 3fe7637df0..ff30fa0cfc 100644
--- a/terraform/source.go
+++ b/terraform/source.go
@@ -10,9 +10,9 @@ import (
 	"regexp"
 	"strings"
 
+	"github.com/gruntwork-io/terragrunt/pkg/log"
 	"github.com/hashicorp/go-getter"
 	urlhelper "github.com/hashicorp/go-getter/helper/url"
-	"github.com/sirupsen/logrus"
 
 	"github.com/gruntwork-io/go-commons/errors"
 	"github.com/gruntwork-io/terragrunt/util"
@@ -39,7 +39,7 @@ type Source struct {
 	// The path to a file in DownloadDir that stores the version number of the code
 	VersionFile string
 
-	Logger logrus.FieldLogger
+	Logger log.Logger
 }
 
 func (src *Source) String() string {
@@ -90,7 +90,7 @@ func (terraformSource Source) EncodeSourceVersion() (string, error) {
 			return hash, nil
 		}
 
-		terraformSource.Logger.WithError(err).Warningf("Could not encode version for local source")
+		terraformSource.Logger.WithError(err).Warnf("Could not encode version for local source")
 
 		return "", err
 	}
@@ -145,7 +145,7 @@ func (terraformSource Source) WriteVersionFile() error {
 //  1. Always download source URLs pointing to local file paths.
 //  2. Only download source URLs pointing to remote paths if /T/W/H doesn't already exist or, if it does exist, if the
 //     version number in /T/W/H/.terragrunt-source-version doesn't match the current version.
-func NewSource(source string, downloadDir string, workingDir string, logger *logrus.Entry) (*Source, error) {
+func NewSource(source string, downloadDir string, workingDir string, logger log.Logger) (*Source, error) {
 	canonicalWorkingDir, err := util.CanonicalPath(workingDir, "")
 	if err != nil {
 		return nil, err
@@ -275,7 +275,7 @@ func IsLocalSource(sourceUrl *url.URL) bool {
 // (//), which typically represents the root of a modules repo (e.g. github.com/foo/infrastructure-modules) and the
 // path is everything after the double slash. If there is no double-slash in the URL, the root repo is the entire
 // sourceUrl and the path is an empty string.
-func SplitSourceUrl(sourceUrl *url.URL, logger *logrus.Entry) (*url.URL, string, error) {
+func SplitSourceUrl(sourceUrl *url.URL, logger log.Logger) (*url.URL, string, error) {
 	pathSplitOnDoubleSlash := strings.SplitN(sourceUrl.Path, "//", 2) //nolint:mnd
 
 	if len(pathSplitOnDoubleSlash) > 1 {
@@ -296,7 +296,7 @@ func SplitSourceUrl(sourceUrl *url.URL, logger *logrus.Entry) (*url.URL, string,
 	_, err := os.Stat(sourceUrl.Path)
 	if err != nil {
 		// log warning message to notify user that sourceUrl.Path may not work
-		logger.Warningf("No double-slash (//) found in source URL %s. Relative paths in downloaded Terraform code may not work.", sourceUrl.Path)
+		logger.Warnf("No double-slash (//) found in source URL %s. Relative paths in downloaded Terraform code may not work.", sourceUrl.Path)
 	}
 
 	return sourceUrl, "", nil
diff --git a/terraform/terraform.go b/terraform/terraform.go
index 005e96b218..f22c1686b7 100644
--- a/terraform/terraform.go
+++ b/terraform/terraform.go
@@ -23,6 +23,7 @@ const (
 	CommandNameConsole        = "console"
 	CommandNameForceUnlock    = "force-unlock"
 	CommandNameShow           = "show"
+	CommandNameVersion        = "version"
 
 	FlagNameJSON    = "-json"
 	FlagNameNoColor = "-no-color"
diff --git a/util/file.go b/util/file.go
index ec40caf2ad..bf79f72ddf 100644
--- a/util/file.go
+++ b/util/file.go
@@ -16,6 +16,7 @@ import (
 	"fmt"
 
 	"github.com/gruntwork-io/go-commons/errors"
+	"github.com/gruntwork-io/terragrunt/pkg/log"
 	"github.com/mattn/go-zglob"
 	homedir "github.com/mitchellh/go-homedir"
 )
@@ -217,21 +218,6 @@ func GetPathRelativeTo(path string, basePath string) (string, error) {
 	return filepath.ToSlash(relPath), nil
 }
 
-// GetPathRelativeToWithSeparator is a wrapper for the `GetPathRelativeTo` func, and appends a separator, if the relative path consists only of a dot.
-// The function is intended for use in logs, and so that a path with single dot would not seem like the end of a sentence, we add trail slash.
-func GetPathRelativeToWithSeparator(path string, basePath string) (string, error) {
-	relPath, err := GetPathRelativeTo(path, basePath)
-	if err != nil {
-		return relPath, err
-	}
-
-	if relPath == "." {
-		relPath += string(filepath.Separator)
-	}
-
-	return relPath, nil
-}
-
 // Return the contents of the file at the given path as a string
 func ReadFileAsString(path string) (string, error) {
 	bytes, err := os.ReadFile(path)
@@ -289,7 +275,7 @@ func expandGlobPath(source, absoluteGlobPath string) ([]string, error) {
 
 // Copy the files and folders within the source folder into the destination folder. Note that hidden files and folders
 // (those starting with a dot) will be skipped. Will create a specified manifest file that contains paths of all copied files.
-func CopyFolderContents(source, destination, manifestFile string, includeInCopy []string) error {
+func CopyFolderContents(logger log.Logger, source, destination, manifestFile string, includeInCopy []string) error {
 	// Expand all the includeInCopy glob paths, converting the globbed results to relative paths so that they work in
 	// the copy filter.
 	includeExpandedGlobs := []string{}
@@ -305,7 +291,7 @@ func CopyFolderContents(source, destination, manifestFile string, includeInCopy
 		includeExpandedGlobs = append(includeExpandedGlobs, expandGlob...)
 	}
 
-	return CopyFolderContentsWithFilter(source, destination, manifestFile, func(absolutePath string) bool {
+	return CopyFolderContentsWithFilter(logger, source, destination, manifestFile, func(absolutePath string) bool {
 		relativePath, err := GetPathRelativeTo(absolutePath, source)
 		if err == nil && listContainsElementWithPrefix(includeExpandedGlobs, relativePath) {
 			return true
@@ -318,13 +304,13 @@ func CopyFolderContents(source, destination, manifestFile string, includeInCopy
 // Copy the files and folders within the source folder into the destination folder. Pass each file and folder through
 // the given filter function and only copy it if the filter returns true. Will create a specified manifest file
 // that contains paths of all copied files.
-func CopyFolderContentsWithFilter(source, destination, manifestFile string, filter func(absolutePath string) bool) error {
+func CopyFolderContentsWithFilter(logger log.Logger, source, destination, manifestFile string, filter func(absolutePath string) bool) error {
 	const ownerReadWriteExecutePerms = 0700
 	if err := os.MkdirAll(destination, ownerReadWriteExecutePerms); err != nil {
 		return errors.WithStackTrace(err)
 	}
 
-	manifest := NewFileManifest(destination, manifestFile)
+	manifest := NewFileManifest(logger, destination, manifestFile)
 	if err := manifest.Clean(); err != nil {
 		return errors.WithStackTrace(err)
 	}
@@ -336,7 +322,7 @@ func CopyFolderContentsWithFilter(source, destination, manifestFile string, filt
 	defer func(manifest *fileManifest) {
 		err := manifest.Close()
 		if err != nil {
-			GlobalFallbackLogEntry.Warnf("Error closing manifest file: %v", err)
+			logger.Warnf("Error closing manifest file: %v", err)
 		}
 	}(manifest)
 
@@ -370,7 +356,7 @@ func CopyFolderContentsWithFilter(source, destination, manifestFile string, filt
 				return errors.WithStackTrace(err)
 			}
 
-			if err := CopyFolderContentsWithFilter(file, dest, manifestFile, filter); err != nil {
+			if err := CopyFolderContentsWithFilter(logger, file, dest, manifestFile, filter); err != nil {
 				return err
 			}
 
@@ -522,6 +508,7 @@ type fileManifest struct {
 	ManifestFile   string // this is the manifest file name
 	encoder        *gob.Encoder
 	fileHandle     *os.File
+	logger         log.Logger
 }
 
 // fileManifestEntry represents an entry in the fileManifest.
@@ -552,11 +539,11 @@ func (manifest *fileManifest) clean(manifestPath string) error {
 	// cleaning manifest file
 	defer func(name string) {
 		if err := file.Close(); err != nil {
-			GlobalFallbackLogEntry.Warnf("Error closing file %s: %v", name, err)
+			manifest.logger.Warnf("Error closing file %s: %v", name, err)
 		}
 
 		if err := os.Remove(name); err != nil {
-			GlobalFallbackLogEntry.Warnf("Error removing manifest file %s: %v", name, err)
+			manifest.logger.Warnf("Error removing manifest file %s: %v", name, err)
 		}
 	}(manifestPath)
 
@@ -619,8 +606,8 @@ func (manifest *fileManifest) Close() error {
 	return manifest.fileHandle.Close()
 }
 
-func NewFileManifest(manifestFolder string, manifestFile string) *fileManifest {
-	return &fileManifest{ManifestFolder: manifestFolder, ManifestFile: manifestFile}
+func NewFileManifest(logger log.Logger, manifestFolder string, manifestFile string) *fileManifest {
+	return &fileManifest{logger: logger, ManifestFolder: manifestFolder, ManifestFile: manifestFile}
 }
 
 // Custom errors
diff --git a/util/logger.go b/util/logger.go
deleted file mode 100644
index 9b210e845f..0000000000
--- a/util/logger.go
+++ /dev/null
@@ -1,166 +0,0 @@
-package util
-
-import (
-	"io"
-	"os"
-	"time"
-
-	"golang.org/x/term"
-
-	"github.com/gruntwork-io/terragrunt/internal/log/formatter"
-	"github.com/hashicorp/hcl/v2"
-	"github.com/hashicorp/hcl/v2/hclparse"
-	"github.com/sirupsen/logrus"
-)
-
-// used in integration tests
-const (
-	defaultLogLevel        = logrus.InfoLevel
-	defaultTimestampFormat = time.RFC3339
-
-	logLevelEnvVar        = "TERRAGRUNT_LOG_LEVEL"
-	timestampFormatEnvVar = "TERRAGRUNT_LOG_TIMESTAMP_FORMAT"
-)
-
-var (
-	// GlobalFallbackLogEntry is a global fallback logentry for the application
-	// Should be used in cases when more specific logger can't be created (like in the very beginning, when we have not yet
-	// parsed command line arguments).
-	//
-	// This might go away once we migrate toproper cli library
-	// (see https://github.com/gruntwork-io/terragrunt/blob/master/cli/args.go#L29)
-	GlobalFallbackLogEntry *logrus.Entry
-
-	globalDisableColors     bool
-	globalDisableFormatting bool
-	jsonLogFormat           bool
-)
-
-func init() {
-	setGlobalFallbackLogEntry()
-}
-
-func setGlobalFallbackLogEntry() {
-	logLevel := GetDefaultLogLevel()
-	GlobalFallbackLogEntry = CreateLogEntry("", logLevel, nil, globalDisableColors, globalDisableFormatting)
-}
-
-func DisableLogColors() {
-	globalDisableColors = true
-
-	setGlobalFallbackLogEntry()
-}
-
-func DisableLogFormatting() {
-	globalDisableFormatting = true
-
-	setGlobalFallbackLogEntry()
-}
-
-func JsonFormat() {
-	jsonLogFormat = true
-
-	setGlobalFallbackLogEntry()
-}
-
-func DisableJsonFormat() {
-	jsonLogFormat = false
-
-	setGlobalFallbackLogEntry()
-}
-
-// CreateLogger creates a logger. If debug is set, we use ErrorLevel to enable verbose output, otherwise - only errors are shown
-func CreateLogger(lvl logrus.Level, prefixStyle formatter.PrefixStyle, disableColors, disableFormatting bool) *logrus.Logger {
-	logger := logrus.New()
-	logger.SetLevel(lvl)
-	logger.SetOutput(os.Stderr) // Terragrunt should output all it's logs to stderr by default
-
-	if jsonLogFormat {
-		logger.SetFormatter(&logrus.JSONFormatter{})
-	} else {
-		logFormatter := formatter.NewFormatter()
-		logFormatter.DisableColors = disableColors
-		logFormatter.DisableLogFormatting = disableFormatting
-
-		if prefixStyle != nil {
-			logFormatter.PrefixStyle = prefixStyle
-		}
-
-		if timestampFormat := os.Getenv(timestampFormatEnvVar); timestampFormat != "" {
-			logFormatter.TimestampFormat = timestampFormat
-		}
-
-		logger.SetFormatter(logFormatter)
-	}
-
-	return logger
-}
-
-// CreateLogEntry creates a logger entry with the given prefix field
-func CreateLogEntry(prefix string, level logrus.Level, prefixStyle formatter.PrefixStyle, disableColors, disableFormatting bool) *logrus.Entry {
-	logger := CreateLogger(level, prefixStyle, disableColors, disableFormatting)
-	fields := logrus.Fields{
-		formatter.PrefixKeyName: prefix,
-	}
-
-	return logger.WithFields(fields)
-}
-
-// CreateLogEntryWithWriter Create a logger around the given output stream and prefix
-func CreateLogEntryWithWriter(writer io.Writer, prefix string, level logrus.Level, hooks logrus.LevelHooks, prefixStyle formatter.PrefixStyle, disableColors, disableFormatting bool) *logrus.Entry {
-	logger := CreateLogEntry(prefix, level, prefixStyle, disableColors, disableFormatting)
-	logger.Logger.SetOutput(writer)
-	logger.Logger.ReplaceHooks(hooks)
-
-	return logger
-}
-
-// GetDiagnosticsWriter returns a hcl2 parsing diagnostics emitter for the current terminal.
-func GetDiagnosticsWriter(writer io.Writer, parser *hclparse.Parser, disableColor bool) hcl.DiagnosticWriter {
-	termColor := !disableColor && term.IsTerminal(int(os.Stderr.Fd()))
-
-	termWidth, _, err := term.GetSize(int(os.Stdout.Fd()))
-	if err != nil {
-		termWidth = 80
-	}
-
-	return hcl.NewDiagnosticTextWriter(writer, parser.Files(), uint(termWidth), termColor)
-}
-
-// GetDefaultLogLevel returns the default log level to use. The log level is resolved based on the environment variable
-// with name from LogLevelEnvVar, falling back to info if unspecified or there is an error parsing the given log level.
-func GetDefaultLogLevel() logrus.Level {
-	defaultLogLevelStr := os.Getenv(logLevelEnvVar)
-	if defaultLogLevelStr == "" {
-		return defaultLogLevel
-	}
-
-	return ParseLogLevel(defaultLogLevelStr)
-}
-
-func ParseLogLevel(logLevelStr string) logrus.Level {
-	parsedLogLevel, err := logrus.ParseLevel(logLevelStr)
-	if err != nil {
-		CreateLogEntry("", defaultLogLevel, nil, globalDisableColors, globalDisableFormatting).Errorf(
-			"Could not parse log level from environment variable %s (%s) - falling back to default %s",
-			logLevelEnvVar,
-			logLevelStr,
-			defaultLogLevel,
-		)
-
-		return defaultLogLevel
-	}
-
-	return parsedLogLevel
-}
-
-// LogWriter - Writer implementation which redirect Write requests to configured logger and level
-type LogWriter struct {
-	Logger *logrus.Entry
-	Level  logrus.Level
-}
-
-func (w *LogWriter) Write(p []byte) (n int, err error) {
-	w.Logger.Log(w.Level, string(p))
-	return len(p), nil
-}
diff --git a/util/retry.go b/util/retry.go
index 95de5dda1f..43ce873cdb 100644
--- a/util/retry.go
+++ b/util/retry.go
@@ -7,16 +7,15 @@ import (
 	"time"
 
 	"github.com/gruntwork-io/go-commons/errors"
-	"github.com/gruntwork-io/terragrunt/internal/log"
-	"github.com/sirupsen/logrus"
+	"github.com/gruntwork-io/terragrunt/pkg/log"
 )
 
 // DoWithRetry runs the specified action. If it returns a value, return that value. If it returns an error, sleep for
 // sleepBetweenRetries and try again, up to a maximum of maxRetries retries. If maxRetries is exceeded, return a
 // MaxRetriesExceeded error.
-func DoWithRetry(ctx context.Context, actionDescription string, maxRetries int, sleepBetweenRetries time.Duration, logLevel logrus.Level, action func(ctx context.Context) error) error {
+func DoWithRetry(ctx context.Context, actionDescription string, maxRetries int, sleepBetweenRetries time.Duration, logger log.Logger, logLevel log.Level, action func(ctx context.Context) error) error {
 	for i := 0; i <= maxRetries; i++ {
-		log.Logf(logLevel, actionDescription)
+		logger.Logf(logLevel, actionDescription)
 
 		err := action(ctx)
 		if err == nil {
@@ -29,11 +28,11 @@ func DoWithRetry(ctx context.Context, actionDescription string, maxRetries int,
 		}
 
 		if ctx.Err() != nil {
-			log.Debugf("%s returned an error: %s.", actionDescription, err.Error())
+			logger.Debugf("%s returned an error: %s.", actionDescription, err.Error())
 			return errors.WithStackTrace(ctx.Err())
 		}
 
-		log.Errorf("%s returned an error: %s. Retry %d of %d. Sleeping for %s and will try again.", actionDescription, err.Error(), i, maxRetries, sleepBetweenRetries)
+		logger.Errorf("%s returned an error: %s. Retry %d of %d. Sleeping for %s and will try again.", actionDescription, err.Error(), i, maxRetries, sleepBetweenRetries)
 
 		select {
 		case <-time.After(sleepBetweenRetries): // Try again
diff --git a/util/shell.go b/util/shell.go
index fde25ce5d7..3bb64f45c0 100644
--- a/util/shell.go
+++ b/util/shell.go
@@ -68,7 +68,7 @@ func GetExitCode(err error) (int, error) {
 // ProcessExecutionError - error returned when a command fails, contains StdOut and StdErr
 type ProcessExecutionError struct {
 	Err        error
-	StdOut     string
+	Stdout     string
 	Stderr     string
 	WorkingDir string
 }
@@ -81,3 +81,19 @@ func (err ProcessExecutionError) Error() string {
 func (err ProcessExecutionError) ExitStatus() (int, error) {
 	return GetExitCode(err.Err)
 }
+
+func Unwrap[V error](err error) *V {
+	var target = new(V)
+
+	for {
+		if ok := goErrors.As(err, target); ok {
+			return target
+		}
+
+		if err = goErrors.Unwrap(err); err == nil {
+			break
+		}
+	}
+
+	return target
+}


Test patch:
diff --git a/cli/app_test.go b/cli/app_test.go
index 78e00a0d10..9b89b441a3 100644
--- a/cli/app_test.go
+++ b/cli/app_test.go
@@ -19,14 +19,14 @@ import (
 	"github.com/gruntwork-io/terragrunt/config"
 	"github.com/gruntwork-io/terragrunt/options"
 	cliPkg "github.com/gruntwork-io/terragrunt/pkg/cli"
+	"github.com/gruntwork-io/terragrunt/pkg/log"
 	"github.com/gruntwork-io/terragrunt/terraform"
 	"github.com/gruntwork-io/terragrunt/util"
-	"github.com/sirupsen/logrus"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 )
 
-var defaultLogLevel = util.GetDefaultLogLevel()
+var defaultLogLevel = log.DebugLevel
 
 func TestParseTerragruntOptionsFromArgs(t *testing.T) {
 	t.Parallel()
@@ -154,7 +154,7 @@ func TestParseTerragruntOptionsFromArgs(t *testing.T) {
 		// Adding the --terragrunt-log-level flag should result in DebugLevel configured
 		{
 			[]string{doubleDashed(commands.TerragruntLogLevelFlagName), "debug"},
-			mockOptions(t, util.JoinPath(workingDir, config.DefaultTerragruntConfigPath), workingDir, []string{}, false, "", false, false, logrus.DebugLevel, false),
+			mockOptions(t, util.JoinPath(workingDir, config.DefaultTerragruntConfigPath), workingDir, []string{}, false, "", false, false, log.DebugLevel, false),
 			nil,
 		},
 		{
@@ -220,7 +220,7 @@ func assertOptionsEqual(t *testing.T, expected options.TerragruntOptions, actual
 	assert.Equal(t, expected.SourceMap, actual.SourceMap, msgAndArgs...)
 }
 
-func mockOptions(t *testing.T, terragruntConfigPath string, workingDir string, terraformCliArgs []string, nonInteractive bool, terragruntSource string, ignoreDependencyErrors bool, includeExternalDependencies bool, logLevel logrus.Level, debug bool) *options.TerragruntOptions {
+func mockOptions(t *testing.T, terragruntConfigPath string, workingDir string, terraformCliArgs []string, nonInteractive bool, terragruntSource string, ignoreDependencyErrors bool, includeExternalDependencies bool, logLevel log.Level, debug bool) *options.TerragruntOptions {
 	t.Helper()
 
 	opts, err := options.NewTerragruntOptionsForTest(terragruntConfigPath)
@@ -234,7 +234,7 @@ func mockOptions(t *testing.T, terragruntConfigPath string, workingDir string, t
 	opts.Source = terragruntSource
 	opts.IgnoreDependencyErrors = ignoreDependencyErrors
 	opts.IncludeExternalDependencies = includeExternalDependencies
-	opts.Logger.Level = logLevel
+	opts.Logger.SetOptions(log.WithLevel(logLevel))
 	opts.Debug = debug
 
 	return opts
@@ -390,7 +390,8 @@ func TestTerragruntVersion(t *testing.T) {
 
 	for _, testCase := range testCases {
 		output := &bytes.Buffer{}
-		app := cli.NewApp(output, os.Stderr)
+		opts := options.NewTerragruntOptionsWithWriters(output, os.Stderr)
+		app := cli.NewApp(opts)
 		app.Version = version
 
 		err := app.Run(testCase.args)
@@ -403,7 +404,8 @@ func TestTerragruntVersion(t *testing.T) {
 func TestTerragruntHelp(t *testing.T) {
 	t.Parallel()
 
-	app := cli.NewApp(os.Stdout, os.Stderr)
+	opts := options.NewTerragruntOptions()
+	app := cli.NewApp(opts)
 
 	testCases := []struct {
 		args        []string
@@ -419,7 +421,8 @@ func TestTerragruntHelp(t *testing.T) {
 
 	for _, testCase := range testCases {
 		output := &bytes.Buffer{}
-		app := cli.NewApp(output, os.Stderr)
+		opts := options.NewTerragruntOptionsWithWriters(output, os.Stderr)
+		app := cli.NewApp(opts)
 		err := app.Run(testCase.args)
 		require.NoError(t, err, testCase)
 
@@ -446,7 +449,8 @@ func TestTerraformHelp(t *testing.T) {
 
 	for _, testCase := range testCases {
 		output := &bytes.Buffer{}
-		app := cli.NewApp(output, os.Stderr)
+		opts := options.NewTerragruntOptionsWithWriters(output, os.Stderr)
+		app := cli.NewApp(opts)
 		err := app.Run(testCase.args)
 		require.NoError(t, err)
 
@@ -460,10 +464,10 @@ func TestTerraformHelp(t *testing.T) {
 func TestTerraformHelp_wrongHelpFlag(t *testing.T) {
 	t.Parallel()
 
-	app := cli.NewApp(os.Stdout, os.Stderr)
-
 	output := &bytes.Buffer{}
-	app.Writer = output
+
+	opts := options.NewTerragruntOptionsWithWriters(output, os.Stderr)
+	app := cli.NewApp(opts)
 
 	err := app.Run([]string{"terragrunt", "plan", "help"})
 	require.Error(t, err)
@@ -509,9 +513,7 @@ func (err argMissingValueError) Error() string {
 	return "flag needs an argument: -" + string(err)
 }
 
-func TestAutocomplete(t *testing.T) {
-	t.Parallel()
-
+func TestAutocomplete(t *testing.T) { //nolint:paralleltest
 	defer os.Unsetenv("COMP_LINE")
 
 	testCases := []struct {
@@ -540,7 +542,9 @@ func TestAutocomplete(t *testing.T) {
 		os.Setenv("COMP_LINE", "terragrunt "+testCase.compLine)
 
 		output := &bytes.Buffer{}
-		app := cli.NewApp(output, os.Stderr)
+		opts := options.NewTerragruntOptionsWithWriters(output, os.Stderr)
+		app := cli.NewApp(opts)
+
 		app.Commands = app.Commands.Filter([]string{"aws-provider-patch", "graph-dependencies", "hclfmt", "output-module-groups", "render-json", "run-all", "terragrunt-info", "validate-inputs"})
 
 		err := app.Run([]string{"terragrunt"})
diff --git a/cli/commands/catalog/module/repo_test.go b/cli/commands/catalog/module/repo_test.go
index 2388cdc0d3..51863d54ed 100644
--- a/cli/commands/catalog/module/repo_test.go
+++ b/cli/commands/catalog/module/repo_test.go
@@ -7,6 +7,7 @@ import (
 	"testing"
 
 	"github.com/gruntwork-io/terragrunt/cli/commands/catalog/module"
+	"github.com/gruntwork-io/terragrunt/pkg/log"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 )
@@ -62,7 +63,7 @@ func TestFindModules(t *testing.T) {
 
 			ctx := context.Background()
 
-			repo, err := module.NewRepo(ctx, testCase.repoPath, "")
+			repo, err := module.NewRepo(ctx, log.New(), testCase.repoPath, "")
 			require.NoError(t, err)
 
 			modules, err := repo.FindModules(ctx)
diff --git a/cli/commands/terraform/action_test.go b/cli/commands/terraform/action_test.go
index c6390a1fc0..ccc18acbb4 100644
--- a/cli/commands/terraform/action_test.go
+++ b/cli/commands/terraform/action_test.go
@@ -10,8 +10,8 @@ import (
 	"github.com/gruntwork-io/terragrunt/cli/commands/terraform"
 	"github.com/gruntwork-io/terragrunt/config"
 	"github.com/gruntwork-io/terragrunt/options"
+	"github.com/gruntwork-io/terragrunt/pkg/log"
 	"github.com/gruntwork-io/terragrunt/util"
-	"github.com/sirupsen/logrus"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 )
@@ -447,7 +447,7 @@ func TestFilterTerraformExtraArgs(t *testing.T) {
 
 }
 
-var defaultLogLevel = util.GetDefaultLogLevel()
+var defaultLogLevel = log.DebugLevel
 
 func mockCmdOptions(t *testing.T, workingDir string, terraformCliArgs []string) *options.TerragruntOptions {
 	t.Helper()
@@ -468,7 +468,7 @@ func mockExtraArgs(arguments, commands, requiredVarFiles, optionalVarFiles []str
 	return a
 }
 
-func mockOptions(t *testing.T, terragruntConfigPath string, workingDir string, terraformCliArgs []string, nonInteractive bool, terragruntSource string, ignoreDependencyErrors bool, includeExternalDependencies bool, logLevel logrus.Level, debug bool) *options.TerragruntOptions {
+func mockOptions(t *testing.T, terragruntConfigPath string, workingDir string, terraformCliArgs []string, nonInteractive bool, terragruntSource string, ignoreDependencyErrors bool, includeExternalDependencies bool, logLevel log.Level, debug bool) *options.TerragruntOptions {
 	t.Helper()
 
 	opts, err := options.NewTerragruntOptionsForTest(terragruntConfigPath)
@@ -482,7 +482,7 @@ func mockOptions(t *testing.T, terragruntConfigPath string, workingDir string, t
 	opts.Source = terragruntSource
 	opts.IgnoreDependencyErrors = ignoreDependencyErrors
 	opts.IncludeExternalDependencies = includeExternalDependencies
-	opts.Logger.Level = logLevel
+	opts.Logger.SetOptions(log.WithLevel(logLevel))
 	opts.Debug = debug
 
 	return opts
diff --git a/cli/commands/terraform/download_source_test.go b/cli/commands/terraform/download_source_test.go
index 8389994b9a..95b2ce55d0 100644
--- a/cli/commands/terraform/download_source_test.go
+++ b/cli/commands/terraform/download_source_test.go
@@ -12,10 +12,11 @@ import (
 	"strings"
 	"testing"
 
+	"github.com/gruntwork-io/terragrunt/pkg/log"
+
 	"github.com/gruntwork-io/go-commons/env"
 	"github.com/gruntwork-io/terragrunt/cli/commands/terraform"
 	tgTerraform "github.com/gruntwork-io/terragrunt/terraform"
-	"github.com/sirupsen/logrus"
 
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
@@ -395,8 +396,8 @@ func testDownloadTerraformSourceIfNecessary(t *testing.T, canonicalUrl string, d
 func createConfig(t *testing.T, canonicalUrl string, downloadDir string, sourceUpdate bool) (*tgTerraform.Source, *options.TerragruntOptions, *config.TerragruntConfig, error) {
 	t.Helper()
 
-	logger := logrus.New()
-	logger.Out = io.Discard
+	logger := log.New()
+	logger.SetOptions(log.WithOutput(io.Discard))
 	terraformSource := &tgTerraform.Source{
 		CanonicalSourceURL: parseUrl(t, canonicalUrl),
 		DownloadDir:        downloadDir,
@@ -426,8 +427,8 @@ func createConfig(t *testing.T, canonicalUrl string, downloadDir string, sourceU
 func testAlreadyHaveLatestCode(t *testing.T, canonicalUrl string, downloadDir string, expected bool) {
 	t.Helper()
 
-	logger := logrus.New()
-	logger.Out = io.Discard
+	logger := log.New()
+	logger.SetOptions(log.WithOutput(io.Discard))
 	terraformSource := &tgTerraform.Source{
 		CanonicalSourceURL: parseUrl(t, canonicalUrl),
 		DownloadDir:        downloadDir,
@@ -490,6 +491,9 @@ func readFile(t *testing.T, path string) string {
 func copyFolder(t *testing.T, src string, dest string) {
 	t.Helper()
 
-	err := util.CopyFolderContents(filepath.FromSlash(src), filepath.FromSlash(dest), ".terragrunt-test", nil)
+	logger := log.New()
+	logger.SetOptions(log.WithOutput(io.Discard))
+
+	err := util.CopyFolderContents(logger, filepath.FromSlash(src), filepath.FromSlash(dest), ".terragrunt-test", nil)
 	require.NoError(t, err)
 }
diff --git a/cli/provider_cache_test.go b/cli/provider_cache_test.go
index 0092b1db28..a85eb91155 100644
--- a/cli/provider_cache_test.go
+++ b/cli/provider_cache_test.go
@@ -14,6 +14,7 @@ import (
 
 	"github.com/google/uuid"
 	"github.com/gruntwork-io/terragrunt/cli"
+	"github.com/gruntwork-io/terragrunt/pkg/log"
 	"github.com/gruntwork-io/terragrunt/terraform/cache"
 	"github.com/gruntwork-io/terragrunt/terraform/cache/handlers"
 	"github.com/gruntwork-io/terragrunt/terraform/cache/services"
@@ -113,7 +114,7 @@ func TestProviderCache(t *testing.T) {
 
 			errGroup, ctx := errgroup.WithContext(ctx)
 
-			providerService := services.NewProviderService(providerCacheDir, pluginCacheDir, nil)
+			providerService := services.NewProviderService(providerCacheDir, pluginCacheDir, nil, log.New())
 			providerHandler := handlers.NewProviderDirectHandler(providerService, cli.CACHE_PROVIDER_HTTP_STATUS_CODE, new(cliconfig.ProviderInstallationDirect), nil)
 
 			testCase.opts = append(testCase.opts, cache.WithServices(providerService), cache.WithProviderHandlers(providerHandler))
diff --git a/config/config_test.go b/config/config_test.go
index cf570430be..fdf27846a8 100644
--- a/config/config_test.go
+++ b/config/config_test.go
@@ -10,11 +10,21 @@ import (
 	"github.com/gruntwork-io/go-commons/errors"
 	"github.com/gruntwork-io/terragrunt/config"
 	"github.com/gruntwork-io/terragrunt/options"
+	"github.com/gruntwork-io/terragrunt/pkg/log"
+	"github.com/gruntwork-io/terragrunt/pkg/log/format"
 	"github.com/gruntwork-io/terragrunt/util"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 )
 
+func createLogger() log.Logger {
+	formatter := format.NewFormatter()
+	formatter.DisableColors = true
+	formatter.DisableLogFormatting = true
+
+	return log.New(log.WithLevel(log.DebugLevel), log.WithFormatter(formatter))
+}
+
 func TestParseTerragruntConfigRemoteStateMinimalConfig(t *testing.T) {
 	t.Parallel()
 
@@ -465,7 +475,7 @@ include {
 	opts := &options.TerragruntOptions{
 		TerragruntConfigPath: "../test/fixtures/parent-folders/terragrunt-in-root/child/sub-child/sub-sub-child/" + config.DefaultTerragruntConfigPath,
 		NonInteractive:       true,
-		Logger:               util.CreateLogEntry("", util.GetDefaultLogLevel(), nil, true, true),
+		Logger:               createLogger(),
 	}
 
 	ctx := config.NewParsingContext(context.Background(), opts)
@@ -1240,7 +1250,7 @@ terraform {
 		TerragruntConfigPath: "../test/fixtures/parent-folders/terragrunt-in-root/child/" + config.DefaultTerragruntConfigPath,
 		NonInteractive:       true,
 		MaxFoldersToCheck:    5,
-		Logger:               util.CreateLogEntry("", util.GetDefaultLogLevel(), nil, true, true),
+		Logger:               createLogger(),
 	}
 
 	ctx := config.NewParsingContext(context.Background(), opts)
diff --git a/configstack/log_test.go b/configstack/log_test.go
index 5d5721c8bd..a6a045c51f 100644
--- a/configstack/log_test.go
+++ b/configstack/log_test.go
@@ -6,7 +6,8 @@ import (
 	"testing"
 
 	"github.com/gruntwork-io/terragrunt/configstack"
-	"github.com/sirupsen/logrus"
+	"github.com/gruntwork-io/terragrunt/pkg/log"
+	"github.com/gruntwork-io/terragrunt/pkg/log/format"
 
 	"github.com/stretchr/testify/assert"
 )
@@ -17,17 +18,23 @@ func ptr(str string) *string {
 
 func TestLogReductionHook(t *testing.T) {
 	t.Parallel()
-	var hook = configstack.NewForceLogLevelHook(logrus.ErrorLevel)
+	var hook = configstack.NewForceLogLevelHook(log.ErrorLevel)
 
 	stdout := bytes.Buffer{}
 
-	var testLogger = logrus.New()
-	testLogger.Out = &stdout
-	testLogger.AddHook(hook)
-	testLogger.Level = logrus.DebugLevel
+	formatter := format.NewFormatter()
+	formatter.DisableColors = true
+	formatter.DisableLogFormatting = true
 
-	logrus.NewEntry(testLogger).Info("Test tomato")
-	logrus.NewEntry(testLogger).Error("666 potato 111")
+	var testLogger = log.New(
+		log.WithOutput(&stdout),
+		log.WithHooks(hook),
+		log.WithLevel(log.DebugLevel),
+		log.WithFormatter(formatter),
+	)
+
+	testLogger.Info("Test tomato")
+	testLogger.Error("666 potato 111")
 
 	out := stdout.String()
 
diff --git a/pkg/cli/args_test.go b/pkg/cli/args_test.go
index 5315c2cb52..8a88409c15 100644
--- a/pkg/cli/args_test.go
+++ b/pkg/cli/args_test.go
@@ -7,36 +7,36 @@ import (
 	"github.com/stretchr/testify/assert"
 )
 
-var mockArgs = cli.Args([]string{"one", "-foo", "two", "--bar", "value"})
+var mockArgs = func() cli.Args { return cli.Args{"one", "-foo", "two", "--bar", "value"} }
 
 func TestArgsSlice(t *testing.T) {
 	t.Parallel()
 
-	actual := mockArgs.Slice()
-	expected := []string(mockArgs)
+	actual := mockArgs().Slice()
+	expected := []string(mockArgs())
 	assert.Equal(t, expected, actual)
 }
 
 func TestArgsTail(t *testing.T) {
 	t.Parallel()
 
-	actual := mockArgs.Tail()
-	expected := []string(mockArgs[1:])
+	actual := mockArgs().Tail()
+	expected := mockArgs()[1:]
 	assert.Equal(t, expected, actual)
 }
 
 func TestArgsFirst(t *testing.T) {
 	t.Parallel()
 
-	actual := mockArgs.First()
-	expected := mockArgs[0]
+	actual := mockArgs().First()
+	expected := mockArgs()[0]
 	assert.Equal(t, expected, actual)
 }
 
 func TestArgsGet(t *testing.T) {
 	t.Parallel()
 
-	actual := mockArgs.Get(2)
+	actual := mockArgs().Get(2)
 	expected := "two"
 	assert.Equal(t, expected, actual)
 }
@@ -44,7 +44,7 @@ func TestArgsGet(t *testing.T) {
 func TestArgsLen(t *testing.T) {
 	t.Parallel()
 
-	actual := mockArgs.Len()
+	actual := mockArgs().Len()
 	expected := 5
 	assert.Equal(t, expected, actual)
 }
@@ -52,12 +52,12 @@ func TestArgsLen(t *testing.T) {
 func TestArgsPresent(t *testing.T) {
 	t.Parallel()
 
-	actual := mockArgs.Present()
+	actual := mockArgs().Present()
 	expected := true
 	assert.Equal(t, expected, actual)
 
-	mockArgs := cli.Args([]string{})
-	actual = mockArgs.Present()
+	args := cli.Args([]string{})
+	actual = args.Present()
 	expected = false
 	assert.Equal(t, expected, actual)
 }
@@ -65,12 +65,12 @@ func TestArgsPresent(t *testing.T) {
 func TestArgsCommandName(t *testing.T) {
 	t.Parallel()
 
-	actual := mockArgs.CommandName()
+	actual := mockArgs().CommandName()
 	expected := "one"
 	assert.Equal(t, expected, actual)
 
-	mockArgs := mockArgs[1:]
-	actual = mockArgs.CommandName()
+	args := mockArgs()[1:]
+	actual = args.CommandName()
 	expected = ""
 	assert.Equal(t, expected, actual)
 }
@@ -78,11 +78,11 @@ func TestArgsCommandName(t *testing.T) {
 func TestArgsNormalize(t *testing.T) {
 	t.Parallel()
 
-	actual := mockArgs.Normalize(cli.SingleDashFlag).Slice()
+	actual := mockArgs().Normalize(cli.SingleDashFlag).Slice()
 	expected := []string{"one", "-foo", "two", "-bar", "value"}
 	assert.Equal(t, expected, actual)
 
-	actual = mockArgs.Normalize(cli.DoubleDashFlag).Slice()
+	actual = mockArgs().Normalize(cli.DoubleDashFlag).Slice()
 	expected = []string{"one", "--foo", "two", "--bar", "value"}
 	assert.Equal(t, expected, actual)
 }
diff --git a/pkg/cli/bool_flag_test.go b/pkg/cli/bool_flag_test.go
index e80c1c8725..9de3ed49dd 100644
--- a/pkg/cli/bool_flag_test.go
+++ b/pkg/cli/bool_flag_test.go
@@ -148,7 +148,7 @@ func testBoolFlagApply(t *testing.T, flag *cli.BoolFlag, args []string, envs map
 		assert.Equal(t, strconv.FormatBool(expectedValue), flag.GetValue(), "GetValue()")
 	}
 
-	assert.Equal(t, len(args) > 0, flag.Value().IsSet(), "IsSet()")
+	assert.Equal(t, len(args) > 0 || len(envs) > 0, flag.Value().IsSet(), "IsSet()")
 	assert.Equal(t, expectedDefaultValue, flag.Value().GetDefaultText(), "GetDefaultText()")
 
 	assert.True(t, flag.Value().IsBoolFlag(), "IsBoolFlag()")
diff --git a/pkg/cli/flags_test.go b/pkg/cli/flags_test.go
index 92bfbbe641..f6470739a2 100644
--- a/pkg/cli/flags_test.go
+++ b/pkg/cli/flags_test.go
@@ -63,7 +63,7 @@ func TestFalgsRunActions(t *testing.T) {
 
 	mockFlags := cli.Flags{
 		&cli.SliceFlag[string]{Name: "bar"},
-		&cli.GenericFlag[string]{Name: "foo", Action: func(ctx *cli.Context) error { actionHasBeenRun = true; return nil }},
+		&cli.GenericFlag[string]{Name: "foo", Action: func(ctx *cli.Context, val string) error { actionHasBeenRun = true; return nil }},
 	}
 
 	flagSet := libflag.NewFlagSet("test-cmd", libflag.ContinueOnError)
diff --git a/pkg/cli/generic_flag_test.go b/pkg/cli/generic_flag_test.go
index 3b85ca1a69..2138274be1 100644
--- a/pkg/cli/generic_flag_test.go
+++ b/pkg/cli/generic_flag_test.go
@@ -212,7 +212,7 @@ func testGenericFlagApply[T cli.GenericType](t *testing.T, flag *cli.GenericFlag
 	assert.Equal(t, expectedValue, actualValue)
 	assert.Equal(t, fmt.Sprintf("%v", expectedValue), flag.GetValue(), "GetValue()")
 
-	assert.Equal(t, len(args) > 0, flag.Value().IsSet(), "IsSet()")
+	assert.Equal(t, len(args) > 0 || len(envs) > 0, flag.Value().IsSet(), "IsSet()")
 	assert.Equal(t, expectedDefaultValue, flag.Value().GetDefaultText(), "GetDefaultText()")
 
 	assert.False(t, flag.Value().IsBoolFlag(), "IsBoolFlag()")
diff --git a/pkg/cli/map_flag_test.go b/pkg/cli/map_flag_test.go
index 0f56cb56cb..fde415005f 100644
--- a/pkg/cli/map_flag_test.go
+++ b/pkg/cli/map_flag_test.go
@@ -169,7 +169,7 @@ func testMapFlagApply[K cli.MapFlagKeyType, V cli.MapFlagValueType](t *testing.T
 
 	assert.Equal(t, collections.MapJoin(expectedValue, flag.EnvVarSep, flag.KeyValSep), flag.GetValue(), "GetValue()")
 
-	assert.Equal(t, len(args) > 0, flag.Value().IsSet(), "IsSet()")
+	assert.Equal(t, len(args) > 0 || len(envs) > 0, flag.Value().IsSet(), "IsSet()")
 	assert.Equal(t, collections.MapJoin(expectedDefaultValue, flag.EnvVarSep, flag.KeyValSep), flag.Value().GetDefaultText(), "GetDefaultText()")
 
 	assert.False(t, flag.Value().IsBoolFlag(), "IsBoolFlag()")
diff --git a/pkg/cli/slice_flag_test.go b/pkg/cli/slice_flag_test.go
index 5fa83405d3..62990b10cb 100644
--- a/pkg/cli/slice_flag_test.go
+++ b/pkg/cli/slice_flag_test.go
@@ -214,7 +214,7 @@ func testSliceFlagApply[T cli.SliceFlagType](t *testing.T, flag *cli.SliceFlag[T
 
 	assert.Equal(t, expectedStringValueFn(expectedValue), flag.GetValue(), "GetValue()")
 
-	assert.Equal(t, len(args) > 0, flag.Value().IsSet(), "IsSet()")
+	assert.Equal(t, len(args) > 0 || len(envs) > 0, flag.Value().IsSet(), "IsSet()")
 	assert.Equal(t, expectedStringValueFn(expectedDefaultValue), flag.Value().GetDefaultText(), "GetDefaultText()")
 
 	assert.False(t, flag.Value().IsBoolFlag(), "IsBoolFlag()")
diff --git a/shell/error_explainer_test.go b/shell/error_explainer_test.go
index ce48f7f084..04ddc64973 100644
--- a/shell/error_explainer_test.go
+++ b/shell/error_explainer_test.go
@@ -48,7 +48,7 @@ func TestExplainError(t *testing.T) {
 
 			err := multierror.Append(&multierror.Error{}, util.ProcessExecutionError{
 				Err:    errors.New(""),
-				StdOut: "",
+				Stdout: "",
 				Stderr: tt.errorOutput,
 			})
 			explanation := shell.ExplainError(err)
diff --git a/shell/run_shell_cmd_output_test.go b/shell/run_shell_cmd_output_test.go
index 8d5b742973..72bac20357 100644
--- a/shell/run_shell_cmd_output_test.go
+++ b/shell/run_shell_cmd_output_test.go
@@ -7,11 +7,13 @@ import (
 	"bytes"
 	"context"
 	"fmt"
+	"path/filepath"
 	"strings"
 	"sync"
 	"testing"
 
-	"github.com/gruntwork-io/terragrunt/internal/log/formatter"
+	"github.com/gruntwork-io/terragrunt/pkg/log"
+	"github.com/gruntwork-io/terragrunt/pkg/log/format"
 	"github.com/gruntwork-io/terragrunt/shell"
 	"github.com/gruntwork-io/terragrunt/util"
 
@@ -57,18 +59,19 @@ func testCommandOutputOrder(t *testing.T, withPtty bool, fullOutput []string, st
 func TestCommandOutputPrefix(t *testing.T) {
 	t.Parallel()
 	prefix := "PREFIX"
+	terraformPath := "../testdata/test_outputs.sh"
 	prefixedOutput := []string{}
 	for _, line := range FULL_OUTPUT {
-		prefixedOutput = append(prefixedOutput, fmt.Sprintf("prefix=%s msg=%s", prefix, line))
+		prefixedOutput = append(prefixedOutput, fmt.Sprintf("prefix=%s binary=%s msg=%s", prefix, filepath.Base(terraformPath), line))
 	}
 
-	formatter := formatter.NewFormatter()
-	formatter.DisableLogFormatting = true
+	logFormatter := format.NewFormatter()
+	logFormatter.DisableLogFormatting = true
 
 	testCommandOutput(t, func(terragruntOptions *options.TerragruntOptions) {
-		terragruntOptions.TerraformPath = ""
-		terragruntOptions.OutputPrefix = prefix
-		terragruntOptions.Logger.Logger.Formatter = formatter
+		terragruntOptions.TerraformPath = terraformPath
+		terragruntOptions.Logger.SetOptions(log.WithFormatter(logFormatter))
+		terragruntOptions.Logger = terragruntOptions.Logger.WithField(format.PrefixKeyName, prefix)
 	}, assertOutputs(t,
 		prefixedOutput,
 		STDOUT,
@@ -113,7 +116,7 @@ func assertOutputs(
 		allOutputs := strings.Split(strings.TrimSpace(allOutput), "\n")
 		assert.Equal(t, len(expectedAllOutputs), len(allOutputs))
 		for i := 0; i < len(allOutputs); i++ {
-			assert.Contains(t, allOutputs[i], expectedAllOutputs[i])
+			assert.Contains(t, allOutputs[i], expectedAllOutputs[i], allOutputs[i])
 		}
 
 		stdOutputs := strings.Split(strings.TrimSpace(out.Stdout), "\n")
diff --git a/terraform/getproviders/lock_test.go b/terraform/getproviders/lock_test.go
index 23b870ec4c..255f156ad7 100644
--- a/terraform/getproviders/lock_test.go
+++ b/terraform/getproviders/lock_test.go
@@ -11,6 +11,7 @@ import (
 	"path/filepath"
 	"testing"
 
+	"github.com/gruntwork-io/terragrunt/pkg/log"
 	"github.com/gruntwork-io/terragrunt/terraform/getproviders/mocks"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/mock"
@@ -42,6 +43,7 @@ func mockProviderUpdateLock(t *testing.T, address, version string) Provider {
 	provider.On("Address").Return(address)
 	provider.On("Version").Return(version)
 	provider.On("PackageDir").Return(packageDir)
+	provider.On("Logger").Return(log.New())
 	provider.On("DocumentSHA256Sums", mock.Anything).Return([]byte(document), nil)
 
 	return provider
diff --git a/terraform/getter_test.go b/terraform/getter_test.go
index e9a64a159b..9b0c67811b 100644
--- a/terraform/getter_test.go
+++ b/terraform/getter_test.go
@@ -7,6 +7,8 @@ import (
 	"path/filepath"
 	"testing"
 
+	"github.com/gruntwork-io/terragrunt/options"
+	"github.com/gruntwork-io/terragrunt/pkg/log"
 	"github.com/gruntwork-io/terragrunt/terraform"
 	"github.com/gruntwork-io/terratest/modules/files"
 	"github.com/stretchr/testify/assert"
@@ -16,7 +18,7 @@ import (
 func TestGetModuleRegistryURLBasePath(t *testing.T) {
 	t.Parallel()
 
-	basePath, err := terraform.GetModuleRegistryURLBasePath(context.Background(), "registry.terraform.io")
+	basePath, err := terraform.GetModuleRegistryURLBasePath(context.Background(), log.New(), "registry.terraform.io")
 	require.NoError(t, err)
 	assert.Equal(t, "/v1/modules/", basePath)
 }
@@ -29,7 +31,7 @@ func TestGetTerraformHeader(t *testing.T) {
 		Host:   "registry.terraform.io",
 		Path:   "/v1/modules/terraform-aws-modules/vpc/aws/3.3.0/download",
 	}
-	terraformGetHeader, err := terraform.GetTerraformGetHeader(context.Background(), testModuleURL)
+	terraformGetHeader, err := terraform.GetTerraformGetHeader(context.Background(), log.New(), testModuleURL)
 	require.NoError(t, err)
 	assert.Contains(t, terraformGetHeader, "github.com/terraform-aws-modules/terraform-aws-vpc")
 }
@@ -118,6 +120,8 @@ func TestTFRGetterRootDir(t *testing.T) {
 	assert.False(t, files.FileExists(filepath.Join(moduleDestPath, "main.tf")))
 
 	tfrGetter := new(terraform.RegistryGetter)
+	tfrGetter.TerragruntOptions, err = options.NewTerragruntOptionsForTest("")
+	require.NoError(t, err)
 	require.NoError(t, tfrGetter.Get(moduleDestPath, testModuleURL))
 	assert.True(t, files.FileExists(filepath.Join(moduleDestPath, "main.tf")))
 }
@@ -137,6 +141,7 @@ func TestTFRGetterSubModule(t *testing.T) {
 	assert.False(t, files.FileExists(filepath.Join(moduleDestPath, "main.tf")))
 
 	tfrGetter := new(terraform.RegistryGetter)
+	tfrGetter.TerragruntOptions, _ = options.NewTerragruntOptionsForTest("")
 	require.NoError(t, tfrGetter.Get(moduleDestPath, testModuleURL))
 	assert.True(t, files.FileExists(filepath.Join(moduleDestPath, "main.tf")))
 }
diff --git a/test/integration_aws_test.go b/test/integration_aws_test.go
index 44a5e59f8b..68f51520ed 100644
--- a/test/integration_aws_test.go
+++ b/test/integration_aws_test.go
@@ -964,7 +964,7 @@ func TestAwsParallelStateInit(t *testing.T) {
 		require.NoError(t, err)
 	}
 	for i := 0; i < 20; i++ {
-		err := util.CopyFolderContents(testFixtureParallelStateInit, tmpEnvPath, ".terragrunt-test", nil)
+		err := util.CopyFolderContents(createLogger(), testFixtureParallelStateInit, tmpEnvPath, ".terragrunt-test", nil)
 		require.NoError(t, err)
 		err = os.Rename(
 			path.Join(tmpEnvPath, "template"),
diff --git a/test/integration_catalog_test.go b/test/integration_catalog_test.go
index 4028f98cd9..0a23aa40b7 100644
--- a/test/integration_catalog_test.go
+++ b/test/integration_catalog_test.go
@@ -10,6 +10,7 @@ import (
 	"github.com/gruntwork-io/terragrunt/cli/commands/catalog/tui/command"
 	"github.com/gruntwork-io/terragrunt/config"
 	"github.com/gruntwork-io/terragrunt/options"
+	"github.com/gruntwork-io/terragrunt/pkg/log"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 )
@@ -22,7 +23,7 @@ func TestScaffoldGitRepo(t *testing.T) {
 	tempDir, err := os.MkdirTemp("", "catalog-*")
 	require.NoError(t, err)
 
-	repo, err := module.NewRepo(ctx, "github.com/gruntwork-io/terraform-fake-modules.git", tempDir)
+	repo, err := module.NewRepo(ctx, log.New(), "github.com/gruntwork-io/terraform-fake-modules.git", tempDir)
 	require.NoError(t, err)
 
 	modules, err := repo.FindModules(ctx)
@@ -38,7 +39,7 @@ func TestScaffoldGitModule(t *testing.T) {
 	tempDir, err := os.MkdirTemp("", "catalog-*")
 	require.NoError(t, err)
 
-	repo, err := module.NewRepo(ctx, "https://github.com/gruntwork-io/terraform-fake-modules.git", tempDir)
+	repo, err := module.NewRepo(ctx, log.New(), "https://github.com/gruntwork-io/terraform-fake-modules.git", tempDir)
 	require.NoError(t, err)
 
 	modules, err := repo.FindModules(ctx)
@@ -77,7 +78,7 @@ func TestScaffoldGitModuleHttps(t *testing.T) {
 	tempDir, err := os.MkdirTemp("", "catalog-*")
 	require.NoError(t, err)
 
-	repo, err := module.NewRepo(ctx, "https://github.com/gruntwork-io/terraform-fake-modules", tempDir)
+	repo, err := module.NewRepo(ctx, log.New(), "https://github.com/gruntwork-io/terraform-fake-modules", tempDir)
 	require.NoError(t, err)
 
 	modules, err := repo.FindModules(ctx)
diff --git a/test/integration_common_test.go b/test/integration_common_test.go
index cf306904fa..f16c086535 100644
--- a/test/integration_common_test.go
+++ b/test/integration_common_test.go
@@ -25,6 +25,8 @@ import (
 	"time"
 
 	"github.com/gruntwork-io/terragrunt/cli/commands/terraform"
+	"github.com/gruntwork-io/terragrunt/pkg/log"
+	"github.com/gruntwork-io/terragrunt/pkg/log/format"
 
 	"github.com/gruntwork-io/terragrunt/util"
 	"github.com/stretchr/testify/assert"
@@ -33,6 +35,36 @@ import (
 	"github.com/NYTimes/gziphandler"
 )
 
+func getPathRelativeTo(t *testing.T, path string, basePath string) string {
+	t.Helper()
+
+	relPath, err := util.GetPathRelativeTo(path, basePath)
+	require.NoError(t, err)
+	return relPath
+}
+
+func getPathsRelativeTo(t *testing.T, basePath string, paths []string) []string {
+	t.Helper()
+
+	relPaths := make([]string, len(paths))
+
+	for i, path := range paths {
+		relPath, err := util.GetPathRelativeTo(path, basePath)
+		require.NoError(t, err)
+		relPaths[i] = relPath
+	}
+
+	return relPaths
+}
+
+func createLogger() log.Logger {
+	formatter := format.NewFormatter()
+	formatter.DisableColors = true
+	formatter.DisableLogFormatting = true
+
+	return log.New(log.WithLevel(log.DebugLevel), log.WithFormatter(formatter))
+}
+
 func testRunAllPlan(t *testing.T, args string) (string, string, string, error) {
 	t.Helper()
 
diff --git a/test/integration_debug_test.go b/test/integration_debug_test.go
index 5ed0eaf98b..d03238c3de 100644
--- a/test/integration_debug_test.go
+++ b/test/integration_debug_test.go
@@ -48,7 +48,7 @@ func TestDebugGeneratedInputs(t *testing.T) {
 	debugFile := util.JoinPath(rootPath, terragruntDebugFile)
 	assert.True(t, util.FileExists(debugFile))
 
-	assert.Contains(t, stderr.String(), fmt.Sprintf("-chdir=\"%s\"", rootPath))
+	assert.Contains(t, stderr.String(), fmt.Sprintf("-chdir=\"%s\"", getPathRelativeTo(t, rootPath, rootPath)))
 
 	// If the debug file is generated correctly, we should be able to run terraform apply using the generated var file
 	// without going through terragrunt.
diff --git a/test/integration_destroy_test.go b/test/integration_destroy_test.go
index 357d4a14ee..6956ece08b 100644
--- a/test/integration_destroy_test.go
+++ b/test/integration_destroy_test.go
@@ -308,7 +308,7 @@ func TestStorePlanFilesRunAllDestroy(t *testing.T) {
 	_, output, err := runTerragruntCommandWithOutput(t, fmt.Sprintf("terragrunt run-all plan -destroy --terragrunt-non-interactive --terragrunt-log-level debug --terragrunt-working-dir %s --terragrunt-out-dir %s", testPath, tmpDir))
 	require.NoError(t, err)
 
-	assert.Contains(t, output, "Using output file "+tmpDir)
+	assert.Contains(t, output, "Using output file "+getPathRelativeTo(t, tmpDir, testPath))
 	// verify that tfplan files are created in the tmpDir, 2 files
 	list, err = findFilesWithExtension(tmpDir, ".tfplan")
 	require.NoError(t, err)
diff --git a/test/integration_download_test.go b/test/integration_download_test.go
index c74a2e7b14..0523260ca7 100644
--- a/test/integration_download_test.go
+++ b/test/integration_download_test.go
@@ -195,7 +195,7 @@ func TestCustomLockFile(t *testing.T) {
 
 	source := "../custom-lock-file-module"
 	downloadDir := util.JoinPath(rootPath, terragruntCache)
-	result, err := tfsource.NewSource(source, downloadDir, rootPath, util.CreateLogEntry("", util.GetDefaultLogLevel(), nil, true, true))
+	result, err := tfsource.NewSource(source, downloadDir, rootPath, createLogger())
 	require.NoError(t, err)
 
 	lockFilePath := util.JoinPath(result.WorkingDir, util.TerraformLockFile)
@@ -362,10 +362,10 @@ func TestIncludeDirsDependencyConsistencyRegression(t *testing.T) {
 	assert.NotEmpty(t, includedModulesWithNone)
 
 	includedModulesWithAmzApp := runValidateAllWithIncludeAndGetIncludedModules(t, testPath, []string{"amazing-app/k8s"}, false)
-	assert.Equal(t, []string{"amazing-app/k8s", "clusters/eks"}, includedModulesWithAmzApp)
+	assert.Equal(t, getPathsRelativeTo(t, testPath, []string{"amazing-app/k8s", "clusters/eks"}), includedModulesWithAmzApp)
 
 	includedModulesWithTestApp := runValidateAllWithIncludeAndGetIncludedModules(t, testPath, []string{"testapp/k8s"}, false)
-	assert.Equal(t, []string{"clusters/eks", "testapp/k8s"}, includedModulesWithTestApp)
+	assert.Equal(t, getPathsRelativeTo(t, testPath, []string{"clusters/eks", "testapp/k8s"}), includedModulesWithTestApp)
 }
 
 func TestIncludeDirsStrict(t *testing.T) {
@@ -388,10 +388,10 @@ func TestIncludeDirsStrict(t *testing.T) {
 	assert.Equal(t, []string{}, includedModulesWithNone)
 
 	includedModulesWithAmzApp := runValidateAllWithIncludeAndGetIncludedModules(t, testPath, []string{"amazing-app/k8s"}, true)
-	assert.Equal(t, []string{"amazing-app/k8s"}, includedModulesWithAmzApp)
+	assert.Equal(t, getPathsRelativeTo(t, testPath, []string{"amazing-app/k8s"}), includedModulesWithAmzApp)
 
 	includedModulesWithTestApp := runValidateAllWithIncludeAndGetIncludedModules(t, testPath, []string{"testapp/k8s"}, true)
-	assert.Equal(t, []string{"testapp/k8s"}, includedModulesWithTestApp)
+	assert.Equal(t, getPathsRelativeTo(t, testPath, []string{"testapp/k8s"}), includedModulesWithTestApp)
 }
 
 func TestTerragruntExternalDependencies(t *testing.T) {
diff --git a/test/integration_gcp_test.go b/test/integration_gcp_test.go
index d8f0deda15..8414a7d7ff 100644
--- a/test/integration_gcp_test.go
+++ b/test/integration_gcp_test.go
@@ -112,7 +112,7 @@ func TestGcpParallelStateInit(t *testing.T) {
 		require.NoError(t, err)
 	}
 	for i := 0; i < 20; i++ {
-		err := util.CopyFolderContents(testFixtureGcsParallelStateInit, tmpEnvPath, ".terragrunt-test", nil)
+		err := util.CopyFolderContents(createLogger(), testFixtureGcsParallelStateInit, tmpEnvPath, ".terragrunt-test", nil)
 		require.NoError(t, err)
 		err = os.Rename(
 			path.Join(tmpEnvPath, "template"),
diff --git a/test/integration_locals_test.go b/test/integration_locals_test.go
index 533b5f2bb4..062d6b6f74 100644
--- a/test/integration_locals_test.go
+++ b/test/integration_locals_test.go
@@ -105,7 +105,7 @@ func TestLogFailedLocalsEvaluation(t *testing.T) {
 	require.Error(t, err)
 
 	output := stderr.String()
-	assert.Contains(t, output, "Encountered error while evaluating locals in file terragrunt.hcl")
+	assert.Contains(t, output, "Encountered error while evaluating locals in file ./terragrunt.hcl")
 }
 
 func TestTerragruntInitRunCmd(t *testing.T) {
diff --git a/test/integration_serial_aws_test.go b/test/integration_serial_aws_test.go
index ca7102af86..5d2419f91f 100644
--- a/test/integration_serial_aws_test.go
+++ b/test/integration_serial_aws_test.go
@@ -100,7 +100,7 @@ func testRemoteFixtureParallelism(t *testing.T, parallelism int, numberOfModules
 		t.Fatalf("Failed to create temp dir due to error: %v", err)
 	}
 	for i := 0; i < numberOfModules; i++ {
-		err := util.CopyFolderContents(testFixtureParallelism, tmpEnvPath, ".terragrunt-test", nil)
+		err := util.CopyFolderContents(createLogger(), testFixtureParallelism, tmpEnvPath, ".terragrunt-test", nil)
 		if err != nil {
 			return "", 0, err
 		}
diff --git a/test/integration_serial_test.go b/test/integration_serial_test.go
index a507d600f5..5d59934e51 100644
--- a/test/integration_serial_test.go
+++ b/test/integration_serial_test.go
@@ -457,7 +457,7 @@ func TestTerragruntLogLevelEnvVarOverridesDefault(t *testing.T) {
 	assert.Contains(t, output, "level=debug")
 }
 
-func TestTerragruntLogLevelEnvVarUnparsableLogsErrorButContinues(t *testing.T) {
+func TestTerragruntLogLevelEnvVarUnparsableLogsError(t *testing.T) {
 	// NOTE: this matches logLevelEnvVar const in util/logger.go
 	t.Setenv("TERRAGRUNT_LOG_LEVEL", "unparsable")
 
@@ -465,10 +465,10 @@ func TestTerragruntLogLevelEnvVarUnparsableLogsErrorButContinues(t *testing.T) {
 	tmpEnvPath := copyEnvironment(t, ".")
 	rootPath := util.JoinPath(tmpEnvPath, testFixtureInputs)
 
-	// Ideally, we would check stderr to introspect the error message, but the global fallback logger only logs to real
-	// stderr and we can't capture the output, so in this case we only make sure that the command runs successfully to
-	// completion.
-	runTerragrunt(t, "terragrunt validate --terragrunt-non-interactive --terragrunt-working-dir "+rootPath)
+	err := runTerragruntCommand(t, "terragrunt validate --terragrunt-non-interactive --terragrunt-working-dir "+rootPath, os.Stdout, os.Stderr)
+	require.Error(t, err)
+
+	assert.Contains(t, err.Error(), "invalid level")
 }
 
 func TestTerragruntProduceTelemetryTraces(t *testing.T) {
@@ -507,143 +507,6 @@ func TestTerragruntProduceTelemetryMetrics(t *testing.T) {
 	assert.Contains(t, output, ",\"IsMonotonic\":true}}")
 }
 
-func TestTerragruntOutputJson(t *testing.T) {
-	// no parallel test execution since JSON output is global
-	defer func() {
-		util.DisableJsonFormat()
-	}()
-
-	tmpEnvPath := copyEnvironment(t, testFixtureNotExistingSource)
-	cleanupTerraformFolder(t, tmpEnvPath)
-	testPath := util.JoinPath(tmpEnvPath, testFixtureNotExistingSource)
-
-	_, stderr, err := runTerragruntCommandWithOutput(t, "terragrunt apply --terragrunt-json-log --terragrunt-non-interactive --terragrunt-working-dir "+testPath)
-	require.Error(t, err)
-
-	// for windows OS
-	output := bytes.ReplaceAll([]byte(stderr), []byte("\r\n"), []byte("\n"))
-
-	multipeJSONs := bytes.Split(output, []byte("\n"))
-
-	var msgs = make([]string, 0, len(multipeJSONs))
-
-	for _, jsonBytes := range multipeJSONs {
-		if len(jsonBytes) == 0 {
-			continue
-		}
-
-		var output map[string]interface{}
-
-		err = json.Unmarshal(jsonBytes, &output)
-		require.NoError(t, err)
-
-		msg, ok := output["msg"].(string)
-		assert.True(t, ok)
-		msgs = append(msgs, msg)
-	}
-
-	assert.Contains(t, strings.Join(msgs, ""), "Downloading Terraform configurations from git::https://github.com/gruntwork-io/terragrunt.git?ref=v0.9.9")
-}
-
-func TestTerragruntTerraformOutputJson(t *testing.T) {
-	// no parallel test execution since JSON output is global
-	defer util.DisableJsonFormat()
-
-	tmpEnvPath := copyEnvironment(t, testFixtureInitError)
-	cleanupTerraformFolder(t, tmpEnvPath)
-	testPath := util.JoinPath(tmpEnvPath, testFixtureInitError)
-
-	_, stderr, err := runTerragruntCommandWithOutput(t, "terragrunt apply --no-color --terragrunt-json-log --terragrunt-tf-logs-to-json --terragrunt-non-interactive --terragrunt-forward-tf-stdout --terragrunt-working-dir "+testPath)
-	require.Error(t, err)
-
-	assert.Contains(t, stderr, "\"msg\":\"Initializing the backend...")
-
-	// check if output can be extracted in json
-	jsonStrings := strings.Split(stderr, "\n")
-	for _, jsonString := range jsonStrings {
-		if len(jsonString) == 0 {
-			continue
-		}
-		var output map[string]interface{}
-		err = json.Unmarshal([]byte(jsonString), &output)
-		require.NoErrorf(t, err, "Failed to parse json %s", jsonString)
-		assert.NotNil(t, output["level"])
-		assert.NotNil(t, output["time"])
-	}
-}
-
-func TestTerragruntOutputFromDependencyLogsJson(t *testing.T) {
-	// no parallel test execution since JSON output is global
-	testCases := []struct {
-		arg string
-	}{
-		{"--terragrunt-json-log"},
-		{"--terragrunt-json-log --terragrunt-tf-logs-to-json"},
-		{"--terragrunt-forward-tf-stdout"},
-		{"--terragrunt-json-log --terragrunt-tf-logs-to-json --terragrunt-forward-tf-stdout"},
-	}
-	for _, testCase := range testCases {
-		testCase := testCase
-		t.Run("terragrunt output with "+testCase.arg, func(t *testing.T) {
-			defer func() {
-				util.DisableJsonFormat()
-			}()
-			tmpEnvPath := copyEnvironment(t, testFixtureDependencyOutput)
-			rootTerragruntPath := util.JoinPath(tmpEnvPath, testFixtureDependencyOutput)
-			// apply dependency first
-			dependencyTerragruntConfigPath := util.JoinPath(rootTerragruntPath, "dependency")
-			_, _, err := runTerragruntCommandWithOutput(t, fmt.Sprintf("terragrunt apply -auto-approve --terragrunt-non-interactive --terragrunt-working-dir %s ", dependencyTerragruntConfigPath))
-			require.NoError(t, err)
-			appTerragruntConfigPath := util.JoinPath(rootTerragruntPath, "app")
-			stdout, stderr, err := runTerragruntCommandWithOutput(t, fmt.Sprintf("terragrunt plan --terragrunt-non-interactive --terragrunt-working-dir %s %s", appTerragruntConfigPath, testCase.arg))
-			require.NoError(t, err)
-			output := fmt.Sprintf("%s %s", stderr, stdout)
-			assert.NotContains(t, output, "invalid character")
-		})
-
-	}
-}
-
-func TestTerragruntJsonPlanJsonOutput(t *testing.T) {
-	// no parallel test execution since JSON output is global
-	testCases := []struct {
-		arg string
-	}{
-		{"--terragrunt-json-log"},
-		{"--terragrunt-json-log --terragrunt-tf-logs-to-json"},
-		{"--terragrunt-forward-tf-stdout"},
-		{"--terragrunt-json-log --terragrunt-tf-logs-to-json --terragrunt-forward-tf-stdout"},
-	}
-	for _, testCase := range testCases {
-		testCase := testCase
-		t.Run("terragrunt with "+testCase.arg, func(t *testing.T) {
-			defer func() {
-				util.DisableJsonFormat()
-			}()
-			tmpDir := t.TempDir()
-			_, _, _, err := testRunAllPlan(t, fmt.Sprintf("--terragrunt-json-out-dir %s %s", tmpDir, testCase.arg))
-			require.NoError(t, err)
-			list, err := findFilesWithExtension(tmpDir, ".json")
-			require.NoError(t, err)
-			assert.Len(t, list, 2)
-			for _, file := range list {
-				assert.Equal(t, "tfplan.json", filepath.Base(file))
-				// verify that file is not empty
-				content, err := os.ReadFile(file)
-				require.NoError(t, err)
-				assert.NotEmpty(t, content)
-				// check that produced json is valid and can be unmarshalled
-				var plan map[string]interface{}
-				err = json.Unmarshal(content, &plan)
-				require.NoError(t, err)
-				// check that plan is not empty
-				assert.NotEmpty(t, plan)
-			}
-		})
-
-	}
-}
-
 func TestTerragruntProduceTelemetryTracesWithRootSpanAndTraceID(t *testing.T) {
 	t.Setenv("TERRAGRUNT_TELEMETRY_TRACE_EXPORTER", "console")
 	t.Setenv("TRACEPARENT", "00-b2ff2d54551433d53dd807a6c94e81d1-0e6f631d793c718a-01")
@@ -832,3 +695,22 @@ func TestReadTerragruntAuthProviderCmdCredsForDependency(t *testing.T) {
 	})
 	runTerragrunt(t, fmt.Sprintf("terragrunt run-all apply --terragrunt-non-interactive --terragrunt-working-dir %s --terragrunt-auth-provider-cmd %s", rootPath, mockAuthCmd))
 }
+
+func TestParseTFLog(t *testing.T) {
+	os.Setenv("TF_LOG", "info")
+
+	defer func() {
+		os.Setenv("TF_LOG", "")
+	}()
+
+	cleanupTerraformFolder(t, testFixtureLogFormatter)
+	tmpEnvPath := copyEnvironment(t, testFixtureLogFormatter)
+	rootPath := util.JoinPath(tmpEnvPath, testFixtureLogFormatter)
+
+	_, stderr, err := runTerragruntCommandWithOutput(t, "terragrunt run-all init --terragrunt-log-level debug --terragrunt-non-interactive --terragrunt-disable-log-formatting=false -no-color --terragrunt-no-color --terragrunt-working-dir "+rootPath)
+	require.NoError(t, err)
+
+	for _, prefixName := range []string{"app", "dep"} {
+		assert.Contains(t, stderr, "INFO   ["+prefixName+"] "+wrappedBinary()+`: TF_LOG: Go runtime version`)
+	}
+}
diff --git a/test/integration_test.go b/test/integration_test.go
index 99e27bf586..03b5439826 100644
--- a/test/integration_test.go
+++ b/test/integration_test.go
@@ -118,6 +118,37 @@ const (
 	fixtureDownload = "fixtures/download"
 )
 
+func TestDisableLogging(t *testing.T) {
+	t.Parallel()
+
+	cleanupTerraformFolder(t, testFixtureLogFormatter)
+	tmpEnvPath := copyEnvironment(t, testFixtureLogFormatter)
+	rootPath := util.JoinPath(tmpEnvPath, testFixtureLogFormatter)
+
+	stdout, stderr, err := runTerragruntCommandWithOutput(t, "terragrunt run-all init --terragrunt-log-level debug --terragrunt-log-disable --terragrunt-non-interactive --terragrunt-disable-log-formatting=false -no-color --terragrunt-no-color --terragrunt-working-dir "+rootPath)
+	require.NoError(t, err)
+
+	assert.Contains(t, stdout, "Initializing provider plugins...")
+	assert.Empty(t, stderr)
+}
+
+func TestLogWithAbsPath(t *testing.T) {
+	t.Parallel()
+
+	cleanupTerraformFolder(t, testFixtureLogFormatter)
+	tmpEnvPath := copyEnvironment(t, testFixtureLogFormatter)
+	rootPath := util.JoinPath(tmpEnvPath, testFixtureLogFormatter)
+
+	stdout, stderr, err := runTerragruntCommandWithOutput(t, "terragrunt run-all init --terragrunt-log-level debug --terragrunt-log-show-abs-paths --terragrunt-non-interactive --terragrunt-disable-log-formatting=false -no-color --terragrunt-no-color --terragrunt-working-dir "+rootPath)
+	require.NoError(t, err)
+
+	for _, prefixName := range []string{"app", "dep"} {
+		prefixName = filepath.Join(rootPath, prefixName)
+		assert.Contains(t, stdout, "STDOUT ["+prefixName+"] "+wrappedBinary()+": Initializing provider plugins...")
+		assert.Contains(t, stderr, "DEBUG  ["+prefixName+"] Reading Terragrunt config file at "+prefixName+"/terragrunt.hcl")
+	}
+}
+
 func TestLogFormatterPrettyOutput(t *testing.T) {
 	t.Parallel()
 
@@ -125,14 +156,14 @@ func TestLogFormatterPrettyOutput(t *testing.T) {
 	tmpEnvPath := copyEnvironment(t, testFixtureLogFormatter)
 	rootPath := util.JoinPath(tmpEnvPath, testFixtureLogFormatter)
 
-	stdout, stderr, err := runTerragruntCommandWithOutput(t, "terragrunt run-all init --terragrunt-log-level debug --terragrunt-non-interactive --terragrunt-disable-log-formatting=false --terragrunt-working-dir "+rootPath)
+	stdout, stderr, err := runTerragruntCommandWithOutput(t, "terragrunt run-all init --terragrunt-log-level debug --terragrunt-non-interactive --terragrunt-disable-log-formatting=false -no-color --terragrunt-no-color --terragrunt-working-dir "+rootPath)
 	require.NoError(t, err)
 
 	for _, prefixName := range []string{"app", "dep"} {
-		assert.Regexp(t, regexp.QuoteMeta("\x1b[0mSTDOUT \x1b[38;5;")+`\d+`+regexp.QuoteMeta("m["+prefixName+"] \x1b[0m\x1b[36m"+wrappedBinary()+": \x1b[0m\x1b[0m\x1b[1mInitializing provider plugins..."), stdout)
-		assert.Regexp(t, regexp.QuoteMeta("\x1b[0m\x1b[94mDEBUG  \x1b[0m\x1b[38;5;")+`\d+`+regexp.QuoteMeta("m["+prefixName+"] \x1b[0mReading Terragrunt config file at "+prefixName+"/terragrunt.hcl"), stderr)
+		assert.Contains(t, stdout, "STDOUT ["+prefixName+"] "+wrappedBinary()+": Initializing provider plugins...")
+		assert.Contains(t, stderr, "DEBUG  ["+prefixName+"] Reading Terragrunt config file at ./"+prefixName+"/terragrunt.hcl")
 	}
-	assert.Contains(t, stderr, "\x1b[0m\x1b[94mDEBUG  \x1b[0mTerragrunt Version:")
+	assert.Contains(t, stderr, "DEBUG  Terragrunt Version:")
 }
 
 func TestLogFormatterKeyValueOutput(t *testing.T) {
@@ -147,9 +178,9 @@ func TestLogFormatterKeyValueOutput(t *testing.T) {
 
 	for _, prefixName := range []string{"app", "dep"} {
 		assert.Contains(t, stdout, "level=stdout prefix="+prefixName+" binary="+wrappedBinary()+" msg=Initializing provider plugins...\n")
-		assert.Contains(t, stderr, "level=debug prefix="+prefixName+" msg=Reading Terragrunt config file at "+prefixName+"/terragrunt.hcl\n")
+		assert.Contains(t, stderr, "level=debug prefix="+prefixName+" msg=Reading Terragrunt config file at ./"+prefixName+"/terragrunt.hcl\n")
 	}
-	assert.Contains(t, stderr, "level=debug msg=Terragrunt Version:")
+	assert.Contains(t, stderr, "level=debug prefix=. msg=Terragrunt Version:")
 }
 
 func TestLogRawModuleOutput(t *testing.T) {
@@ -813,7 +844,7 @@ func TestApplySkipTrue(t *testing.T) {
 	stderr := showStderr.String()
 
 	require.NoError(t, err)
-	assert.Regexp(t, regexp.MustCompile("Skipping terragrunt module .*fixtures/skip/skip-true/terragrunt.hcl due to skip = true."), stderr)
+	assert.Contains(t, stderr, "Skipping terragrunt module ./terragrunt.hcl due to skip = true.")
 	assert.NotContains(t, stdout, "hello, Hobbs")
 }
 
@@ -855,7 +886,7 @@ func TestApplyAllSkipTrue(t *testing.T) {
 	stderr := showStderr.String()
 
 	require.NoError(t, err)
-	assert.Regexp(t, regexp.MustCompile("Skipping terragrunt module .*fixtures/skip/skip-true/terragrunt.hcl due to skip = true."), stderr)
+	assert.Contains(t, stderr, "Skipping terragrunt module ./terragrunt.hcl due to skip = true.")
 	assert.Contains(t, stdout, "hello, Ernie")
 	assert.Contains(t, stdout, "hello, Bert")
 }
@@ -2553,7 +2584,7 @@ func TestLogFailingDependencies(t *testing.T) {
 	require.NoError(t, err)
 
 	output := stderr.String()
-	assert.Contains(t, output, fmt.Sprintf("%s invocation failed in %s", wrappedBinary(), testdataDir))
+	assert.Contains(t, output, fmt.Sprintf("%s invocation failed in %s", wrappedBinary(), getPathRelativeTo(t, testdataDir, path)))
 }
 
 func cleanupTerraformFolder(t *testing.T, templatesPath string) {
@@ -2602,7 +2633,8 @@ func runTerragruntCommand(t *testing.T, command string, writer io.Writer, errwri
 
 	t.Log(args)
 
-	app := cli.NewApp(writer, errwriter)
+	opts := options.NewTerragruntOptionsWithWriters(writer, errwriter)
+	app := cli.NewApp(opts)
 	return app.Run(args)
 }
 
@@ -2658,7 +2690,7 @@ func copyEnvironment(t *testing.T, environmentPath string, includeInCopy ...stri
 
 	t.Logf("Copying %s to %s", environmentPath, tmpDir)
 
-	require.NoError(t, util.CopyFolderContents(environmentPath, util.JoinPath(tmpDir, environmentPath), ".terragrunt-test", includeInCopy))
+	require.NoError(t, util.CopyFolderContents(createLogger(), environmentPath, util.JoinPath(tmpDir, environmentPath), ".terragrunt-test", includeInCopy))
 
 	return tmpDir
 }
@@ -2854,7 +2886,7 @@ func runValidateAllWithIncludeAndGetIncludedModules(t *testing.T, rootModulePath
 	includedModules := []string{}
 	for _, match := range matches {
 		if match[2] == "false" {
-			includedModules = append(includedModules, match[1])
+			includedModules = append(includedModules, getPathRelativeTo(t, match[1], rootModulePath))
 		}
 	}
 	sort.Strings(includedModules)
@@ -3124,7 +3156,7 @@ func TestInitFailureModulePrefix(t *testing.T) {
 		runTerragruntCommand(t, "terragrunt init -no-color --terragrunt-non-interactive --terragrunt-working-dir "+initTestCase, &stdout, &stderr),
 	)
 	logBufferContentsLineByLine(t, stderr, "init")
-	assert.Contains(t, stderr.String(), "error=[fixtures/init-error]")
+	assert.Contains(t, stderr.String(), "fixtures/init-error")
 }
 
 func TestDependencyOutputModulePrefix(t *testing.T) {
@@ -3219,7 +3251,7 @@ func TestModulePathInRunAllPlanErrorMessage(t *testing.T) {
 	require.Error(t, err)
 	output := fmt.Sprintf("%s\n%s\n%v\n", stdout.String(), stderr.String(), err.Error())
 	assert.Contains(t, output, "finished with an error")
-	assert.Contains(t, output, "Module d1", output)
+	assert.Contains(t, output, "Module ./d1", output)
 }
 
 func TestHclFmtDiff(t *testing.T) {
@@ -3574,7 +3606,7 @@ func TestStorePlanFilesRunAllPlanApply(t *testing.T) {
 	_, output, err := runTerragruntCommandWithOutput(t, fmt.Sprintf("terragrunt run-all plan --terragrunt-non-interactive --terragrunt-log-level debug --terragrunt-working-dir %s --terragrunt-out-dir %s", testPath, tmpDir))
 	require.NoError(t, err)
 
-	assert.Contains(t, output, "Using output file "+tmpDir)
+	assert.Contains(t, output, "Using output file "+getPathRelativeTo(t, tmpDir, testPath))
 
 	// verify that tfplan files are created in the tmpDir, 2 files
 	list, err := findFilesWithExtension(tmpDir, ".tfplan")
@@ -3718,3 +3750,134 @@ func createTmpTerragruntConfigWithParentAndChild(t *testing.T, parentPath string
 
 	return childTerragruntDestPath
 }
+
+func TestTerragruntOutputJson(t *testing.T) {
+	t.Parallel()
+
+	tmpEnvPath := copyEnvironment(t, testFixtureNotExistingSource)
+	cleanupTerraformFolder(t, tmpEnvPath)
+	testPath := util.JoinPath(tmpEnvPath, testFixtureNotExistingSource)
+
+	_, stderr, err := runTerragruntCommandWithOutput(t, "terragrunt apply --terragrunt-json-log --terragrunt-non-interactive --terragrunt-working-dir "+testPath)
+	require.Error(t, err)
+
+	// for windows OS
+	output := bytes.ReplaceAll([]byte(stderr), []byte("\r\n"), []byte("\n"))
+
+	multipeJSONs := bytes.Split(output, []byte("\n"))
+
+	var msgs = make([]string, 0, len(multipeJSONs))
+
+	for _, jsonBytes := range multipeJSONs {
+		if len(jsonBytes) == 0 {
+			continue
+		}
+
+		var output map[string]interface{}
+
+		err = json.Unmarshal(jsonBytes, &output)
+		require.NoError(t, err)
+
+		msg, ok := output["msg"].(string)
+		assert.True(t, ok)
+		msgs = append(msgs, msg)
+	}
+
+	assert.Contains(t, strings.Join(msgs, ""), "Downloading Terraform configurations from git::https://github.com/gruntwork-io/terragrunt.git?ref=v0.9.9")
+}
+
+func TestTerragruntTerraformOutputJson(t *testing.T) {
+	t.Parallel()
+
+	tmpEnvPath := copyEnvironment(t, testFixtureInitError)
+	cleanupTerraformFolder(t, tmpEnvPath)
+	testPath := util.JoinPath(tmpEnvPath, testFixtureInitError)
+
+	_, stderr, err := runTerragruntCommandWithOutput(t, "terragrunt apply --no-color --terragrunt-json-log --terragrunt-tf-logs-to-json --terragrunt-forward-tf-stdout --terragrunt-non-interactive --terragrunt-working-dir "+testPath)
+	require.Error(t, err)
+
+	assert.Contains(t, stderr, `"msg":"Initializing the backend..."`)
+
+	// check if output can be extracted in json
+	jsonStrings := strings.Split(stderr, "\n")
+	for _, jsonString := range jsonStrings {
+		if len(jsonString) == 0 {
+			continue
+		}
+		var output map[string]interface{}
+		err = json.Unmarshal([]byte(jsonString), &output)
+		require.NoErrorf(t, err, "Failed to parse json %s", jsonString)
+		assert.NotNil(t, output["level"])
+		assert.NotNil(t, output["time"])
+	}
+}
+
+func TestTerragruntOutputFromDependencyLogsJson(t *testing.T) {
+	t.Parallel()
+
+	testCases := []struct {
+		arg string
+	}{
+		{"--terragrunt-json-log"},
+		{"--terragrunt-json-log --terragrunt-tf-logs-to-json"},
+		{"--terragrunt-forward-tf-stdout"},
+		{"--terragrunt-json-log --terragrunt-tf-logs-to-json --terragrunt-forward-tf-stdout"},
+	}
+	for _, testCase := range testCases {
+		testCase := testCase
+		t.Run("terragrunt output with "+testCase.arg, func(t *testing.T) {
+			t.Parallel()
+			tmpEnvPath := copyEnvironment(t, testFixtureDependencyOutput)
+			rootTerragruntPath := util.JoinPath(tmpEnvPath, testFixtureDependencyOutput)
+			// apply dependency first
+			dependencyTerragruntConfigPath := util.JoinPath(rootTerragruntPath, "dependency")
+			_, _, err := runTerragruntCommandWithOutput(t, fmt.Sprintf("terragrunt apply -auto-approve --terragrunt-non-interactive --terragrunt-working-dir %s ", dependencyTerragruntConfigPath))
+			require.NoError(t, err)
+			appTerragruntConfigPath := util.JoinPath(rootTerragruntPath, "app")
+			stdout, stderr, err := runTerragruntCommandWithOutput(t, fmt.Sprintf("terragrunt plan --terragrunt-non-interactive --terragrunt-working-dir %s %s", appTerragruntConfigPath, testCase.arg))
+			require.NoError(t, err)
+			output := fmt.Sprintf("%s %s", stderr, stdout)
+			assert.NotContains(t, output, "invalid character")
+		})
+
+	}
+}
+
+func TestTerragruntJsonPlanJsonOutput(t *testing.T) {
+	t.Parallel()
+
+	testCases := []struct {
+		arg string
+	}{
+		{"--terragrunt-json-log"},
+		{"--terragrunt-json-log --terragrunt-tf-logs-to-json"},
+		{"--terragrunt-forward-tf-stdout"},
+		{"--terragrunt-json-log --terragrunt-tf-logs-to-json --terragrunt-forward-tf-stdout"},
+	}
+	for _, testCase := range testCases {
+		testCase := testCase
+		t.Run("terragrunt with "+testCase.arg, func(t *testing.T) {
+			t.Parallel()
+			tmpDir := t.TempDir()
+			_, _, _, err := testRunAllPlan(t, fmt.Sprintf("--terragrunt-json-out-dir %s %s", tmpDir, testCase.arg))
+			require.NoError(t, err)
+			list, err := findFilesWithExtension(tmpDir, ".json")
+			require.NoError(t, err)
+			assert.Len(t, list, 2)
+			for _, file := range list {
+				assert.Equal(t, "tfplan.json", filepath.Base(file))
+				// verify that file is not empty
+				content, err := os.ReadFile(file)
+				require.NoError(t, err)
+				assert.NotEmpty(t, content)
+				// check that produced json is valid and can be unmarshalled
+				var plan map[string]interface{}
+				err = json.Unmarshal(content, &plan)
+				require.NoError(t, err)
+				// check that plan is not empty
+				assert.NotEmpty(t, plan)
+			}
+		})
+
+	}
+}
diff --git a/test/integration_tflint_test.go b/test/integration_tflint_test.go
index 660674cc79..19d5cf5cf8 100644
--- a/test/integration_tflint_test.go
+++ b/test/integration_tflint_test.go
@@ -8,6 +8,7 @@ import (
 	"fmt"
 	"io"
 	"os"
+	"path/filepath"
 	"regexp"
 	"testing"
 
@@ -39,7 +40,7 @@ func TestTflintFindsNoIssuesWithValidCode(t *testing.T) {
 	assert.NotContains(t, errOut.String(), "Error while running tflint with args:")
 	assert.NotContains(t, errOut.String(), "Tflint found issues in the project. Check for the tflint logs above.")
 
-	found, err := regexp.MatchString(fmt.Sprintf("--config %s/.terragrunt-cache/.*/.tflint.hcl", modulePath), errOut.String())
+	found, err := regexp.MatchString("--config ./.terragrunt-cache/.*/.tflint.hcl", errOut.String())
 	assert.NoError(t, err)
 	assert.True(t, found)
 }
@@ -81,7 +82,7 @@ func TestTflintFindsConfigInCurrentPath(t *testing.T) {
 	assert.NoError(t, err)
 
 	assert.Contains(t, errOut.String(), "Tflint has run successfully. No issues found")
-	assert.Contains(t, errOut.String(), fmt.Sprintf("--config %s/.tflint.hcl", modulePath))
+	assert.Contains(t, errOut.String(), "--config ./.tflint.hcl")
 }
 
 func TestTflintInitSameModule(t *testing.T) {
@@ -95,7 +96,7 @@ func TestTflintInitSameModule(t *testing.T) {
 	// generate multiple "app" modules that will be initialized in parallel
 	for i := 0; i < 50; i++ {
 		appPath := util.JoinPath(modulePath, "dev", fmt.Sprintf("app-%d", i))
-		err := util.CopyFolderContents(appTemplate, appPath, ".terragrunt-test", []string{})
+		err := util.CopyFolderContents(createLogger(), appTemplate, appPath, ".terragrunt-test", []string{})
 		assert.NoError(t, err)
 	}
 	runTerragrunt(t, fmt.Sprintf("terragrunt run-all init --terragrunt-log-level debug --terragrunt-non-interactive --terragrunt-working-dir %s", runPath))
@@ -117,7 +118,11 @@ func TestTflintFindsNoIssuesWithValidCodeDifferentDownloadDir(t *testing.T) {
 
 	assert.NotContains(t, errOut.String(), "Error while running tflint with args:")
 	assert.NotContains(t, errOut.String(), "Tflint found issues in the project. Check for the tflint logs above.")
-	found, err := regexp.MatchString(fmt.Sprintf("--config %s/.*/.tflint.hcl", downloadDir), errOut.String())
+
+	relPath, err := filepath.Rel(modulePath, downloadDir)
+	assert.NoError(t, err)
+
+	found, err := regexp.MatchString(fmt.Sprintf("--config %s/.*/.tflint.hcl", relPath), errOut.String())
 	assert.NoError(t, err)
 	assert.True(t, found)
 }
@@ -194,7 +199,7 @@ func copyEnvironmentWithTflint(t *testing.T, environmentPath string) string {
 
 	t.Logf("Copying %s to %s", environmentPath, tmpDir)
 
-	require.NoError(t, util.CopyFolderContents(environmentPath, util.JoinPath(tmpDir, environmentPath), ".terragrunt-test", []string{".tflint.hcl"}))
+	require.NoError(t, util.CopyFolderContents(createLogger(), environmentPath, util.JoinPath(tmpDir, environmentPath), ".terragrunt-test", []string{".tflint.hcl"}))
 
 	return tmpDir
 }
diff --git a/test/integration_windows_test.go b/test/integration_windows_test.go
index 74c2e09062..feaad7d008 100644
--- a/test/integration_windows_test.go
+++ b/test/integration_windows_test.go
@@ -176,7 +176,7 @@ func copyEnvironmentToPath(t *testing.T, environmentPath, targetPath string) {
 		t.Fatalf("Failed to create temp dir %s due to error %v", targetPath, err)
 	}
 
-	copyErr := util.CopyFolderContents(environmentPath, util.JoinPath(targetPath, environmentPath), ".terragrunt-test", nil)
+	copyErr := util.CopyFolderContents(createLogger(), environmentPath, util.JoinPath(targetPath, environmentPath), ".terragrunt-test", nil)
 	require.NoError(t, copyErr)
 }
 
@@ -188,7 +188,7 @@ func copyEnvironmentWithTflint(t *testing.T, environmentPath string) string {
 
 	t.Logf("Copying %s to %s", environmentPath, tmpDir)
 
-	require.NoError(t, util.CopyFolderContents(environmentPath, util.JoinPath(tmpDir, environmentPath), ".terragrunt-test", []string{".tflint.hcl"}))
+	require.NoError(t, util.CopyFolderContents(createLogger(), environmentPath, util.JoinPath(tmpDir, environmentPath), ".terragrunt-test", []string{".tflint.hcl"}))
 
 	return tmpDir
 }
diff --git a/util/file_test.go b/util/file_test.go
index 70ca5b5386..d3d724dfa6 100644
--- a/util/file_test.go
+++ b/util/file_test.go
@@ -11,6 +11,7 @@ import (
 
 	"fmt"
 
+	"github.com/gruntwork-io/terragrunt/pkg/log"
 	"github.com/gruntwork-io/terragrunt/test/helpers"
 	"github.com/gruntwork-io/terragrunt/util"
 	"github.com/stretchr/testify/assert"
@@ -218,7 +219,7 @@ func TestFileManifest(t *testing.T) {
 	testfiles = append(testfiles, path.Join(dir, "ephemeral-file-that-doesnt-exist.txt"))
 
 	// create a manifest
-	manifest := util.NewFileManifest(dir, ".terragrunt-test-manifest")
+	manifest := util.NewFileManifest(log.New(), dir, ".terragrunt-test-manifest")
 	require.NoError(t, manifest.Create())
 	// check the file manifest has been created
 	assert.FileExists(t, filepath.Join(manifest.ManifestFolder, manifest.ManifestFile))
@@ -361,7 +362,7 @@ func TestIncludeInCopy(t *testing.T) {
 		assert.NoError(t, os.WriteFile(path, fileContent, 0644))
 	}
 
-	require.NoError(t, util.CopyFolderContents(source, destination, ".terragrunt-test", includeInCopy))
+	require.NoError(t, util.CopyFolderContents(log.New(), source, destination, ".terragrunt-test", includeInCopy))
 
 	for i, tt := range tc {
 		tt := tt