diff --git a/libraries/src/AWS.Lambda.Powertools.Metrics/IMetrics.cs b/libraries/src/AWS.Lambda.Powertools.Metrics/IMetrics.cs index 6f0b868a..69ef2ee3 100644 --- a/libraries/src/AWS.Lambda.Powertools.Metrics/IMetrics.cs +++ b/libraries/src/AWS.Lambda.Powertools.Metrics/IMetrics.cs @@ -1,104 +1,109 @@ /* * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * + * * Licensed under the Apache License, Version 2.0 (the "License"). * You may not use this file except in compliance with the License. * A copy of the License is located at - * + * * http://aws.amazon.com/apache2.0 - * + * * or in the "license" file accompanying this file. This file is distributed * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either * express or implied. See the License for the specific language governing * permissions and limitations under the License. */ -using System; using System.Collections.Generic; namespace AWS.Lambda.Powertools.Metrics; /// -/// Interface IMetrics -/// Implements the +/// Interface for metrics operations. /// /// -public interface IMetrics +public interface IMetrics { /// - /// Adds metric + /// Adds a metric to the collection. /// - /// Metric key - /// Metric value - /// Metric unit - /// - void AddMetric(string key, double value, MetricUnit unit, MetricResolution metricResolution); + /// The metric key. + /// The metric value. + /// The metric unit. + /// The metric resolution. + void AddMetric(string key, double value, MetricUnit unit = MetricUnit.None, + MetricResolution resolution = MetricResolution.Default); /// - /// Adds a dimension + /// Adds a dimension to the collection. /// - /// Dimension key - /// Dimension value + /// The dimension key. + /// The dimension value. void AddDimension(string key, string value); /// - /// Sets the default dimensions + /// Adds metadata to the collection. /// - /// Default dimensions - void SetDefaultDimensions(Dictionary defaultDimension); + /// The metadata key. + /// The metadata value. + void AddMetadata(string key, object value); /// - /// Adds metadata + /// Sets the default dimensions. /// - /// Metadata key - /// Metadata value - void AddMetadata(string key, object value); + /// The default dimensions. + void SetDefaultDimensions(Dictionary defaultDimensions); /// - /// Pushes a single metric with custom namespace, service and dimensions. + /// Sets the namespace for the metrics. /// - /// Name of the metric - /// Metric value - /// Metric unit - /// Metric namespace - /// Metric service - /// Metric default dimensions - /// Metrics resolution - void PushSingleMetric(string metricName, double value, MetricUnit unit, string nameSpace = null, - string service = null, Dictionary defaultDimensions = null, MetricResolution metricResolution = MetricResolution.Default); + /// The namespace. + void SetNamespace(string nameSpace); /// - /// Sets the namespace + /// Sets the service name for the metrics. /// - /// Metrics namespace - void SetNamespace(string nameSpace); + /// The service name. + void SetService(string service); + + /// + /// Sets whether to raise an event on empty metrics. + /// + /// If set to true, raises an event on empty metrics. + void SetRaiseOnEmptyMetrics(bool raiseOnEmptyMetrics); /// - /// Gets the namespace + /// Sets whether to capture cold start metrics. /// - /// System.String. - string GetNamespace(); + /// If set to true, captures cold start metrics. + void SetCaptureColdStart(bool captureColdStart); /// - /// Gets the service + /// Pushes a single metric to the collection. /// - /// System.String. - string GetService(); + /// The metric name. + /// The metric value. + /// The metric unit. + /// The namespace. + /// The service name. + /// The default dimensions. + /// The metric resolution. + void PushSingleMetric(string name, double value, MetricUnit unit, string nameSpace = null, string service = null, + Dictionary defaultDimensions = null, MetricResolution resolution = MetricResolution.Default); /// - /// Serializes metrics instance + /// Clears the default dimensions. /// - /// System.String. - string Serialize(); + void ClearDefaultDimensions(); /// - /// Flushes metrics to CloudWatch + /// Flushes the metrics. /// - /// if set to true [metrics overflow]. + /// If set to true, indicates a metrics overflow. void Flush(bool metricsOverflow = false); - + /// - /// Clears both default dimensions and dimensions lists + /// Gets the metrics options. /// - void ClearDefaultDimensions(); -} + /// The metrics options. + public MetricsOptions Options { get; } +} \ No newline at end of file diff --git a/libraries/src/AWS.Lambda.Powertools.Metrics/Internal/MetricsAspect.cs b/libraries/src/AWS.Lambda.Powertools.Metrics/Internal/MetricsAspect.cs index a72e205e..1006d25c 100644 --- a/libraries/src/AWS.Lambda.Powertools.Metrics/Internal/MetricsAspect.cs +++ b/libraries/src/AWS.Lambda.Powertools.Metrics/Internal/MetricsAspect.cs @@ -70,13 +70,12 @@ public void Before( var trigger = triggers.OfType().First(); - _metricsInstance ??= new Metrics( - PowertoolsConfigurations.Instance, - trigger.Namespace, - trigger.Service, - trigger.RaiseOnEmptyMetrics, - trigger.CaptureColdStart - ); + _metricsInstance ??= Metrics.Configure(options => { + options.Namespace = trigger.Namespace; + options.Service = trigger.Service; + options.RaiseOnEmptyMetrics = trigger.IsRaiseOnEmptyMetricsSet ? trigger.RaiseOnEmptyMetrics : null; + options.CaptureColdStart = trigger.IsCaptureColdStartSet ? trigger.CaptureColdStart : null; + }); var eventArgs = new AspectEventArgs { @@ -89,31 +88,27 @@ public void Before( Triggers = triggers }; - if (trigger.CaptureColdStart && _isColdStart) + if (_metricsInstance.Options.CaptureColdStart != null && _metricsInstance.Options.CaptureColdStart.Value && _isColdStart) { + var defaultDimensions = _metricsInstance.Options?.DefaultDimensions; _isColdStart = false; - var nameSpace = _metricsInstance.GetNamespace(); - var service = _metricsInstance.GetService(); - Dictionary dimensions = null; - var context = GetContext(eventArgs); - + if (context is not null) { - dimensions = new Dictionary - { - { "FunctionName", context.FunctionName } - }; + defaultDimensions ??= new Dictionary(); + defaultDimensions.Add("FunctionName", context.FunctionName); + _metricsInstance.SetDefaultDimensions(defaultDimensions); } _metricsInstance.PushSingleMetric( "ColdStart", 1.0, MetricUnit.Count, - nameSpace, - service, - dimensions + _metricsInstance.Options?.Namespace ?? "", + _metricsInstance.Options?.Service ?? "", + defaultDimensions ); } } @@ -137,7 +132,7 @@ internal static void ResetForTest() _isColdStart = true; Metrics.ResetForTest(); } - + /// /// Gets the Lambda context /// diff --git a/libraries/src/AWS.Lambda.Powertools.Metrics/Metrics.cs b/libraries/src/AWS.Lambda.Powertools.Metrics/Metrics.cs index 2ddbc539..86823bf0 100644 --- a/libraries/src/AWS.Lambda.Powertools.Metrics/Metrics.cs +++ b/libraries/src/AWS.Lambda.Powertools.Metrics/Metrics.cs @@ -15,7 +15,9 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; +using System.Threading; using AWS.Lambda.Powertools.Common; namespace AWS.Lambda.Powertools.Metrics; @@ -27,10 +29,30 @@ namespace AWS.Lambda.Powertools.Metrics; /// public class Metrics : IMetrics, IDisposable { + /// + /// Gets or sets the instance. + /// + public static IMetrics Instance + { + get => Current.Value ?? new Metrics(PowertoolsConfigurations.Instance); + private set => Current.Value = value; + } + + /// + public MetricsOptions Options => + new() + { + CaptureColdStart = _captureColdStartEnabled, + Namespace = GetNamespace(), + Service = GetService(), + RaiseOnEmptyMetrics = _raiseOnEmptyMetrics, + DefaultDimensions = GetDefaultDimensions() + }; + /// /// The instance /// - private static IMetrics _instance; + private static readonly AsyncLocal Current = new(); /// /// The context @@ -45,18 +67,46 @@ public class Metrics : IMetrics, IDisposable /// /// If true, Powertools for AWS Lambda (.NET) will throw an exception on empty metrics when trying to flush /// - private readonly bool _raiseOnEmptyMetrics; + private bool _raiseOnEmptyMetrics; /// /// The capture cold start enabled /// - private readonly bool _captureColdStartEnabled; + private bool _captureColdStartEnabled; // // Shared synchronization object // private readonly object _lockObj = new(); + + /// + /// Initializes a new instance of the class. + /// + /// + /// + public static IMetrics Configure(Action configure) + { + var options = new MetricsOptions(); + configure(options); + + if (!string.IsNullOrEmpty(options.Namespace)) + SetNamespace(options.Namespace); + + if (!string.IsNullOrEmpty(options.Service)) + Instance.SetService(options.Service); + + if (options.RaiseOnEmptyMetrics.HasValue) + Instance.SetRaiseOnEmptyMetrics(options.RaiseOnEmptyMetrics.Value); + if (options.CaptureColdStart.HasValue) + Instance.SetCaptureColdStart(options.CaptureColdStart.Value); + + if (options.DefaultDimensions != null) + SetDefaultDimensions(options.DefaultDimensions); + + return Instance; + } + /// /// Creates a Metrics object that provides features to send metrics to Amazon Cloudwatch using the Embedded metric /// format (EMF). See @@ -70,86 +120,70 @@ public class Metrics : IMetrics, IDisposable internal Metrics(IPowertoolsConfigurations powertoolsConfigurations, string nameSpace = null, string service = null, bool raiseOnEmptyMetrics = false, bool captureColdStartEnabled = false) { - _instance ??= this; - _powertoolsConfigurations = powertoolsConfigurations; + _context = new MetricsContext(); _raiseOnEmptyMetrics = raiseOnEmptyMetrics; _captureColdStartEnabled = captureColdStartEnabled; - _context = InitializeContext(nameSpace, service, null); + Instance = this; _powertoolsConfigurations.SetExecutionEnvironment(this); + + if (!string.IsNullOrEmpty(nameSpace)) SetNamespace(nameSpace); + if (!string.IsNullOrEmpty(service)) SetService(service); } - /// - /// Implements interface that adds new metric to memory. - /// - /// Metric Key - /// Metric Value - /// Metric Unit - /// Metric resolution - /// - /// 'AddMetric' method requires a valid metrics key. 'Null' or empty values - /// are not allowed. - /// - void IMetrics.AddMetric(string key, double value, MetricUnit unit, MetricResolution metricResolution) + /// + void IMetrics.AddMetric(string key, double value, MetricUnit unit, MetricResolution resolution) { - if (string.IsNullOrWhiteSpace(key)) - throw new ArgumentNullException( - nameof(key), - "'AddMetric' method requires a valid metrics key. 'Null' or empty values are not allowed."); - - if (value < 0) + if (Instance != null) { - throw new ArgumentException( - "'AddMetric' method requires a valid metrics value. Value must be >= 0.", nameof(value)); - } + if (string.IsNullOrWhiteSpace(key)) + throw new ArgumentNullException( + nameof(key), + "'AddMetric' method requires a valid metrics key. 'Null' or empty values are not allowed."); - lock (_lockObj) - { - var metrics = _context.GetMetrics(); - - if (metrics.Count > 0 && - (metrics.Count == PowertoolsConfigurations.MaxMetrics || - metrics.FirstOrDefault(x => x.Name == key) - ?.Values.Count == PowertoolsConfigurations.MaxMetrics)) + if (value < 0) { - _instance.Flush(true); + throw new ArgumentException( + "'AddMetric' method requires a valid metrics value. Value must be >= 0.", nameof(value)); } - _context.AddMetric(key, value, unit, metricResolution); + lock (_lockObj) + { + var metrics = _context.GetMetrics(); + + if (metrics.Count > 0 && + (metrics.Count == PowertoolsConfigurations.MaxMetrics || + metrics.FirstOrDefault(x => x.Name == key) + ?.Values.Count == PowertoolsConfigurations.MaxMetrics)) + { + Instance.Flush(true); + } + + _context.AddMetric(key, value, unit, resolution); + } + } + else + { + Debug.WriteLine( + $"##WARNING##: Metrics should be initialized in Handler method before calling {nameof(AddMetric)} method."); } } - /// - /// Implements interface that sets metrics namespace identifier. - /// - /// Metrics Namespace Identifier + /// void IMetrics.SetNamespace(string nameSpace) { - _context.SetNamespace(nameSpace); + _context.SetNamespace(!string.IsNullOrWhiteSpace(nameSpace) + ? nameSpace + : GetNamespace() ?? _powertoolsConfigurations.MetricsNamespace); } - /// - /// Implements interface that allows retrieval of namespace identifier. - /// - /// Namespace identifier - string IMetrics.GetNamespace() - { - try - { - return _context.GetNamespace(); - } - catch - { - return null; - } - } /// /// Implements interface to get service name /// /// System.String. - string IMetrics.GetService() + private string GetService() { try { @@ -161,15 +195,7 @@ string IMetrics.GetService() } } - /// - /// Implements interface that adds a dimension. - /// - /// Dimension key. Must not be null, empty or whitespace - /// Dimension value - /// - /// 'AddDimension' method requires a valid dimension key. 'Null' or empty - /// values are not allowed. - /// + /// void IMetrics.AddDimension(string key, string value) { if (string.IsNullOrWhiteSpace(key)) @@ -179,15 +205,7 @@ void IMetrics.AddDimension(string key, string value) _context.AddDimension(key, value); } - /// - /// Implements interface that adds metadata. - /// - /// Metadata key. Must not be null, empty or whitespace - /// Metadata value - /// - /// 'AddMetadata' method requires a valid metadata key. 'Null' or empty - /// values are not allowed. - /// + /// void IMetrics.AddMetadata(string key, object value) { if (string.IsNullOrWhiteSpace(key)) @@ -197,30 +215,18 @@ void IMetrics.AddMetadata(string key, object value) _context.AddMetadata(key, value); } - /// - /// Implements interface that sets default dimension list - /// - /// Default Dimension List - /// - /// 'SetDefaultDimensions' method requires a valid key pair. 'Null' or empty - /// values are not allowed. - /// - void IMetrics.SetDefaultDimensions(Dictionary defaultDimension) + /// + void IMetrics.SetDefaultDimensions(Dictionary defaultDimensions) { - foreach (var item in defaultDimension) + foreach (var item in defaultDimensions) if (string.IsNullOrWhiteSpace(item.Key) || string.IsNullOrWhiteSpace(item.Value)) throw new ArgumentNullException(nameof(item.Key), "'SetDefaultDimensions' method requires a valid key pair. 'Null' or empty values are not allowed."); - _context.SetDefaultDimensions(DictionaryToList(defaultDimension)); + _context.SetDefaultDimensions(DictionaryToList(defaultDimensions)); } - /// - /// Flushes metrics in Embedded Metric Format (EMF) to Standard Output. In Lambda, this output is collected - /// automatically and sent to Cloudwatch. - /// - /// If enabled, non-default dimensions are cleared after flushing metrics - /// true + /// void IMetrics.Flush(bool metricsOverflow) { if (_context.GetMetrics().Count == 0 @@ -244,52 +250,73 @@ void IMetrics.Flush(bool metricsOverflow) "##User-WARNING## No application metrics to publish. The cold-start metric may be published if enabled. If application metrics should never be empty, consider using 'RaiseOnEmptyMetrics = true'"); } } - - /// - /// Clears both default dimensions and dimensions lists - /// + + /// void IMetrics.ClearDefaultDimensions() { _context.ClearDefaultDimensions(); } - /// - /// Serialize global context object - /// - /// Serialized global context object - public string Serialize() + /// + public void SetService(string service) { - return _context.Serialize(); + // this needs to check if service is set through code or env variables + // the default value service_undefined has to be ignored and return null so it is not added as default + var parsedService = !string.IsNullOrWhiteSpace(service) + ? service + : _powertoolsConfigurations.Service == "service_undefined" + ? null + : _powertoolsConfigurations.Service; + + if (parsedService != null) + { + _context.SetService(parsedService); + _context.SetDefaultDimensions(new List(new[] + { new DimensionSet("Service", GetService()) })); + } } - /// - /// Implements the interface that pushes single metric to CloudWatch using Embedded Metric Format. This can be used to - /// push metrics with a different context. - /// - /// Metric Name. Metric key cannot be null, empty or whitespace - /// Metric Value - /// Metric Unit - /// Metric Namespace - /// Service Name - /// Default dimensions list - /// Metrics resolution - /// - /// 'PushSingleMetric' method requires a valid metrics key. 'Null' or empty - /// values are not allowed. - /// - void IMetrics.PushSingleMetric(string metricName, double value, MetricUnit unit, string nameSpace, string service, - Dictionary defaultDimensions, MetricResolution metricResolution) + /// + public void SetRaiseOnEmptyMetrics(bool raiseOnEmptyMetrics) + { + _raiseOnEmptyMetrics = raiseOnEmptyMetrics; + } + + /// + public void SetCaptureColdStart(bool captureColdStart) + { + _captureColdStartEnabled = captureColdStart; + } + + private Dictionary GetDefaultDimensions() + { + return ListToDictionary(_context.GetDefaultDimensions()); + } + + /// + void IMetrics.PushSingleMetric(string name, double value, MetricUnit unit, string nameSpace, + string service, Dictionary defaultDimensions, MetricResolution resolution) { - if (string.IsNullOrWhiteSpace(metricName)) - throw new ArgumentNullException(nameof(metricName), + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentNullException(nameof(name), "'PushSingleMetric' method requires a valid metrics key. 'Null' or empty values are not allowed."); - using var context = InitializeContext(nameSpace, service, defaultDimensions); - context.AddMetric(metricName, value, unit, metricResolution); + var context = new MetricsContext(); + context.SetNamespace(nameSpace ?? GetNamespace()); + context.SetService(service ?? _context.GetService()); + + if (defaultDimensions != null) + { + var defaultDimensionsList = DictionaryToList(defaultDimensions); + context.SetDefaultDimensions(defaultDimensionsList); + } + + context.AddMetric(name, value, unit, resolution); Flush(context); } + /// /// Implementation of IDisposable interface /// @@ -308,7 +335,7 @@ protected virtual void Dispose(bool disposing) // Cleanup if (disposing) { - _instance.Flush(); + Instance.Flush(); } } @@ -318,11 +345,11 @@ protected virtual void Dispose(bool disposing) /// Metric Key. Must not be null, empty or whitespace /// Metric Value /// Metric Unit - /// + /// public static void AddMetric(string key, double value, MetricUnit unit = MetricUnit.None, - MetricResolution metricResolution = MetricResolution.Default) + MetricResolution resolution = MetricResolution.Default) { - _instance.AddMetric(key, value, unit, metricResolution); + Instance.AddMetric(key, value, unit, resolution); } /// @@ -331,16 +358,23 @@ public static void AddMetric(string key, double value, MetricUnit unit = MetricU /// Metrics Namespace Identifier public static void SetNamespace(string nameSpace) { - _instance.SetNamespace(nameSpace); + Instance.SetNamespace(nameSpace); } /// /// Retrieves namespace identifier. /// /// Namespace identifier - public static string GetNamespace() + public string GetNamespace() { - return _instance.GetNamespace(); + try + { + return _context.GetNamespace() ?? _powertoolsConfigurations.MetricsNamespace; + } + catch + { + return null; + } } /// @@ -350,7 +384,7 @@ public static string GetNamespace() /// Dimension value public static void AddDimension(string key, string value) { - _instance.AddDimension(key, value); + Instance.AddDimension(key, value); } /// @@ -360,7 +394,7 @@ public static void AddDimension(string key, string value) /// Metadata value public static void AddMetadata(string key, object value) { - _instance.AddMetadata(key, value); + Instance.AddMetadata(key, value); } /// @@ -369,7 +403,7 @@ public static void AddMetadata(string key, object value) /// Default Dimension List public static void SetDefaultDimensions(Dictionary defaultDimensions) { - _instance.SetDefaultDimensions(defaultDimensions); + Instance?.SetDefaultDimensions(defaultDimensions); } /// @@ -377,7 +411,15 @@ public static void SetDefaultDimensions(Dictionary defaultDimens /// public static void ClearDefaultDimensions() { - _instance.ClearDefaultDimensions(); + if (Instance != null) + { + Instance.ClearDefaultDimensions(); + } + else + { + Debug.WriteLine( + $"##WARNING##: Metrics should be initialized in Handler method before calling {nameof(ClearDefaultDimensions)} method."); + } } /// @@ -396,55 +438,27 @@ private void Flush(MetricsContext context) /// Pushes single metric to CloudWatch using Embedded Metric Format. This can be used to push metrics with a different /// context. /// - /// Metric Name. Metric key cannot be null, empty or whitespace + /// Metric Name. Metric key cannot be null, empty or whitespace /// Metric Value /// Metric Unit /// Metric Namespace /// Service Name /// Default dimensions list - /// Metrics resolution - public static void PushSingleMetric(string metricName, double value, MetricUnit unit, string nameSpace = null, + /// Metrics resolution + public static void PushSingleMetric(string name, double value, MetricUnit unit, string nameSpace = null, string service = null, Dictionary defaultDimensions = null, - MetricResolution metricResolution = MetricResolution.Default) - { - _instance.PushSingleMetric(metricName, value, unit, nameSpace, service, defaultDimensions, metricResolution); - } - - /// - /// Sets global namespace, service name and default dimensions list. - /// - /// Metrics namespace - /// Service Name - /// Default Dimensions List - /// MetricsContext. - private MetricsContext InitializeContext(string nameSpace, string service, - Dictionary defaultDimensions) + MetricResolution resolution = MetricResolution.Default) { - var context = new MetricsContext(); - var defaultDimensionsList = DictionaryToList(defaultDimensions); - - context.SetNamespace(!string.IsNullOrWhiteSpace(nameSpace) - ? nameSpace - : _instance.GetNamespace() ?? _powertoolsConfigurations.MetricsNamespace); - - // this needs to check if service is set through code or env variables - // the default value service_undefined has to be ignored and return null so it is not added as default - // TODO: Check if there is a way to get the default dimensions and if it makes sense - var parsedService = !string.IsNullOrWhiteSpace(service) - ? service - : _powertoolsConfigurations.Service == "service_undefined" - ? null - : _powertoolsConfigurations.Service; - - if (parsedService != null) + if (Instance != null) { - context.SetService(parsedService); - defaultDimensionsList.Add(new DimensionSet("Service", context.GetService())); + Instance.PushSingleMetric(name, value, unit, nameSpace, service, defaultDimensions, + resolution); + } + else + { + Debug.WriteLine( + $"##WARNING##: Metrics should be initialized in Handler method before calling {nameof(PushSingleMetric)} method."); } - - context.SetDefaultDimensions(defaultDimensionsList); - - return context; } /// @@ -462,11 +476,36 @@ private List DictionaryToList(Dictionary defaultDi return defaultDimensionsList; } + private Dictionary ListToDictionary(List dimensions) + { + var dictionary = new Dictionary(); + try + { + return dimensions != null + ? new Dictionary(dimensions.SelectMany(x => x.Dimensions)) + : dictionary; + } + catch (Exception e) + { + Debug.WriteLine("Error converting list to dictionary: " + e.Message); + return dictionary; + } + } + /// /// Helper method for testing purposes. Clears static instance between test execution /// internal static void ResetForTest() { - _instance = null; + Instance = null; + } + + /// + /// For testing purposes, resets the Instance to the provided metrics instance. + /// + /// + public static void UseMetricsForTests(IMetrics metricsInstance) + { + Instance = metricsInstance; } } \ No newline at end of file diff --git a/libraries/src/AWS.Lambda.Powertools.Metrics/MetricsAttribute.cs b/libraries/src/AWS.Lambda.Powertools.Metrics/MetricsAttribute.cs index 0033f560..6413b597 100644 --- a/libraries/src/AWS.Lambda.Powertools.Metrics/MetricsAttribute.cs +++ b/libraries/src/AWS.Lambda.Powertools.Metrics/MetricsAttribute.cs @@ -119,15 +119,41 @@ public class MetricsAttribute : Attribute /// The service. public string Service { get; set; } + private bool _captureColdStartSet; + private bool _captureColdStart; + /// /// Captures cold start during Lambda execution /// /// true if [capture cold start]; otherwise, false. - public bool CaptureColdStart { get; set; } + public bool CaptureColdStart + { + get => _captureColdStart; + set + { + _captureColdStart = value; + _captureColdStartSet = true; + } + } + + internal bool IsCaptureColdStartSet => _captureColdStartSet; + + private bool _raiseOnEmptyMetricsSet; + private bool _raiseOnEmptyMetrics; /// /// Instructs metrics validation to throw exception if no metrics are provided. /// /// true if [raise on empty metrics]; otherwise, false. - public bool RaiseOnEmptyMetrics { get; set; } + public bool RaiseOnEmptyMetrics + { + get => _raiseOnEmptyMetrics; + set + { + _raiseOnEmptyMetrics = value; + _raiseOnEmptyMetricsSet = true; + } + } + + internal bool IsRaiseOnEmptyMetricsSet => _raiseOnEmptyMetricsSet; } diff --git a/libraries/src/AWS.Lambda.Powertools.Metrics/MetricsBuilder.cs b/libraries/src/AWS.Lambda.Powertools.Metrics/MetricsBuilder.cs new file mode 100644 index 00000000..ad5b516a --- /dev/null +++ b/libraries/src/AWS.Lambda.Powertools.Metrics/MetricsBuilder.cs @@ -0,0 +1,97 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +using System.Collections.Generic; + +namespace AWS.Lambda.Powertools.Metrics; + +/// +/// Provides a builder for configuring metrics. +/// +public class MetricsBuilder +{ + private readonly MetricsOptions _options = new(); + + /// + /// Sets the namespace for the metrics. + /// + /// The namespace identifier. + /// The current instance of . + public MetricsBuilder WithNamespace(string nameSpace) + { + _options.Namespace = nameSpace; + return this; + } + + /// + /// Sets the service name for the metrics. + /// + /// The service name. + /// The current instance of . + public MetricsBuilder WithService(string service) + { + _options.Service = service; + return this; + } + + /// + /// Sets whether to raise an exception if no metrics are captured. + /// + /// If true, raises an exception when no metrics are captured. + /// The current instance of . + public MetricsBuilder WithRaiseOnEmptyMetrics(bool raiseOnEmptyMetrics) + { + _options.RaiseOnEmptyMetrics = raiseOnEmptyMetrics; + return this; + } + + /// + /// Sets whether to capture cold start metrics. + /// + /// If true, captures cold start metrics. + /// The current instance of . + public MetricsBuilder WithCaptureColdStart(bool captureColdStart) + { + _options.CaptureColdStart = captureColdStart; + return this; + } + + /// + /// Sets the default dimensions for the metrics. + /// + /// A dictionary of default dimensions. + /// The current instance of . + public MetricsBuilder WithDefaultDimensions(Dictionary defaultDimensions) + { + _options.DefaultDimensions = defaultDimensions; + return this; + } + + /// + /// Builds and configures the metrics instance. + /// + /// An instance of . + public IMetrics Build() + { + return Metrics.Configure(opt => + { + opt.Namespace = _options.Namespace; + opt.Service = _options.Service; + opt.RaiseOnEmptyMetrics = _options.RaiseOnEmptyMetrics; + opt.CaptureColdStart = _options.CaptureColdStart; + opt.DefaultDimensions = _options.DefaultDimensions; + }); + } +} \ No newline at end of file diff --git a/libraries/src/AWS.Lambda.Powertools.Metrics/MetricsOptions.cs b/libraries/src/AWS.Lambda.Powertools.Metrics/MetricsOptions.cs new file mode 100644 index 00000000..67ae87bc --- /dev/null +++ b/libraries/src/AWS.Lambda.Powertools.Metrics/MetricsOptions.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; + +namespace AWS.Lambda.Powertools.Metrics; + +/// +/// Configuration options for AWS Lambda Powertools Metrics. +/// +public class MetricsOptions +{ + /// + /// Gets or sets the CloudWatch metrics namespace. + /// + public string Namespace { get; set; } + + /// + /// Gets or sets the service name to be used as a metric dimension. + /// + public string Service { get; set; } + + /// + /// Gets or sets whether to throw an exception when no metrics are emitted. + /// + public bool? RaiseOnEmptyMetrics { get; set; } + + /// + /// Gets or sets whether to capture cold start metrics. + /// + public bool? CaptureColdStart { get; set; } + + /// + /// Gets or sets the default dimensions to be added to all metrics. + /// + public Dictionary DefaultDimensions { get; set; } +} \ No newline at end of file diff --git a/libraries/src/AWS.Lambda.Powertools.Metrics/Model/Metadata.cs b/libraries/src/AWS.Lambda.Powertools.Metrics/Model/Metadata.cs index ee3d0605..2119dd93 100644 --- a/libraries/src/AWS.Lambda.Powertools.Metrics/Model/Metadata.cs +++ b/libraries/src/AWS.Lambda.Powertools.Metrics/Model/Metadata.cs @@ -184,4 +184,12 @@ internal void ClearDefaultDimensions() { _metricDirective.ClearDefaultDimensions(); } + + /// + /// Retrieves default dimensions list + /// + internal List GetDefaultDimensions() + { + return _metricDirective.DefaultDimensions; + } } \ No newline at end of file diff --git a/libraries/src/AWS.Lambda.Powertools.Metrics/Model/MetricsContext.cs b/libraries/src/AWS.Lambda.Powertools.Metrics/Model/MetricsContext.cs index ba77d0ed..8e886a90 100644 --- a/libraries/src/AWS.Lambda.Powertools.Metrics/Model/MetricsContext.cs +++ b/libraries/src/AWS.Lambda.Powertools.Metrics/Model/MetricsContext.cs @@ -170,4 +170,12 @@ public void ClearDefaultDimensions() { _rootNode.AWS.ClearDefaultDimensions(); } + + /// + /// Retrieves default dimensions list + /// + internal List GetDefaultDimensions() + { + return _rootNode.AWS.GetDefaultDimensions(); + } } \ No newline at end of file diff --git a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/EMFValidationTests.cs b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/EMFValidationTests.cs index 0be19d3b..adce5337 100644 --- a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/EMFValidationTests.cs +++ b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/EMFValidationTests.cs @@ -201,7 +201,7 @@ public void WhenNamespaceIsDefined_AbleToRetrieveNamespace() var metricsOutput = _consoleOut.ToString(); - var result = Metrics.GetNamespace(); + var result = Metrics.Instance.Options.Namespace; // Assert Assert.Equal("dotnet-powertools-test", result); @@ -393,6 +393,7 @@ private List AllIndexesOf(string str, string value) public void Dispose() { // need to reset instance after each test + Metrics.ResetForTest(); MetricsAspect.ResetForTest(); Environment.SetEnvironmentVariable("POWERTOOLS_METRICS_NAMESPACE", null); } diff --git a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/DefaultDimensionsHandler.cs b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/DefaultDimensionsHandler.cs new file mode 100644 index 00000000..1028f58c --- /dev/null +++ b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/DefaultDimensionsHandler.cs @@ -0,0 +1,57 @@ +using System.Collections.Generic; +using Amazon.Lambda.Core; + +namespace AWS.Lambda.Powertools.Metrics.Tests.Handlers; + +public class DefaultDimensionsHandler +{ + public DefaultDimensionsHandler() + { + Metrics.Configure(options => + { + options.DefaultDimensions = new Dictionary + { + { "Environment", "Prod" }, + { "Another", "One" } + }; + }); + } + + [Metrics(Namespace = "dotnet-powertools-test", Service = "testService", CaptureColdStart = true)] + public void Handler() + { + // Default dimensions are already set + Metrics.AddMetric("SuccessfulBooking", 1, MetricUnit.Count); + } + + [Metrics(Namespace = "dotnet-powertools-test", Service = "testService", CaptureColdStart = true)] + public void HandlerWithContext(ILambdaContext context) + { + // Default dimensions are already set and adds FunctionName dimension + Metrics.AddMetric("Memory", 10, MetricUnit.Megabytes); + } +} + +public class MetricsDependencyInjectionOptionsHandler +{ + private readonly IMetrics _metrics; + + // Allow injection of IMetrics for testing + public MetricsDependencyInjectionOptionsHandler(IMetrics metrics = null) + { + _metrics = metrics ?? Metrics.Configure(options => + { + options.DefaultDimensions = new Dictionary + { + { "Environment", "Prod" }, + { "Another", "One" } + }; + }); + } + + [Metrics(Namespace = "dotnet-powertools-test", Service = "testService", CaptureColdStart = true)] + public void Handler() + { + _metrics.AddMetric("SuccessfulBooking", 1, MetricUnit.Count); + } +} \ No newline at end of file diff --git a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/FunctionHandler.cs b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/FunctionHandler.cs index f00a8c5f..abc41d7f 100644 --- a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/FunctionHandler.cs +++ b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/FunctionHandler.cs @@ -42,12 +42,12 @@ public void AddDimensions() [Metrics(Namespace = "dotnet-powertools-test", Service = "ServiceName", CaptureColdStart = true)] public void AddMultipleDimensions() { - Metrics.PushSingleMetric("SingleMetric1", 1, MetricUnit.Count, metricResolution: MetricResolution.High, + Metrics.PushSingleMetric("SingleMetric1", 1, MetricUnit.Count, resolution: MetricResolution.High, defaultDimensions: new Dictionary { { "Default1", "SingleMetric1" } }); - Metrics.PushSingleMetric("SingleMetric2", 1, MetricUnit.Count, metricResolution: MetricResolution.High, nameSpace: "ns2", + Metrics.PushSingleMetric("SingleMetric2", 1, MetricUnit.Count, resolution: MetricResolution.High, nameSpace: "ns2", defaultDimensions: new Dictionary { { "Default1", "SingleMetric2" }, { "Default2", "SingleMetric2" } @@ -59,7 +59,7 @@ public void AddMultipleDimensions() [Metrics(Namespace = "ExampleApplication")] public void PushSingleMetricWithNamespace() { - Metrics.PushSingleMetric("SingleMetric", 1, MetricUnit.Count, metricResolution: MetricResolution.High, + Metrics.PushSingleMetric("SingleMetric", 1, MetricUnit.Count, resolution: MetricResolution.High, defaultDimensions: new Dictionary { { "Default", "SingleMetric" } }); @@ -68,7 +68,7 @@ public void PushSingleMetricWithNamespace() [Metrics] public void PushSingleMetricWithEnvNamespace() { - Metrics.PushSingleMetric("SingleMetric", 1, MetricUnit.Count, metricResolution: MetricResolution.High, + Metrics.PushSingleMetric("SingleMetric", 1, MetricUnit.Count, resolution: MetricResolution.High, defaultDimensions: new Dictionary { { "Default", "SingleMetric" } }); @@ -214,4 +214,10 @@ public void HandleWithParamAndLambdaContext(string input, ILambdaContext context { } + + [Metrics(Namespace = "ns", Service = "svc", RaiseOnEmptyMetrics = true)] + public void HandlerRaiseOnEmptyMetrics() + { + + } } \ No newline at end of file diff --git a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/FunctionHandlerTests.cs b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/FunctionHandlerTests.cs index 3469e2e4..c34397f4 100644 --- a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/FunctionHandlerTests.cs +++ b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/FunctionHandlerTests.cs @@ -14,9 +14,11 @@ */ using System; +using System.Collections.Generic; using System.Threading.Tasks; using Amazon.Lambda.TestUtilities; using AWS.Lambda.Powertools.Common; +using NSubstitute; using Xunit; namespace AWS.Lambda.Powertools.Metrics.Tests.Handlers; @@ -26,38 +28,35 @@ public class FunctionHandlerTests : IDisposable { private readonly FunctionHandler _handler; private readonly CustomConsoleWriter _consoleOut; - + public FunctionHandlerTests() { _handler = new FunctionHandler(); _consoleOut = new CustomConsoleWriter(); SystemWrapper.Instance.SetOut(_consoleOut); } - + [Fact] public async Task When_Metrics_Add_Metadata_Same_Key_Should_Ignore_Metadata() { - // Arrange - - // Act - var exception = await Record.ExceptionAsync( () => _handler.HandleSameKey("whatever")); - + var exception = await Record.ExceptionAsync(() => _handler.HandleSameKey("whatever")); + // Assert Assert.Null(exception); } - + [Fact] public async Task When_Metrics_Add_Metadata_Second_Invocation_Should_Not_Throw_Exception() { // Act - var exception = await Record.ExceptionAsync( () => _handler.HandleTestSecondCall("whatever")); + var exception = await Record.ExceptionAsync(() => _handler.HandleTestSecondCall("whatever")); Assert.Null(exception); - - exception = await Record.ExceptionAsync( () => _handler.HandleTestSecondCall("whatever")); + + exception = await Record.ExceptionAsync(() => _handler.HandleTestSecondCall("whatever")); Assert.Null(exception); } - + [Fact] public async Task When_Metrics_Add_Metadata_FromMultipleThread_Should_Not_Throw_Exception() { @@ -65,7 +64,7 @@ public async Task When_Metrics_Add_Metadata_FromMultipleThread_Should_Not_Throw_ var exception = await Record.ExceptionAsync(() => _handler.HandleMultipleThreads("whatever")); Assert.Null(exception); } - + [Fact] public void When_LambdaContext_Should_Add_FunctioName_Dimension_CaptureColdStart() { @@ -74,21 +73,21 @@ public void When_LambdaContext_Should_Add_FunctioName_Dimension_CaptureColdStart { FunctionName = "My Function with context" }; - + // Act _handler.HandleWithLambdaContext(context); var metricsOutput = _consoleOut.ToString(); - + // Assert Assert.Contains( "\"FunctionName\":\"My Function with context\"", metricsOutput); - + Assert.Contains( - "\"Metrics\":[{\"Name\":\"ColdStart\",\"Unit\":\"Count\"}],\"Dimensions\":[[\"FunctionName\",\"Service\"]]}]}", + "\"CloudWatchMetrics\":[{\"Namespace\":\"ns\",\"Metrics\":[{\"Name\":\"ColdStart\",\"Unit\":\"Count\"}],\"Dimensions\":[[\"Service\",\"FunctionName\"]]}]},\"Service\":\"svc\",\"FunctionName\":\"My Function with context\",\"ColdStart\":1}", metricsOutput); } - + [Fact] public void When_LambdaContext_And_Parameter_Should_Add_FunctioName_Dimension_CaptureColdStart() { @@ -97,38 +96,276 @@ public void When_LambdaContext_And_Parameter_Should_Add_FunctioName_Dimension_Ca { FunctionName = "My Function with context" }; - + // Act - _handler.HandleWithParamAndLambdaContext("Hello",context); + _handler.HandleWithParamAndLambdaContext("Hello", context); var metricsOutput = _consoleOut.ToString(); - + // Assert Assert.Contains( "\"FunctionName\":\"My Function with context\"", metricsOutput); - + Assert.Contains( - "\"Metrics\":[{\"Name\":\"ColdStart\",\"Unit\":\"Count\"}],\"Dimensions\":[[\"FunctionName\",\"Service\"]]}]}", + "\"CloudWatchMetrics\":[{\"Namespace\":\"ns\",\"Metrics\":[{\"Name\":\"ColdStart\",\"Unit\":\"Count\"}],\"Dimensions\":[[\"Service\",\"FunctionName\"]]}]},\"Service\":\"svc\",\"FunctionName\":\"My Function with context\",\"ColdStart\":1}", metricsOutput); } - + [Fact] public void When_No_LambdaContext_Should_Not_Add_FunctioName_Dimension_CaptureColdStart() { // Act _handler.HandleColdStartNoContext(); var metricsOutput = _consoleOut.ToString(); - + // Assert Assert.DoesNotContain( "\"FunctionName\"", metricsOutput); - + Assert.Contains( "\"Metrics\":[{\"Name\":\"MyMetric\",\"Unit\":\"None\"}],\"Dimensions\":[[\"Service\"]]}]},\"Service\":\"svc\",\"MyMetric\":1}", metricsOutput); } + [Fact] + public void DefaultDimensions_AreAppliedCorrectly() + { + // Arrange + var handler = new DefaultDimensionsHandler(); + + // Act + handler.Handler(); + + // Get the output and parse it + var metricsOutput = _consoleOut.ToString(); + + // Assert cold start + Assert.Contains( + "\"CloudWatchMetrics\":[{\"Namespace\":\"dotnet-powertools-test\",\"Metrics\":[{\"Name\":\"ColdStart\",\"Unit\":\"Count\"}],\"Dimensions\":[[\"Environment\",\"Another\",\"Service\"]]}]},\"Environment\":\"Prod\",\"Another\":\"One\",\"Service\":\"testService\",\"ColdStart\":1}", + metricsOutput); + // Assert successful booking metrics + Assert.Contains( + "\"CloudWatchMetrics\":[{\"Namespace\":\"dotnet-powertools-test\",\"Metrics\":[{\"Name\":\"SuccessfulBooking\",\"Unit\":\"Count\"}],\"Dimensions\":[[\"Environment\",\"Another\",\"Service\"]]}]},\"Environment\":\"Prod\",\"Another\":\"One\",\"Service\":\"testService\",\"SuccessfulBooking\":1}", + metricsOutput); + } + + [Fact] + public void DefaultDimensions_AreAppliedCorrectly_WithContext_FunctionName() + { + // Arrange + var handler = new DefaultDimensionsHandler(); + + // Act + handler.HandlerWithContext(new TestLambdaContext + { + FunctionName = "My_Function_Name" + }); + + // Get the output and parse it + var metricsOutput = _consoleOut.ToString(); + + // Assert cold start + Assert.Contains( + "\"CloudWatchMetrics\":[{\"Namespace\":\"dotnet-powertools-test\",\"Metrics\":[{\"Name\":\"ColdStart\",\"Unit\":\"Count\"}],\"Dimensions\":[[\"Environment\",\"Another\",\"Service\",\"FunctionName\"]]}]},\"Environment\":\"Prod\",\"Another\":\"One\",\"Service\":\"testService\",\"FunctionName\":\"My_Function_Name\",\"ColdStart\":1}", + metricsOutput); + // Assert successful Memory metrics + Assert.Contains( + "\"CloudWatchMetrics\":[{\"Namespace\":\"dotnet-powertools-test\",\"Metrics\":[{\"Name\":\"Memory\",\"Unit\":\"Megabytes\"}],\"Dimensions\":[[\"Environment\",\"Another\",\"Service\",\"FunctionName\"]]}]},\"Environment\":\"Prod\",\"Another\":\"One\",\"Service\":\"testService\",\"FunctionName\":\"My_Function_Name\",\"Memory\":10}", + metricsOutput); + } + + [Fact] + public void Handler_WithMockedMetrics_ShouldCallAddMetric() + { + // Arrange + var metricsMock = Substitute.For(); + + metricsMock.Options.Returns(new MetricsOptions + { + CaptureColdStart = true, + Namespace = "dotnet-powertools-test", + Service = "testService", + DefaultDimensions = new Dictionary + { + { "Environment", "Prod" }, + { "Another", "One" } + } + }); + + Metrics.UseMetricsForTests(metricsMock); + + + var sut = new MetricsDependencyInjectionOptionsHandler(metricsMock); + + // Act + sut.Handler(); + + // Assert + metricsMock.Received(1).PushSingleMetric("ColdStart", 1, MetricUnit.Count, "dotnet-powertools-test", + service: "testService", Arg.Any>()); + metricsMock.Received(1).AddMetric("SuccessfulBooking", 1, MetricUnit.Count); + } + + [Fact] + public void Handler_With_Builder_Should_Configure_In_Constructor() + { + // Arrange + var handler = new MetricsnBuilderHandler(); + + // Act + handler.Handler(new TestLambdaContext + { + FunctionName = "My_Function_Name" + }); + + // Get the output and parse it + var metricsOutput = _consoleOut.ToString(); + + // Assert cold start + Assert.Contains( + "\"CloudWatchMetrics\":[{\"Namespace\":\"dotnet-powertools-test\",\"Metrics\":[{\"Name\":\"ColdStart\",\"Unit\":\"Count\"}],\"Dimensions\":[[\"Service\",\"Environment\",\"Another\",\"FunctionName\"]]}]},\"Service\":\"testService\",\"Environment\":\"Prod1\",\"Another\":\"One\",\"FunctionName\":\"My_Function_Name\",\"ColdStart\":1}", + metricsOutput); + // Assert successful Memory metrics + Assert.Contains( + "\"CloudWatchMetrics\":[{\"Namespace\":\"dotnet-powertools-test\",\"Metrics\":[{\"Name\":\"SuccessfulBooking\",\"Unit\":\"Count\"}],\"Dimensions\":[[\"Service\",\"Environment\",\"Another\",\"FunctionName\"]]}]},\"Service\":\"testService\",\"Environment\":\"Prod1\",\"Another\":\"One\",\"FunctionName\":\"My_Function_Name\",\"SuccessfulBooking\":1}", + metricsOutput); + } + + [Fact] + public void Handler_With_Builder_Should_Configure_In_Constructor_Mock() + { + var metricsMock = Substitute.For(); + + metricsMock.Options.Returns(new MetricsOptions + { + CaptureColdStart = true, + Namespace = "dotnet-powertools-test", + Service = "testService", + DefaultDimensions = new Dictionary + { + { "Environment", "Prod" }, + { "Another", "One" } + } + }); + + Metrics.UseMetricsForTests(metricsMock); + + var sut = new MetricsnBuilderHandler(metricsMock); + + // Act + sut.Handler(new TestLambdaContext + { + FunctionName = "My_Function_Name" + }); + + metricsMock.Received(1).PushSingleMetric("ColdStart", 1, MetricUnit.Count, "dotnet-powertools-test", + service: "testService", Arg.Any>()); + metricsMock.Received(1).AddMetric("SuccessfulBooking", 1, MetricUnit.Count); + } + + [Fact] + public void When_RaiseOnEmptyMetrics_And_NoMetrics_Should_ThrowException() + { + // Act & Assert + var exception = Assert.Throws(() => _handler.HandlerRaiseOnEmptyMetrics()); + Assert.Equal("No metrics have been provided.", exception.Message); + } + + [Fact] + public void Handler_With_Builder_Should_Raise_Empty_Metrics() + { + // Arrange + var handler = new MetricsnBuilderHandler(); + + // Act & Assert + var exception = Assert.Throws(() => handler.HandlerEmpty()); + Assert.Equal("No metrics have been provided.", exception.Message); + } + + [Fact] + public void When_ColdStart_Should_Use_DefaultDimensions_From_Options() + { + // Arrange + var metricsMock = Substitute.For(); + var expectedDimensions = new Dictionary + { + { "Environment", "Test" }, + { "Region", "us-east-1" } + }; + + metricsMock.Options.Returns(new MetricsOptions + { + Namespace = "dotnet-powertools-test", + Service = "testService", + CaptureColdStart = true, + DefaultDimensions = expectedDimensions + }); + + Metrics.UseMetricsForTests(metricsMock); + + var context = new TestLambdaContext + { + FunctionName = "TestFunction" + }; + + // Act + _handler.HandleWithLambdaContext(context); + + // Assert + metricsMock.Received(1).PushSingleMetric( + "ColdStart", + 1.0, + MetricUnit.Count, + "dotnet-powertools-test", + "testService", + Arg.Is>(d => + d.ContainsKey("Environment") && d["Environment"] == "Test" && + d.ContainsKey("Region") && d["Region"] == "us-east-1" && + d.ContainsKey("FunctionName") && d["FunctionName"] == "TestFunction" + ) + ); + } + + [Fact] + public void When_ColdStart_And_DefaultDimensions_Is_Null_Should_Only_Add_Service_And_FunctionName() + { + // Arrange + var metricsMock = Substitute.For(); + + metricsMock.Options.Returns(new MetricsOptions + { + Namespace = "dotnet-powertools-test", + Service = "testService", + CaptureColdStart = true, + DefaultDimensions = null + }); + + Metrics.UseMetricsForTests(metricsMock); + + var context = new TestLambdaContext + { + FunctionName = "TestFunction" + }; + + // Act + _handler.HandleWithLambdaContext(context); + + // Assert + metricsMock.Received(1).PushSingleMetric( + "ColdStart", + 1.0, + MetricUnit.Count, + "dotnet-powertools-test", + "testService", + Arg.Is>(d => + d.Count == 1 && + d.ContainsKey("FunctionName") && + d["FunctionName"] == "TestFunction" + ) + ); + } + public void Dispose() { Metrics.ResetForTest(); diff --git a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/MetricsnBuilderHandler.cs b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/MetricsnBuilderHandler.cs new file mode 100644 index 00000000..83cc0e89 --- /dev/null +++ b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/MetricsnBuilderHandler.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; +using Amazon.Lambda.Core; + +namespace AWS.Lambda.Powertools.Metrics.Tests.Handlers; + +public class MetricsnBuilderHandler +{ + private readonly IMetrics _metrics; + + // Allow injection of IMetrics for testing + public MetricsnBuilderHandler(IMetrics metrics = null) + { + _metrics = metrics ?? new MetricsBuilder() + .WithCaptureColdStart(true) + .WithService("testService") + .WithNamespace("dotnet-powertools-test") + .WithRaiseOnEmptyMetrics(true) + .WithDefaultDimensions(new Dictionary + { + { "Environment", "Prod1" }, + { "Another", "One" } + }).Build(); + } + + [Metrics] + public void Handler(ILambdaContext context) + { + _metrics.AddMetric("SuccessfulBooking", 1, MetricUnit.Count); + } + + [Metrics] + public void HandlerEmpty() + { + } +} \ No newline at end of file diff --git a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/MetricsTests.cs b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/MetricsTests.cs index 97aa5bf8..13afdecd 100644 --- a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/MetricsTests.cs +++ b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/MetricsTests.cs @@ -1,3 +1,5 @@ +using System; +using System.Collections.Generic; using AWS.Lambda.Powertools.Common; using NSubstitute; using Xunit; @@ -30,4 +32,122 @@ public void Metrics_Set_Execution_Environment_Context() env.Received(1).GetEnvironmentVariable("AWS_EXECUTION_ENV"); } + + [Fact] + public void When_Constructor_With_Namespace_And_Service_Should_Set_Both() + { + // Arrange + var metricsMock = Substitute.For(); + var powertoolsConfigMock = Substitute.For(); + + // Act + var metrics = new Metrics(powertoolsConfigMock, "TestNamespace", "TestService"); + + // Assert + Assert.Equal("TestNamespace", metrics.GetNamespace()); + Assert.Equal("TestService", metrics.Options.Service); + } + + [Fact] + public void When_Constructor_With_Null_Namespace_And_Service_Should_Not_Set() + { + // Arrange + var metricsMock = Substitute.For(); + var powertoolsConfigMock = Substitute.For(); + powertoolsConfigMock.MetricsNamespace.Returns((string)null); + powertoolsConfigMock.Service.Returns("service_undefined"); + + // Act + var metrics = new Metrics(powertoolsConfigMock, null, null); + + // Assert + Assert.Null(metrics.GetNamespace()); + Assert.Null(metrics.Options.Service); + } + + [Fact] + public void When_AddMetric_With_EmptyKey_Should_ThrowArgumentNullException() + { + // Arrange + var metricsMock = Substitute.For(); + var powertoolsConfigMock = Substitute.For(); + IMetrics metrics = new Metrics(powertoolsConfigMock); + + // Act & Assert + var exception = Assert.Throws(() => metrics.AddMetric("", 1.0)); + Assert.Equal("key", exception.ParamName); + Assert.Contains("'AddMetric' method requires a valid metrics key. 'Null' or empty values are not allowed.", exception.Message); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void When_AddMetric_With_InvalidKey_Should_ThrowArgumentNullException(string key) + { + // Arrange + // var metricsMock = Substitute.For(); + var powertoolsConfigMock = Substitute.For(); + IMetrics metrics = new Metrics(powertoolsConfigMock); + + // Act & Assert + var exception = Assert.Throws(() => metrics.AddMetric(key, 1.0)); + Assert.Equal("key", exception.ParamName); + Assert.Contains("'AddMetric' method requires a valid metrics key. 'Null' or empty values are not allowed.", exception.Message); + } + + [Fact] + public void When_SetDefaultDimensions_With_InvalidKeyOrValue_Should_ThrowArgumentNullException() + { + // Arrange + var powertoolsConfigMock = Substitute.For(); + IMetrics metrics = new Metrics(powertoolsConfigMock); + + var invalidDimensions = new Dictionary + { + { "", "value" }, // empty key + { "key", "" }, // empty value + { " ", "value" }, // whitespace key + { "key1", " " }, // whitespace value + { "key2", null } // null value + }; + + // Act & Assert + foreach (var dimension in invalidDimensions) + { + var dimensions = new Dictionary { { dimension.Key, dimension.Value } }; + var exception = Assert.Throws(() => metrics.SetDefaultDimensions(dimensions)); + Assert.Equal("Key", exception.ParamName); + Assert.Contains("'SetDefaultDimensions' method requires a valid key pair. 'Null' or empty values are not allowed.", exception.Message); + } + } + + [Fact] + public void When_PushSingleMetric_With_EmptyName_Should_ThrowArgumentNullException() + { + // Arrange + var powertoolsConfigMock = Substitute.For(); + IMetrics metrics = new Metrics(powertoolsConfigMock); + + // Act & Assert + var exception = Assert.Throws(() => metrics.PushSingleMetric("", 1.0, MetricUnit.Count)); + Assert.Equal("name", exception.ParamName); + Assert.Contains("'PushSingleMetric' method requires a valid metrics key. 'Null' or empty values are not allowed.", exception.Message); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void When_PushSingleMetric_With_InvalidName_Should_ThrowArgumentNullException(string name) + { + // Arrange + var powertoolsConfigMock = Substitute.For(); + IMetrics metrics = new Metrics(powertoolsConfigMock); + + // Act & Assert + var exception = Assert.Throws(() => metrics.PushSingleMetric(name, 1.0, MetricUnit.Count)); + Assert.Equal("name", exception.ParamName); + Assert.Contains("'PushSingleMetric' method requires a valid metrics key. 'Null' or empty values are not allowed.", exception.Message); + } } \ No newline at end of file diff --git a/libraries/tests/e2e/functions/core/metrics/Function/src/Function/TestHelper.cs b/libraries/tests/e2e/functions/core/metrics/Function/src/Function/TestHelper.cs index 750c77ab..c3434d28 100644 --- a/libraries/tests/e2e/functions/core/metrics/Function/src/Function/TestHelper.cs +++ b/libraries/tests/e2e/functions/core/metrics/Function/src/Function/TestHelper.cs @@ -33,7 +33,7 @@ public static void TestMethod(APIGatewayProxyRequest apigwProxyEvent, ILambdaCon Metrics.AddMetadata("RequestId", apigwProxyEvent.RequestContext.RequestId); Metrics.PushSingleMetric( - metricName: "SingleMetric", + name: "SingleMetric", value: 1, unit: MetricUnit.Count, nameSpace: "Test",