05 Implementing a Singleton Logrus Logger in Golang
This time we will try to implement the Singleton Design Pattern. What is a Singleton? You can first read the article here. To understand it better, a Singleton is a standard software design that guarantees the existence of only one instance of a class while maintaining a global access point to its object. The Singleton is a design pattern that restricts the instantiation of an object, ensuring that it happens only once.
There are 2 approaches we will try to implement Logrus with: directly using Logrus’ own Singleton, and building a custom one ourselves because of certain needs, such as setting our own fields or other requirements.
Using the Logrus Singleton
This time we will try to initialize a Logrus object as a Logger so that we no longer need to create our own logger object. If we use the Singleton, the logger will be changed automatically. By default, the singleton Logger in Logrus uses the TextFormatter and the Info Level. To create a singleton Logger, we will simply use the Logrus package directly.
First, we will create a unit test function like the one below.
1func TestSingletone(t *testing.T) {
2 // set formatter using JSON
3 logrus.SetFormatter(&logrus.JSONFormatter{})
4 // initiate log rus
5 logrus.Info("info using Logrus singleton")
6 logrus.Warn("warn using Logrus singleton")
7 logrus.Error("error using Logrus singleton")
8}When it runs, the output will look like this.
1{"level":"info","msg":"info using Logrus singleton","time":"2024-04-30T23:40:00+07:00"}
2{"level":"warning","msg":"warn using Logrus singleton","time":"2024-04-30T23:40:00+07:00"}
3{"level":"error","msg":"error using Logrus singleton","time":"2024-04-30T23:40:00+07:00"}Creating a Custom Singleton to Initialize Logrus
We will create a Singleton to initialize Logrus, customized to make it easier and to match our needs. First, let’s create a pkg/env folder, which is used for checking the environment used by our system. The package we create here handles the environment requirements used in our system so that it is easier to use.
First, let’s create the env.go file with the following initialization function.
1type ServiceEnvironment = string
2
3const (
4 DevelopmentEnv = "development"
5 StagingEnv = "staging"
6 ProductionEnv = "production"
7 EnvironmentName = "environment"
8 GoVersionName = "go_version"
9)
10
11var (
12 envName = "SERVICE_ENV"
13 goVersion string
14)
15
16func Init() error {
17 err := SetFromEnvFile(".env")
18 if err != nil && !os.IsNotExist(err) {
19 log.Printf("failed to set env file: %v\n", err)
20 return err
21 }
22
23 goVersion = runtime.Version()
24 return nil
25}
26
27func SetFromEnvFile(filepath string) error {
28 if _, err := os.Stat(filepath); err != nil {
29 return err
30 }
31
32 f, err := os.Open(filepath)
33 if err != nil {
34 return err
35 }
36 scanner := bufio.NewScanner(f)
37 if err := scanner.Err(); err != nil {
38 return err
39 }
40 for scanner.Scan() {
41 text := scanner.Text()
42 text = strings.TrimSpace(text)
43 vars := strings.SplitN(text, "=", 2)
44 if len(vars) < 2 {
45 return err
46 }
47 if err := os.Setenv(vars[0], vars[1]); err != nil {
48 return err
49 }
50 }
51 return nil
52}This initialize function is used when the system starts up: it reads the .env file, reads several tags in that file, and then sets them into the system environment on the server.
There are a few additional functions needed by the system, namely the ability to retrieve data based on the currently running environment, such as development, staging, and production.
1func ServiceEnv() ServiceEnvironment {
2 e := os.Getenv(envName)
3 if e == "" {
4 e = DevelopmentEnv
5 }
6 return e
7}
8
9func GetVersion() string {
10 return goVersion
11}
12
13func IsDevelopment() bool {
14 return ServiceEnv() == DevelopmentEnv
15}
16
17func IsStaging() bool {
18 return ServiceEnv() == StagingEnv
19}
20
21func IsProduction() bool {
22 return ServiceEnv() == ProductionEnv
23}Next, we create a new file in a new folder as well, named pkg/log/log.go, with the contents shown below.
1package log
2
3import (
4 "errors"
5 "os"
6
7 "github.com/santekno/learn-golang-logging/pkg/env"
8 "github.com/sirupsen/logrus"
9)
10
11var (
12 log, _ = NewLogger(&Config{Formatter: &TextFormatter, Level: InfoLevel, LogName: "application.log"})
13 JSONFormatter logrus.JSONFormatter
14 TextFormatter logrus.TextFormatter
15 serviceFields = map[string]interface{}{
16 env.EnvironmentName: env.ServiceEnv(),
17 env.GoVersionName: env.GetVersion(),
18 }
19)
20
21type (
22 Level = logrus.Level
23 Logger = *logrus.Logger
24)
25
26const (
27 // override logrus level
28 Paniclevel = logrus.PanicLevel
29 FatalLevel = logrus.FatalLevel
30 ErrorLevel = logrus.ErrorLevel
31 WarnLevel = logrus.WarnLevel
32 InfoLevel = logrus.InfoLevel
33 DebugLevel = logrus.DebugLevel
34 TraceLevel = logrus.TraceLevel
35)
36
37type Config struct {
38 logrus.Formatter
39 logrus.Level
40 LogName string
41}
42
43func NewLogger(cfg *Config) (Logger, error) {
44 l := logrus.New()
45 if env.IsDevelopment() {
46 l.SetFormatter(&logrus.TextFormatter{})
47 }
48 l.SetFormatter(cfg.Formatter)
49 l.SetLevel(cfg.Level)
50 return l, nil
51}
52
53func SetConfig(cfg *Config) error {
54 if cfg.LogName == "" {
55 return errors.New("log name is empty")
56 }
57
58 if !env.IsDevelopment() {
59 // initiation create file for logger
60 file, err := os.OpenFile(cfg.LogName, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0666)
61 if err != nil {
62 log.Fatal(err)
63 }
64
65 // set output file into logrus
66 log.SetOutput(file)
67 }
68
69 log.SetFormatter(cfg.Formatter)
70 log.SetLevel(cfg.Level)
71 return nil
72}In that file there is a NewLogger function, where we initialize Logrus with the requirements defined according to the parameters we want in the config. After that, we also create SetConfig. Then, if we want to log with the package we built, the required fields—such as setting the environment and the version of Go being used—are already attached. So we provide functions that perform that setting, like this.
1func Debug(args ...interface{}) {
2 log.WithFields(serviceFields).Debug(args...)
3}
4
5func Info(args ...interface{}) {
6 log.WithFields(serviceFields).Info(args...)
7}
8
9func Warn(args ...interface{}) {
10 log.WithFields(serviceFields).Warn(args...)
11}
12
13func Error(args ...interface{}) {
14 log.WithFields(serviceFields).Error(args...)
15}
16
17func Fatal(args ...interface{}) {
18 log.WithFields(serviceFields).Fatal(args...)
19}Finally, we create the main function in the main.go file with the contents shown below.
1func main() {
2 // initialize environment
3 err := env.Init()
4 if err != nil {
5 log.Fatal(err)
6 }
7
8 // initialize config log
9 err = log.SetConfig(&log.Config{
10 Formatter: &log.TextFormatter,
11 Level: log.TraceLevel,
12 LogName: "application.log",
13 })
14 if err != nil {
15 log.Fatal(err)
16 }
17
18 log.Debug("singleton debug")
19 log.Info("singleton info")
20 log.Warn("singleton warn")
21 log.Error("singleton debug")
22 log.Fatal("singleton fatal")
23}In the code above, you can see that there is first an environment initialization to ensure our system runs in the appropriate environment. This is followed by initializing the logging configuration in the package we built, and then performing logging as usual.
So when we run that main code, it will look like this.
1DEBU[0000] singleton debug environment=development go_version=go1.21.1
2INFO[0000] singleton info environment=development go_version=go1.21.1
3WARN[0000] singleton warn environment=development go_version=go1.21.1
4ERRO[0000] singleton debug environment=development go_version=go1.21.1
5FATA[0000] singleton fatal environment=development go_version=go1.21.1And if we switch to a different environment, for example for production needs, we can add the environment file as follows.
1SERVICE_ENV=production