diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 46179dd..f556ea6 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -13,7 +13,7 @@ jobs: steps: - uses: actions/checkout@v2 - name: golangci-lint - uses: golangci/golangci-lint-action@v1 + uses: golangci/golangci-lint-action@v2 with: - version: v1.26 + version: v1.29 working-directory: emf diff --git a/.gitignore b/.gitignore index ee770a6..3c48cd8 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,5 @@ # Dependency directories (remove the comment below to include it) # vendor/ +# IDE artifacts .idea/ diff --git a/README.md b/README.md index b3ab00a..51480fe 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ Go implementation of AWS CloudWatch [Embedded Metric Format](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Embedded_Metric_Format_Specification.html) It's aim is to simplify reporting metrics to CloudWatch: + - using EMF avoids additional HTTP API calls to CloudWatch as metrics are logged in JSON format to stdout - no need for additional dependencies in your services (or mocks in tests) to report metrics from inside your code - built in support for default dimensions and properties for Lambda functions @@ -14,6 +15,7 @@ It's aim is to simplify reporting metrics to CloudWatch: Supports namespaces, setting dimensions and properties as well as different contexts (at least partially). Usage: + ``` emf.New().Namespace("mtg").Metric("totalWins", 1500).Log() @@ -21,12 +23,13 @@ emf.New().Dimension("colour", "red"). MetricAs("gameLength", 2, emf.Seconds).Log() emf.New().DimensionSet( - emf.NewDimension("format", "edh"), + emf.NewDimension("format", "edh"), emf.NewDimension("commander", "Muldrotha")). MetricAs("wins", 1499, emf.Count).Log() ``` You may also use the lib together with `defer`. + ``` m := emf.New() // sets up whatever you fancy here defer m.Log() @@ -34,7 +37,16 @@ defer m.Log() // any reporting metrics calls ``` +Customizing the logger: +``` +emf.New( + emf.WithWriter(os.Stderr), // Log to stderr. + emf.WithTimestamp(time.Now().Add(-time.Hour)), // Record past metrics. +) +``` + Functions for reporting metrics: + ``` func Metric(name string, value int) func Metrics(m map[string]int) @@ -48,6 +60,7 @@ func MetricsFloatAs(m map[string]float64, unit MetricUnit) ``` Functions for setting up dimensions: + ``` func Dimension(key, value string) func DimensionSet(dimensions ...Dimension) // use `func NewDimension` for creating one diff --git a/emf/emf.go b/emf/emf.go index bc60678..f86e17a 100644 --- a/emf/emf.go +++ b/emf/emf.go @@ -1,4 +1,4 @@ -// Spec available here: https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Embedded_Metric_Format_Specification.html +// Package emf implements the spec available here: https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Embedded_Metric_Format_Specification.html package emf // Metadata struct as defined in AWS Embedded Metrics Format spec. diff --git a/emf/logger.go b/emf/logger.go index b7bf604..fe197f1 100644 --- a/emf/logger.go +++ b/emf/logger.go @@ -24,13 +24,29 @@ type Context struct { values map[string]interface{} } -// New creates logger printing to os.Stdout, perfect for Lambda functions. -func New() *Logger { - return NewFor(os.Stdout) +// LoggerOption defines a function that can be used to customize a logger. +type LoggerOption func(l *Logger) + +// WithWriter customizes the writer used by a logger. +func WithWriter(w io.Writer) LoggerOption { + return func(l *Logger) { + l.out = w + } +} + +// WithTimestamp customizes the timestamp used by a logger. +func WithTimestamp(t time.Time) LoggerOption { + return func(l *Logger) { + l.timestamp = t.UnixNano() / int64(time.Millisecond) + } } -// NewFor creates logger printing to any suitable writer. -func NewFor(out io.Writer) *Logger { +// New creates logger with reasonable defaults for Lambda functions: +// - Prints to os.Stdout. +// - Context based on Lambda environment variables. +// - Timestamp set to the time when New was called. +// Specify LoggerOptions to customize the logger. +func New(opts ...LoggerOption) *Logger { values := make(map[string]interface{}) // set default properties for lambda function @@ -48,12 +64,20 @@ func NewFor(out io.Writer) *Logger { values["traceId"] = amznTraceID } - return &Logger{ - out: out, + // create a default logger + l := &Logger{ + out: os.Stdout, defaultContext: newContext(values), values: values, timestamp: time.Now().UnixNano() / int64(time.Millisecond), } + + // apply any options + for _, opt := range opts { + opt(l) + } + + return l } // Dimension helps builds DimensionSet. @@ -129,7 +153,7 @@ func (l *Logger) MetricAs(name string, value int, unit MetricUnit) *Logger { return l } -// Metrics puts all of the int metrics with MetricUnit on default context. +// MetricsAs puts all of the int metrics with MetricUnit on default context. func (l *Logger) MetricsAs(m map[string]int, unit MetricUnit) *Logger { for name, value := range m { l.defaultContext.put(name, value, unit) diff --git a/emf/logger_internal_test.go b/emf/logger_internal_test.go new file mode 100644 index 0000000..c92e45f --- /dev/null +++ b/emf/logger_internal_test.go @@ -0,0 +1,70 @@ +package emf + +import ( + "fmt" + "os" + "testing" + "time" +) + +func TestNew(t *testing.T) { + tcs := []struct { + name string + opts []LoggerOption + expected *Logger + }{ + { + name: "default", + expected: &Logger{ + out: os.Stdout, + timestamp: time.Now().UnixNano() / int64(time.Millisecond), + }, + }, + { + name: "with options", + opts: []LoggerOption{ + WithWriter(os.Stderr), + WithTimestamp(time.Now().Add(time.Hour)), + }, + expected: &Logger{ + out: os.Stderr, + timestamp: time.Now().Add(time.Hour).UnixNano() / int64(time.Millisecond), + }, + }, + } + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + actual := New(tc.opts...) + if err := loggersEqual(actual, tc.expected); err != nil { + t.Errorf("logger does not match: %v", err) + } + }) + } + +} + +// loggersEqual returns a non-nil error if the loggers do not match. +// Currently it only checks that the loggers' output writer and timestamp match. +func loggersEqual(actual, expected *Logger) error { + if actual.out != expected.out { + return fmt.Errorf("output does not match") + } + + if err := approxInt64(actual.timestamp, expected.timestamp, 100 /* ms */); err != nil { + return fmt.Errorf("timestamp %v", err) + } + + return nil +} + +func approxInt64(actual, expected, tolerance int64) error { + diff := expected - actual + if diff < 0 { + diff = -diff + } + if diff > tolerance { + return fmt.Errorf("value %v is out of tolerance %v±%v", actual, expected, tolerance) + } + return nil +} diff --git a/emf/logger_test.go b/emf/logger_test.go index cf56735..3454250 100644 --- a/emf/logger_test.go +++ b/emf/logger_test.go @@ -181,7 +181,7 @@ func TestEmf(t *testing.T) { } var buf bytes.Buffer - logger := emf.NewFor(&buf) + logger := emf.New(emf.WithWriter(&buf)) tc.given(logger) logger.Log() @@ -196,7 +196,7 @@ func TestEmf(t *testing.T) { t.Run("no metrics set", func(t *testing.T) { var buf bytes.Buffer - logger := emf.NewFor(&buf) + logger := emf.New(emf.WithWriter(&buf)) logger.Log() if buf.String() != "" { @@ -206,7 +206,7 @@ func TestEmf(t *testing.T) { t.Run("new context, no metrics set", func(t *testing.T) { var buf bytes.Buffer - logger := emf.NewFor(&buf) + logger := emf.New(emf.WithWriter(&buf)) logger.NewContext().Namespace("galaxy") logger.Log()