diff --git a/libraries/src/AWS.Lambda.Powertools.Metrics/IMetrics.cs b/libraries/src/AWS.Lambda.Powertools.Metrics/IMetrics.cs index d49c9b99..bf6ebac1 100644 --- a/libraries/src/AWS.Lambda.Powertools.Metrics/IMetrics.cs +++ b/libraries/src/AWS.Lambda.Powertools.Metrics/IMetrics.cs @@ -119,4 +119,10 @@ void PushSingleMetric(string name, double value, MetricUnit unit, string nameSpa /// /// void CaptureColdStartMetric(ILambdaContext context); + + /// + /// Adds multiple dimensions at once. + /// + /// Array of key-value tuples representing dimensions. + void AddDimensions(params (string key, string value)[] dimensions); } \ No newline at end of file diff --git a/libraries/src/AWS.Lambda.Powertools.Metrics/Metrics.cs b/libraries/src/AWS.Lambda.Powertools.Metrics/Metrics.cs index d66f0fa0..b78630d3 100644 --- a/libraries/src/AWS.Lambda.Powertools.Metrics/Metrics.cs +++ b/libraries/src/AWS.Lambda.Powertools.Metrics/Metrics.cs @@ -317,7 +317,7 @@ void IMetrics.ClearDefaultDimensions() } /// - public void SetService(string service) + void IMetrics.SetService(string service) { // 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 @@ -433,6 +433,15 @@ public static void SetNamespace(string nameSpace) { Instance.SetNamespace(nameSpace); } + + /// + /// Sets the service name for the metrics. + /// + /// The service name. + public static void SetService(string service) + { + Instance.SetService(service); + } /// /// Retrieves namespace identifier. @@ -576,6 +585,55 @@ void IMetrics.CaptureColdStartMetric(ILambdaContext context) dimensions ); } + + /// + void IMetrics.AddDimensions(params (string key, string value)[] dimensions) + { + if (dimensions == null || dimensions.Length == 0) + return; + + // Validate all dimensions first + foreach (var (key, value) in dimensions) + { + if (string.IsNullOrWhiteSpace(key)) + throw new ArgumentNullException(nameof(dimensions), + "'AddDimensions' method requires valid dimension keys. 'Null' or empty values are not allowed."); + + if (string.IsNullOrWhiteSpace(value)) + throw new ArgumentNullException(nameof(dimensions), + "'AddDimensions' method requires valid dimension values. 'Null' or empty values are not allowed."); + } + + // Create a new dimension set with all dimensions + var dimensionSet = new DimensionSet(dimensions[0].key, dimensions[0].value); + + // Add remaining dimensions to the same set + for (var i = 1; i < dimensions.Length; i++) + { + dimensionSet.Dimensions.Add(dimensions[i].key, dimensions[i].value); + } + + // Add the dimensionSet to a list and pass it to AddDimensions + _context.AddDimensions([dimensionSet]); + } + + /// + /// Adds multiple dimensions at once. + /// + /// Array of key-value tuples representing dimensions. + public static void AddDimensions(params (string key, string value)[] dimensions) + { + Instance.AddDimensions(dimensions); + } + + /// + /// Flushes the metrics. + /// + /// If set to true, indicates a metrics overflow. + public static void Flush(bool metricsOverflow = false) + { + Instance.Flush(metricsOverflow); + } /// /// Helper method for testing purposes. Clears static instance between test execution diff --git a/libraries/src/AWS.Lambda.Powertools.Metrics/Model/Metadata.cs b/libraries/src/AWS.Lambda.Powertools.Metrics/Model/Metadata.cs index 2119dd93..c66596a4 100644 --- a/libraries/src/AWS.Lambda.Powertools.Metrics/Model/Metadata.cs +++ b/libraries/src/AWS.Lambda.Powertools.Metrics/Model/Metadata.cs @@ -129,10 +129,19 @@ internal string GetService() /// Adds new Dimension /// /// Dimension to add - internal void AddDimensionSet(DimensionSet dimension) + internal void AddDimension(DimensionSet dimension) { _metricDirective.AddDimension(dimension); } + + /// + /// Adds new List of Dimensions + /// + /// Dimensions to add + internal void AddDimensionSet(List dimension) + { + _metricDirective.AddDimensionSet(dimension); + } /// /// Sets default dimensions list diff --git a/libraries/src/AWS.Lambda.Powertools.Metrics/Model/MetricDirective.cs b/libraries/src/AWS.Lambda.Powertools.Metrics/Model/MetricDirective.cs index 9047dca0..0d300d5e 100644 --- a/libraries/src/AWS.Lambda.Powertools.Metrics/Model/MetricDirective.cs +++ b/libraries/src/AWS.Lambda.Powertools.Metrics/Model/MetricDirective.cs @@ -109,22 +109,35 @@ public List> AllDimensionKeys { get { - var defaultKeys = DefaultDimensions - .Where(d => d.DimensionKeys.Any()) - .SelectMany(s => s.DimensionKeys) - .ToList(); + var result = new List>(); + var allDimKeys = new List(); - var keys = Dimensions - .Where(d => d.DimensionKeys.Any()) - .SelectMany(s => s.DimensionKeys) - .ToList(); + // Add default dimensions keys + if (DefaultDimensions.Any()) + { + foreach (var dimensionSet in DefaultDimensions) + { + foreach (var key in dimensionSet.DimensionKeys.Where(key => !allDimKeys.Contains(key))) + { + allDimKeys.Add(key); + } + } + } - defaultKeys.AddRange(keys); + // Add all regular dimensions to the same array + foreach (var dimensionSet in Dimensions) + { + foreach (var key in dimensionSet.DimensionKeys.Where(key => !allDimKeys.Contains(key))) + { + allDimKeys.Add(key); + } + } - if (defaultKeys.Count == 0) defaultKeys = new List(); + // Add non-empty dimension arrays + // When no dimensions exist, add an empty array + result.Add(allDimKeys.Any() ? allDimKeys : []); - // Wrap the list of strings in another list - return new List> { defaultKeys }; + return result; } } @@ -192,19 +205,37 @@ internal void SetService(string service) /// Dimensions - Cannot add more than 9 dimensions at the same time. internal void AddDimension(DimensionSet dimension) { - if (Dimensions.Count < PowertoolsConfigurations.MaxDimensions) + // Check if we already have any dimensions + if (Dimensions.Count > 0) { - var matchingKeys = AllDimensionKeys.Where(x => x.Contains(dimension.DimensionKeys[0])); - if (!matchingKeys.Any()) - Dimensions.Add(dimension); - else - Console.WriteLine( - $"##WARNING##: Failed to Add dimension '{dimension.DimensionKeys[0]}'. Dimension already exists."); + // Get the first dimension set where we now store all dimensions + var firstDimensionSet = Dimensions[0]; + + // Check the actual dimension count inside the first dimension set + if (firstDimensionSet.Dimensions.Count >= PowertoolsConfigurations.MaxDimensions) + { + throw new ArgumentOutOfRangeException(nameof(dimension), + $"Cannot add more than {PowertoolsConfigurations.MaxDimensions} dimensions at the same time."); + } + + // Add to the first dimension set instead of creating a new one + foreach (var pair in dimension.Dimensions) + { + if (!firstDimensionSet.Dimensions.ContainsKey(pair.Key)) + { + firstDimensionSet.Dimensions.Add(pair.Key, pair.Value); + } + else + { + Console.WriteLine( + $"##WARNING##: Failed to Add dimension '{pair.Key}'. Dimension already exists."); + } + } } else { - throw new ArgumentOutOfRangeException(nameof(Dimensions), - $"Cannot add more than {PowertoolsConfigurations.MaxDimensions} dimensions at the same time."); + // No dimensions yet, add the new one + Dimensions.Add(dimension); } } @@ -228,18 +259,44 @@ internal void SetDefaultDimensions(List defaultDimensions) /// Dictionary with dimension and default dimension list appended internal Dictionary ExpandAllDimensionSets() { + // if a key appears multiple times, the last value will be the one that's used in the output. var dimensions = new Dictionary(); foreach (var dimensionSet in DefaultDimensions) foreach (var (key, value) in dimensionSet.Dimensions) - dimensions.TryAdd(key, value); + dimensions[key] = value; foreach (var dimensionSet in Dimensions) foreach (var (key, value) in dimensionSet.Dimensions) - dimensions.TryAdd(key, value); + dimensions[key] = value; return dimensions; } + + /// + /// Adds multiple dimensions as a complete dimension set to memory. + /// + /// List of dimension sets to add + internal void AddDimensionSet(List dimensionSets) + { + if (dimensionSets == null || !dimensionSets.Any()) + return; + + if (Dimensions.Count + dimensionSets.Count <= PowertoolsConfigurations.MaxDimensions) + { + // Simply add the dimension sets without checking for existing keys + // This ensures dimensions added together stay together + foreach (var dimensionSet in dimensionSets.Where(dimensionSet => dimensionSet.DimensionKeys.Any())) + { + Dimensions.Add(dimensionSet); + } + } + else + { + throw new ArgumentOutOfRangeException(nameof(Dimensions), + $"Cannot add more than {PowertoolsConfigurations.MaxDimensions} dimensions at the same time."); + } + } /// /// Clears both default dimensions and dimensions lists diff --git a/libraries/src/AWS.Lambda.Powertools.Metrics/Model/MetricsContext.cs b/libraries/src/AWS.Lambda.Powertools.Metrics/Model/MetricsContext.cs index 759cdb9e..d43d059b 100644 --- a/libraries/src/AWS.Lambda.Powertools.Metrics/Model/MetricsContext.cs +++ b/libraries/src/AWS.Lambda.Powertools.Metrics/Model/MetricsContext.cs @@ -132,7 +132,7 @@ internal string GetService() /// Dimension value public void AddDimension(string key, string value) { - _rootNode.AWS.AddDimensionSet(new DimensionSet(key, value)); + _rootNode.AWS.AddDimension(new DimensionSet(key, value)); } /// @@ -141,10 +141,8 @@ public void AddDimension(string key, string value) /// List of dimensions public void AddDimensions(List dimensions) { - foreach (var dimension in dimensions) - { - _rootNode.AWS.AddDimensionSet(dimension); - } + // Call the AddDimensionSet method on the MetricDirective to add as a set + _rootNode.AWS.AddDimensionSet(dimensions); } /// diff --git a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/EMFValidationTests.cs b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/EMFValidationTests.cs index 7d0a3e4a..41ef01f0 100644 --- a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/EMFValidationTests.cs +++ b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/EMFValidationTests.cs @@ -110,7 +110,7 @@ public void WhenMaxDataPointsAreAddedToTheSameMetric_FlushAutomatically() [Trait("Category", "EMFLimits")] [Fact] - public void WhenMoreThan9DimensionsAdded_ThrowArgumentOutOfRangeException() + public void WhenMoreThan29DimensionsAdded_ThrowArgumentOutOfRangeException() { // Act var act = () => { _handler.MaxDimensions(29); }; @@ -400,6 +400,96 @@ public async Task WhenMetricsAsyncRaceConditionItemSameKeyExists_ValidateLock() "{\"Namespace\":\"dotnet-powertools-test\",\"Metrics\":[{\"Name\":\"Metric Name\",\"Unit\":\"Count\"}],\"Dimensions\":[[\"Service\"]]", metricsOutput); } + + [Trait("Category", "MetricsImplementation")] + [Fact] + public void AddDimensions_WithMultipleValues_AddsDimensionsToSameDimensionSet() + { + // Act + _handler.AddMultipleDimensionsInSameSet(); + + var result = _consoleOut.ToString(); + + // Assert + Assert.Contains("\"Dimensions\":[[\"Service\",\"Environment\",\"Region\"]]", result); + Assert.Contains("\"Service\":\"testService\",\"Environment\":\"test\",\"Region\":\"us-west-2\"", result); + } + + [Trait("Category", "MetricsImplementation")] + [Fact] + public void AddDimensions_WithEmptyArray_DoesNotAddAnyDimensions() + { + // Act + _handler.AddEmptyDimensions(); + + var result = _consoleOut.ToString(); + + // Assert + Assert.Contains("\"Dimensions\":[[\"Service\"]]", result); + Assert.DoesNotContain("\"Environment\":", result); + } + + [Trait("Category", "MetricsImplementation")] + [Fact] + public void AddDimensions_WithNullOrEmptyKey_ThrowsArgumentNullException() + { + // Act & Assert + Assert.Throws(() => _handler.AddDimensionsWithInvalidKey()); + } + + [Trait("Category", "MetricsImplementation")] + [Fact] + public void AddDimensions_WithNullOrEmptyValue_ThrowsArgumentNullException() + { + // Act & Assert + Assert.Throws(() => _handler.AddDimensionsWithInvalidValue()); + } + + [Trait("Category", "MetricsImplementation")] + [Fact] + public void AddDimensions_OverwritesExistingDimensions_LastValueWins() + { + // Act + _handler.AddDimensionsWithOverwrite(); + + var result = _consoleOut.ToString(); + + // Assert + Assert.Contains("\"Service\":\"testService\",\"dimension1\":\"B\",\"dimension2\":\"2\"", result); + Assert.DoesNotContain("\"dimension1\":\"A\"", result); + } + + [Trait("Category", "MetricsImplementation")] + [Fact] + public void AddDimensions_IncludesDefaultDimensions() + { + // Act + _handler.AddDimensionsWithDefaultDimensions(); + + var result = _consoleOut.ToString(); + + // Assert + Assert.Contains("\"Dimensions\":[[\"Service\",\"environment\",\"dimension1\",\"dimension2\"]]", result); + Assert.Contains("\"Service\":\"testService\",\"environment\":\"prod\",\"dimension1\":\"1\",\"dimension2\":\"2\"", result); + } + + [Trait("Category", "MetricsImplementation")] + [Fact] + public void AddDefaultDimensionsAtRuntime_OnlyAppliedToNewDimensionSets() + { + // Act + _handler.AddDefaultDimensionsAtRuntime(); + + var result = _consoleOut.ToString(); + + // First metric output should have original default dimensions + Assert.Contains("\"Metrics\":[{\"Name\":\"FirstMetric\",\"Unit\":\"Count\"}],\"Dimensions\":[[\"Service\",\"environment\",\"dimension1\",\"dimension2\"]]", result); + Assert.Contains("\"Service\":\"testService\",\"environment\":\"prod\",\"dimension1\":\"1\",\"dimension2\":\"2\",\"FirstMetric\":1", result); + + // Second metric output should have additional default dimensions + Assert.Contains("\"Metrics\":[{\"Name\":\"SecondMetric\",\"Unit\":\"Count\"}],\"Dimensions\":[[\"Service\",\"environment\",\"tenantId\",\"foo\",\"bar\"]]", result); + Assert.Contains("\"Service\":\"testService\",\"environment\":\"prod\",\"tenantId\":\"1\",\"foo\":\"1\",\"bar\":\"2\",\"SecondMetric\":1", result); + } #region Helpers 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 5743c09d..992f0697 100644 --- a/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/FunctionHandler.cs +++ b/libraries/tests/AWS.Lambda.Powertools.Metrics.Tests/Handlers/FunctionHandler.cs @@ -264,4 +264,114 @@ public void HandleFunctionNameNoContext() { } + + [Metrics(Namespace = "dotnet-powertools-test", Service = "testService", CaptureColdStart = true)] + public void AddMultipleDimensionsInSameSet() + { + // Add multiple dimensions at once + Metrics.AddDimensions( + ("Environment", "test"), + ("Region", "us-west-2") + ); + + Metrics.AddMetric("TestMetric", 1.0, MetricUnit.Count); + } + + [Metrics(Namespace = "dotnet-powertools-test", Service = "testService", CaptureColdStart = true)] + public void AddEmptyDimensions() + { + // Add empty dimensions array + Metrics.AddDimensions(); + + Metrics.AddMetric("TestMetric", 1.0, MetricUnit.Count); + } + + [Metrics(Namespace = "dotnet-powertools-test", Service = "testService", CaptureColdStart = true)] + public void AddDimensionsWithInvalidKey() + { + // Add dimension with null key + Metrics.AddDimensions(("", "value")); + } + + [Metrics(Namespace = "dotnet-powertools-test", Service = "testService", CaptureColdStart = true)] + public void AddDimensionsWithInvalidValue() + { + // Add dimension with null value + Metrics.AddDimensions(("key", "")); + } + + public void AddDimensionsWithOverwrite() + { + Metrics.SetNamespace("dotnet-powertools-test"); + Metrics.SetService("testService"); + + // Add single dimension + Metrics.AddDimension("dimension1", "A"); + + // Then add multiple dimensions, including the same key + Metrics.AddDimensions( + ("dimension1", "B"), + ("dimension2", "2") + ); + + Metrics.AddMetric("TestMetric", 1.0, MetricUnit.Count); + Metrics.Flush(); + } + + public void AddDimensionsWithDefaultDimensions() + { + Metrics.SetNamespace("dotnet-powertools-test"); + Metrics.SetService("testService"); + + // Set default dimensions + Metrics.SetDefaultDimensions(new Dictionary + { + { "environment", "prod" } + }); + + // Add multiple dimensions + Metrics.AddDimensions( + ("dimension1", "1"), + ("dimension2", "2") + ); + + Metrics.AddMetric("TestMetric", 1.0, MetricUnit.Count); + Metrics.Flush(); + } + + public void AddDefaultDimensionsAtRuntime() + { + Metrics.SetNamespace("dotnet-powertools-test"); + Metrics.SetService("testService"); + + // Set initial default dimensions + Metrics.SetDefaultDimensions(new Dictionary + { + { "environment", "prod" } + }); + + // Add first set of dimensions + Metrics.AddDimensions( + ("dimension1", "1"), + ("dimension2", "2") + ); + Metrics.AddMetric("FirstMetric", 1.0, MetricUnit.Count); + Metrics.Flush(); + + // Add more default dimensions + Metrics.SetDefaultDimensions(new Dictionary + { + { "environment", "prod" }, + { "tenantId", "1" } + }); + + // Add second set of dimensions + Metrics.AddDimensions( + ("foo", "1"), + ("bar", "2") + ); + Metrics.AddMetric("SecondMetric", 1.0, MetricUnit.Count); + + Metrics.Flush(); + } } \ No newline at end of file