diff --git a/global.json b/global.json
index 501e79a8..0a1a4c51 100644
--- a/global.json
+++ b/global.json
@@ -1,6 +1,6 @@
{
"sdk": {
- "version": "8.0.100",
+ "version": "9.0.305",
"rollForward": "latestFeature"
}
-}
\ No newline at end of file
+}
diff --git a/src/AWS.Distro.OpenTelemetry.AutoInstrumentation/AWS.Distro.OpenTelemetry.AutoInstrumentation.csproj b/src/AWS.Distro.OpenTelemetry.AutoInstrumentation/AWS.Distro.OpenTelemetry.AutoInstrumentation.csproj
index b123097f..1651f266 100644
--- a/src/AWS.Distro.OpenTelemetry.AutoInstrumentation/AWS.Distro.OpenTelemetry.AutoInstrumentation.csproj
+++ b/src/AWS.Distro.OpenTelemetry.AutoInstrumentation/AWS.Distro.OpenTelemetry.AutoInstrumentation.csproj
@@ -19,6 +19,7 @@
+
@@ -36,6 +37,8 @@
+
+
runtime; build; native; contentfiles; analyzers; buildtransitive
all
diff --git a/src/AWS.Distro.OpenTelemetry.AutoInstrumentation/Exporters/Aws/Metrics/AwsCloudWatchEmfExporter.cs b/src/AWS.Distro.OpenTelemetry.AutoInstrumentation/Exporters/Aws/Metrics/AwsCloudWatchEmfExporter.cs
new file mode 100644
index 00000000..b6b18fe3
--- /dev/null
+++ b/src/AWS.Distro.OpenTelemetry.AutoInstrumentation/Exporters/Aws/Metrics/AwsCloudWatchEmfExporter.cs
@@ -0,0 +1,56 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+using System.Threading;
+using System.Threading.Tasks;
+using Amazon.CloudWatchLogs;
+using OpenTelemetry.Metrics;
+
+namespace AWS.Distro.OpenTelemetry.AutoInstrumentation.Exporters.Aws.Metrics
+{
+ ///
+ /// OpenTelemetry metrics exporter for CloudWatch EMF format.
+ ///
+ /// This exporter converts OTel metrics into CloudWatch EMF logs which are then
+ /// sent to CloudWatch Logs. CloudWatch Logs automatically extracts the metrics
+ /// from the EMF logs.
+ ///
+ public class AwsCloudWatchEmfExporter : EmfExporterBase
+ {
+ private readonly CloudWatchLogsClient _logClient;
+
+ public AwsCloudWatchEmfExporter(
+ string namespaceName = "default",
+ string logGroupName = "aws/otel/metrics",
+ string? logStreamName = null,
+ AmazonCloudWatchLogsConfig? cloudWatchLogsConfig = null)
+ : base(namespaceName)
+ {
+ _logClient = new CloudWatchLogsClient(logGroupName, logStreamName, cloudWatchLogsConfig);
+ }
+
+ ///
+ /// Send a log event to CloudWatch Logs using the log client.
+ ///
+ protected override async Task SendLogEventAsync(LogEvent logEvent)
+ {
+ await _logClient.SendLogEventAsync(logEvent);
+ }
+
+ ///
+ /// Force flush any pending metrics.
+ ///
+ public override async Task ForceFlushAsync(CancellationToken cancellationToken)
+ {
+ await _logClient.FlushPendingEventsAsync();
+ }
+
+ ///
+ /// Shutdown the exporter.
+ ///
+ public override async Task ShutdownAsync(CancellationToken cancellationToken)
+ {
+ await ForceFlushAsync(cancellationToken);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/AWS.Distro.OpenTelemetry.AutoInstrumentation/Exporters/Aws/Metrics/CloudWatchLogsClient.cs b/src/AWS.Distro.OpenTelemetry.AutoInstrumentation/Exporters/Aws/Metrics/CloudWatchLogsClient.cs
new file mode 100644
index 00000000..8d67b9ed
--- /dev/null
+++ b/src/AWS.Distro.OpenTelemetry.AutoInstrumentation/Exporters/Aws/Metrics/CloudWatchLogsClient.cs
@@ -0,0 +1,300 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Amazon.CloudWatchLogs;
+using Amazon.CloudWatchLogs.Model;
+
+namespace AWS.Distro.OpenTelemetry.AutoInstrumentation.Exporters.Aws.Metrics
+{
+ ///
+ /// Container for a batch of CloudWatch log events with metadata.
+ ///
+ public class LogEventBatch
+ {
+ public List LogEvents { get; } = new();
+ public int ByteTotal { get; set; }
+ public long MinTimestampMs { get; set; }
+ public long MaxTimestampMs { get; set; }
+ public long CreatedTimestampMs { get; }
+
+ public LogEventBatch()
+ {
+ CreatedTimestampMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
+ }
+
+ ///
+ /// Add a log event to the batch.
+ ///
+ public void AddEvent(InputLogEvent logEvent, int eventSize)
+ {
+ LogEvents.Add(logEvent);
+ ByteTotal += eventSize;
+
+ var timestampMs = ((DateTimeOffset)logEvent.Timestamp).ToUnixTimeMilliseconds();
+ if (MinTimestampMs == 0 || timestampMs < MinTimestampMs)
+ {
+ MinTimestampMs = timestampMs;
+ }
+ if (timestampMs > MaxTimestampMs)
+ {
+ MaxTimestampMs = timestampMs;
+ }
+ }
+
+ ///
+ /// Check if the batch is empty.
+ ///
+ public bool IsEmpty() => LogEvents.Count == 0;
+
+ ///
+ /// Get the number of events in the batch.
+ ///
+ public int Size() => LogEvents.Count;
+
+ ///
+ /// Clear the batch.
+ ///
+ public void Clear()
+ {
+ LogEvents.Clear();
+ ByteTotal = 0;
+ MinTimestampMs = 0;
+ MaxTimestampMs = 0;
+ }
+ }
+
+ ///
+ /// CloudWatch Logs client for batching and sending log events.
+ ///
+ public class CloudWatchLogsClient
+ {
+ // CloudWatch Logs limits
+ public const int CwMaxEventPayloadBytes = 256 * 1024; // 256KB
+ public const int CwMaxRequestEventCount = 10000;
+ public const int CwPerEventHeaderBytes = 26;
+ public const int BatchFlushInterval = 60 * 1000; // 60 seconds
+ public const int CwMaxRequestPayloadBytes = 1 * 1024 * 1024; // 1MB
+ public const string CwTruncatedSuffix = "[Truncated...]";
+ public const long CwEventTimestampLimitPast = 14 * 24 * 60 * 60 * 1000; // 14 days
+ public const long CwEventTimestampLimitFuture = 2 * 60 * 60 * 1000; // 2 hours
+
+ private readonly string _logGroupName;
+ private readonly string _logStreamName;
+ private readonly IAmazonCloudWatchLogs _logsClient;
+ private LogEventBatch? _eventBatch;
+
+ public CloudWatchLogsClient(string logGroupName, string? logStreamName = null, AmazonCloudWatchLogsConfig? config = null)
+ {
+ _logGroupName = logGroupName;
+ _logStreamName = logStreamName ?? GenerateLogStreamName();
+ _logsClient = new AmazonCloudWatchLogsClient(config ?? new AmazonCloudWatchLogsConfig());
+ }
+
+ ///
+ /// Generate a unique log stream name.
+ ///
+ private static string GenerateLogStreamName()
+ {
+ var uniqueId = Guid.NewGuid().ToString("N")[..8];
+ return $"otel-dotnet-{uniqueId}";
+ }
+
+ ///
+ /// Ensure the log group exists, create if it doesn't.
+ ///
+ private async Task EnsureLogGroupExistsAsync()
+ {
+ try
+ {
+ await _logsClient.CreateLogGroupAsync(new CreateLogGroupRequest
+ {
+ LogGroupName = _logGroupName
+ });
+ }
+ catch (ResourceAlreadyExistsException)
+ {
+ // Log group already exists, which is fine
+ }
+ }
+
+ ///
+ /// Ensure the log stream exists, create if it doesn't.
+ ///
+ private async Task EnsureLogStreamExistsAsync()
+ {
+ try
+ {
+ await _logsClient.CreateLogStreamAsync(new CreateLogStreamRequest
+ {
+ LogGroupName = _logGroupName,
+ LogStreamName = _logStreamName
+ });
+ }
+ catch (ResourceAlreadyExistsException)
+ {
+ // Log stream already exists, which is fine
+ }
+ }
+
+ ///
+ /// Validate the log event according to CloudWatch Logs constraints.
+ ///
+ private bool ValidateLogEvent(InputLogEvent logEvent)
+ {
+ if (string.IsNullOrWhiteSpace(logEvent.Message))
+ {
+ return false;
+ }
+
+ // Check message size
+ var messageSize = logEvent.Message.Length + CwPerEventHeaderBytes;
+ if (messageSize > CwMaxEventPayloadBytes)
+ {
+ var maxMessageSize = CwMaxEventPayloadBytes - CwPerEventHeaderBytes - CwTruncatedSuffix.Length;
+ logEvent.Message = logEvent.Message[..maxMessageSize] + CwTruncatedSuffix;
+ }
+
+ // Check timestamp constraints
+ var currentTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
+ var logEventTimestampMs = ((DateTimeOffset)logEvent.Timestamp).ToUnixTimeMilliseconds();
+ var timeDiff = currentTime - logEventTimestampMs;
+
+ if (timeDiff > CwEventTimestampLimitPast || timeDiff < -CwEventTimestampLimitFuture)
+ {
+ return false;
+ }
+
+ return true;
+ }
+
+ ///
+ /// Create a new log event batch.
+ ///
+ private static LogEventBatch CreateEventBatch() => new();
+
+ ///
+ /// Check if adding the next event would exceed CloudWatch Logs limits.
+ ///
+ private static bool EventBatchExceedsLimit(LogEventBatch batch, int nextEventSize)
+ {
+ return batch.Size() >= CwMaxRequestEventCount ||
+ batch.ByteTotal + nextEventSize > CwMaxRequestPayloadBytes;
+ }
+
+ ///
+ /// Check if the event batch is active and can accept the event.
+ ///
+ private static bool IsBatchActive(LogEventBatch batch, long targetTimestampMs)
+ {
+ // New log event batch
+ if (batch.MinTimestampMs == 0 || batch.MaxTimestampMs == 0)
+ {
+ return true;
+ }
+
+ // Check if adding the event would make the batch span more than 24 hours
+ if (targetTimestampMs - batch.MinTimestampMs > 24 * 3600 * 1000 ||
+ batch.MaxTimestampMs - targetTimestampMs > 24 * 3600 * 1000)
+ {
+ return false;
+ }
+
+ // Flush the event batch when reached 60s interval
+ var currentTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
+ return currentTime - batch.CreatedTimestampMs < BatchFlushInterval;
+ }
+
+ ///
+ /// Sort log events in the batch by timestamp.
+ ///
+ private static void SortLogEvents(LogEventBatch batch)
+ {
+ batch.LogEvents.Sort((a, b) => a.Timestamp.CompareTo(b.Timestamp));
+ }
+
+ ///
+ /// Send a batch of log events to CloudWatch Logs.
+ ///
+ private async Task SendLogBatchAsync(LogEventBatch batch)
+ {
+ if (batch.IsEmpty())
+ {
+ return null;
+ }
+
+ SortLogEvents(batch);
+
+ var request = new PutLogEventsRequest
+ {
+ LogGroupName = _logGroupName,
+ LogStreamName = _logStreamName,
+ LogEvents = batch.LogEvents
+ };
+
+ try
+ {
+ return await _logsClient.PutLogEventsAsync(request);
+ }
+ catch (ResourceNotFoundException)
+ {
+ // Create log group and stream, then retry
+ await EnsureLogGroupExistsAsync();
+ await EnsureLogStreamExistsAsync();
+ return await _logsClient.PutLogEventsAsync(request);
+ }
+ }
+
+ ///
+ /// Send a log event to CloudWatch Logs.
+ ///
+ public async Task SendLogEventAsync(LogEvent logEvent)
+ {
+ var inputLogEvent = new InputLogEvent
+ {
+ Message = logEvent.Message,
+ Timestamp = DateTimeOffset.FromUnixTimeMilliseconds(logEvent.Timestamp).UtcDateTime
+ };
+
+ if (!ValidateLogEvent(inputLogEvent))
+ {
+ return;
+ }
+
+ var eventSize = inputLogEvent.Message.Length + CwPerEventHeaderBytes;
+
+ // Initialize event batch if needed
+ _eventBatch ??= CreateEventBatch();
+
+ // Check if we need to send the current batch and create a new one
+ var currentBatch = _eventBatch;
+ var inputLogEventTimestampMs = ((DateTimeOffset)inputLogEvent.Timestamp).ToUnixTimeMilliseconds();
+ if (EventBatchExceedsLimit(currentBatch, eventSize) ||
+ !IsBatchActive(currentBatch, inputLogEventTimestampMs))
+ {
+ await SendLogBatchAsync(currentBatch);
+ _eventBatch = CreateEventBatch();
+ currentBatch = _eventBatch;
+ }
+
+ // Add the log event to the batch
+ currentBatch.AddEvent(inputLogEvent, eventSize);
+ }
+
+ ///
+ /// Force flush any pending log events.
+ ///
+ public async Task FlushPendingEventsAsync()
+ {
+ if (_eventBatch != null && !_eventBatch.IsEmpty())
+ {
+ var currentBatch = _eventBatch;
+ _eventBatch = CreateEventBatch();
+ await SendLogBatchAsync(currentBatch);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/AWS.Distro.OpenTelemetry.AutoInstrumentation/Exporters/Aws/Metrics/ConsoleEmfExporter.cs b/src/AWS.Distro.OpenTelemetry.AutoInstrumentation/Exporters/Aws/Metrics/ConsoleEmfExporter.cs
new file mode 100644
index 00000000..63163683
--- /dev/null
+++ b/src/AWS.Distro.OpenTelemetry.AutoInstrumentation/Exporters/Aws/Metrics/ConsoleEmfExporter.cs
@@ -0,0 +1,59 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace AWS.Distro.OpenTelemetry.AutoInstrumentation.Exporters.Aws.Metrics
+{
+ ///
+ /// OpenTelemetry metrics exporter for CloudWatch EMF format to console output.
+ ///
+ /// This exporter converts OTel metrics into CloudWatch EMF logs and writes them
+ /// to standard output instead of sending to CloudWatch Logs. This is useful for
+ /// debugging, testing, or when you want to process EMF logs with other tools.
+ ///
+ /// https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Embedded_Metric_Format_Specification.html
+ ///
+ public class ConsoleEmfExporter : EmfExporterBase
+ {
+ ///
+ /// Constructor for the Console EMF exporter.
+ ///
+ /// CloudWatch namespace for metrics (defaults to "default")
+ public ConsoleEmfExporter(string namespaceName = "default")
+ : base(namespaceName)
+ {
+ }
+
+ ///
+ /// This method writes the EMF log message to stdout, making it easy to
+ /// capture and redirect the output for processing or debugging purposes.
+ ///
+ protected override Task SendLogEventAsync(LogEvent logEvent)
+ {
+ Console.WriteLine($"[EMF EXPORT] {DateTime.Now}: {logEvent.Message}");
+ File.AppendAllText("/app/logs/emf-debug.log", $"[{DateTime.Now}] EMF Export: {logEvent.Message}\n");
+ return Task.CompletedTask;
+ }
+
+ ///
+ /// Force flush any pending metrics.
+ /// For this exporter, there is nothing to forceFlush.
+ ///
+ public override Task ForceFlushAsync(CancellationToken cancellationToken)
+ {
+ return Task.CompletedTask;
+ }
+
+ ///
+ /// Shutdown the exporter.
+ /// For this exporter, there is nothing to clean-up in order to shutdown.
+ ///
+ public override Task ShutdownAsync(CancellationToken cancellationToken)
+ {
+ return Task.CompletedTask;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/AWS.Distro.OpenTelemetry.AutoInstrumentation/Exporters/Aws/Metrics/EmfExporterBase.cs b/src/AWS.Distro.OpenTelemetry.AutoInstrumentation/Exporters/Aws/Metrics/EmfExporterBase.cs
new file mode 100644
index 00000000..640d0298
--- /dev/null
+++ b/src/AWS.Distro.OpenTelemetry.AutoInstrumentation/Exporters/Aws/Metrics/EmfExporterBase.cs
@@ -0,0 +1,607 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using OpenTelemetry;
+using OpenTelemetry.Logs;
+using OpenTelemetry.Metrics;
+using OpenTelemetry.Resources;
+
+namespace AWS.Distro.OpenTelemetry.AutoInstrumentation.Exporters.Aws.Metrics
+{
+ ///
+ /// Intermediate format for metric data before converting to EMF.
+ ///
+ public class MetricRecord
+ {
+ public string Name { get; set; } = string.Empty;
+ public string Unit { get; set; } = string.Empty;
+ public string Description { get; set; } = string.Empty;
+ public long Timestamp { get; set; }
+ public Dictionary Attributes { get; set; } = new();
+ public double? Value { get; set; }
+ public HistogramMetricRecordData? HistogramData { get; set; }
+ public ExponentialHistogramMetricRecordData? ExpHistogramData { get; set; }
+ }
+
+ ///
+ /// Histogram metric record data.
+ ///
+ public class HistogramMetricRecordData
+ {
+ public long Count { get; set; }
+ public double Sum { get; set; }
+ public double Max { get; set; }
+ public double Min { get; set; }
+ }
+
+ ///
+ /// Exponential histogram metric record data.
+ ///
+ public class ExponentialHistogramMetricRecordData
+ {
+ public double[] Values { get; set; } = Array.Empty();
+ public long[] Counts { get; set; } = Array.Empty();
+ public long Count { get; set; }
+ public double Sum { get; set; }
+ public double Max { get; set; }
+ public double Min { get; set; }
+ }
+
+ ///
+ /// EMF log structure.
+ ///
+ public class EmfLog : Dictionary
+ {
+ public EmfLog()
+ {
+ this["Version"] = "1";
+ }
+ }
+
+ ///
+ /// CloudWatch metric definition.
+ ///
+ public class CloudWatchMetric
+ {
+ public string Namespace { get; set; } = string.Empty;
+ public string[][]? Dimensions { get; set; }
+ public MetricDefinition[] Metrics { get; set; } = Array.Empty();
+ }
+
+ ///
+ /// Metric definition.
+ ///
+ public class MetricDefinition
+ {
+ public string Name { get; set; } = string.Empty;
+ public string? Unit { get; set; }
+ }
+
+ ///
+ /// AWS EMF metadata.
+ ///
+ public class AwsMetadata
+ {
+ public long Timestamp { get; set; }
+ public CloudWatchMetric[] CloudWatchMetrics { get; set; } = Array.Empty();
+ }
+
+ ///
+ /// Log event structure.
+ ///
+ public class LogEvent
+ {
+ public string Message { get; set; } = string.Empty;
+ public long Timestamp { get; set; }
+ }
+
+ ///
+ /// Base class for OpenTelemetry metrics exporters that convert to CloudWatch EMF format.
+ ///
+ public abstract class EmfExporterBase : BaseExporter
+ {
+ private readonly string _namespace;
+ private readonly HashSet _emfSupportedUnits;
+ private readonly Dictionary _unitMapping;
+
+ protected EmfExporterBase(string namespaceName = "default")
+ {
+ _namespace = namespaceName;
+
+ _emfSupportedUnits = new HashSet
+ {
+ "Seconds", "Microseconds", "Milliseconds", "Bytes", "Kilobytes", "Megabytes",
+ "Gigabytes", "Terabytes", "Bits", "Kilobits", "Megabits", "Gigabits", "Terabits",
+ "Percent", "Count", "Bytes/Second", "Kilobytes/Second", "Megabytes/Second",
+ "Gigabytes/Second", "Terabytes/Second", "Bits/Second", "Kilobits/Second",
+ "Megabits/Second", "Gigabits/Second", "Terabits/Second", "Count/Second", "None"
+ };
+
+ _unitMapping = new Dictionary
+ {
+ { "1", "" },
+ { "ns", "" },
+ { "ms", "Milliseconds" },
+ { "s", "Seconds" },
+ { "us", "Microseconds" },
+ { "By", "Bytes" },
+ { "bit", "Bits" }
+ };
+ }
+
+ ///
+ /// Normalize an OpenTelemetry timestamp to milliseconds for CloudWatch.
+ ///
+ private long NormalizeTimestamp(DateTimeOffset timestamp)
+ {
+ return timestamp.ToUnixTimeMilliseconds();
+ }
+
+ ///
+ /// Create a base metric record with instrument information.
+ ///
+ private MetricRecord CreateMetricRecord(
+ string metricName,
+ string metricUnit,
+ string metricDescription,
+ long timestamp,
+ Dictionary attributes)
+ {
+ return new MetricRecord
+ {
+ Name = metricName,
+ Unit = metricUnit,
+ Description = metricDescription,
+ Timestamp = timestamp,
+ Attributes = attributes
+ };
+ }
+
+ ///
+ /// Convert a Gauge or Sum metric datapoint to a metric record.
+ ///
+ private MetricRecord ConvertGaugeAndSum(Metric metric, ref readonly MetricPoint metricPoint)
+ {
+ var timestampMs = NormalizeTimestamp(metricPoint.EndTime);
+ var attributes = new Dictionary();
+ foreach (var tag in metricPoint.Tags)
+ {
+ attributes[tag.Key] = tag.Value ?? string.Empty;
+ }
+
+ var record = CreateMetricRecord(
+ metric.Name,
+ metric.Unit ?? string.Empty,
+ metric.Description ?? string.Empty,
+ timestampMs,
+ attributes);
+
+ record.Value = metric.MetricType switch
+ {
+ MetricType.LongSum or MetricType.LongGauge or MetricType.LongSumNonMonotonic or MetricType.LongGaugeNonMonotonic => metricPoint.GetSumLong(),
+ MetricType.DoubleSum or MetricType.DoubleGauge or MetricType.DoubleSumNonMonotonic or MetricType.DoubleGaugeNonMonotonic => metricPoint.GetSumDouble(),
+ _ => 0
+ };
+
+ return record;
+ }
+
+ ///
+ /// Convert a Histogram metric datapoint to a metric record.
+ ///
+ private MetricRecord ConvertHistogram(Metric metric, ref readonly MetricPoint metricPoint)
+ {
+ var timestampMs = NormalizeTimestamp(metricPoint.EndTime);
+ var attributes = new Dictionary();
+ foreach (var tag in metricPoint.Tags)
+ {
+ attributes[tag.Key] = tag.Value ?? string.Empty;
+ }
+
+ var record = CreateMetricRecord(
+ metric.Name,
+ metric.Unit ?? string.Empty,
+ metric.Description ?? string.Empty,
+ timestampMs,
+ attributes);
+
+ var min = 0.0;
+ var max = 0.0;
+ if (metricPoint.TryGetHistogramMinMaxValues(out var minValue, out var maxValue))
+ {
+ min = minValue;
+ max = maxValue;
+ }
+
+ record.HistogramData = new HistogramMetricRecordData
+ {
+ Count = metricPoint.GetHistogramCount(),
+ Sum = metricPoint.GetHistogramSum(),
+ Min = min,
+ Max = max
+ };
+
+ return record;
+ }
+
+ ///
+ /// Convert an ExponentialHistogram metric datapoint to a metric record.
+ ///
+ private MetricRecord ConvertExpHistogram(Metric metric, ref readonly MetricPoint metricPoint)
+ {
+ var arrayValues = new List();
+ var arrayCounts = new List();
+
+ var timestampMs = NormalizeTimestamp(metricPoint.EndTime);
+ var attributes = new Dictionary();
+ foreach (var tag in metricPoint.Tags)
+ {
+ attributes[tag.Key] = tag.Value ?? string.Empty;
+ }
+
+ var expHistogram = metricPoint.GetExponentialHistogramData();
+ var scale = expHistogram.Scale;
+ var baseValue = Math.Pow(2, Math.Pow(2, -scale));
+
+ // Process positive buckets using reflection
+ if (expHistogram.PositiveBuckets != null)
+ {
+ var positiveBucketsType = expHistogram.PositiveBuckets.GetType();
+ var offsetProperty = positiveBucketsType.GetProperty("Offset");
+ var positiveOffset = (int)(offsetProperty?.GetValue(expHistogram.PositiveBuckets) ?? 0);
+
+ var bucketIndex = 0;
+ var enumerator = expHistogram.PositiveBuckets.GetEnumerator();
+ while (enumerator.MoveNext())
+ {
+ var bucketCount = enumerator.Current;
+ var index = bucketIndex + positiveOffset;
+ var bucketBegin = Math.Pow(baseValue, index);
+ var bucketEnd = Math.Pow(baseValue, index + 1);
+ var metricVal = (bucketBegin + bucketEnd) / 2;
+
+ if (bucketCount > 0)
+ {
+ arrayValues.Add(metricVal);
+ arrayCounts.Add((long)bucketCount);
+ }
+
+ bucketIndex++;
+ }
+ }
+
+ // Process zero bucket
+ var zeroCount = expHistogram.ZeroCount;
+ if (zeroCount > 0)
+ {
+ arrayValues.Add(0);
+ arrayCounts.Add(zeroCount);
+ }
+
+ // Process negative buckets using reflection
+ var negativeBucketsProperty = expHistogram.GetType().GetProperty("NegativeBuckets");
+ var negativeBuckets = negativeBucketsProperty?.GetValue(expHistogram);
+
+ if (negativeBuckets != null)
+ {
+ var negativeBucketsType = negativeBuckets.GetType();
+ var offsetProperty = negativeBucketsType.GetProperty("Offset");
+ var negativeOffset = (int)(offsetProperty?.GetValue(negativeBuckets) ?? 0);
+
+ var getEnumeratorMethod = negativeBucketsType.GetMethod("GetEnumerator");
+ var enumerator = getEnumeratorMethod?.Invoke(negativeBuckets, null);
+
+ if (enumerator != null)
+ {
+ var moveNextMethod = enumerator.GetType().GetMethod("MoveNext");
+ var currentProperty = enumerator.GetType().GetProperty("Current");
+
+ var bucketIndex = 0;
+ while ((bool)(moveNextMethod?.Invoke(enumerator, null) ?? false))
+ {
+ var bucketCount = Convert.ToInt64(currentProperty?.GetValue(enumerator));
+ var index = bucketIndex + negativeOffset;
+ var bucketEnd = -Math.Pow(baseValue, index);
+ var bucketBegin = -Math.Pow(baseValue, index + 1);
+ var metricVal = (bucketBegin + bucketEnd) / 2;
+
+ if (bucketCount > 0)
+ {
+ arrayValues.Add(metricVal);
+ arrayCounts.Add(bucketCount);
+ }
+
+ bucketIndex++;
+ }
+ }
+ }
+
+ var record = CreateMetricRecord(
+ metric.Name,
+ metric.Unit ?? string.Empty,
+ metric.Description ?? string.Empty,
+ timestampMs,
+ attributes);
+
+ var min = 0.0;
+ var max = 0.0;
+ if (metricPoint.TryGetHistogramMinMaxValues(out var minValue, out var maxValue))
+ {
+ min = minValue;
+ max = maxValue;
+ }
+
+ record.ExpHistogramData = new ExponentialHistogramMetricRecordData
+ {
+ Values = arrayValues.ToArray(),
+ Counts = arrayCounts.ToArray(),
+ Count = (long)metricPoint.GetHistogramCount(),
+ Sum = metricPoint.GetHistogramSum(),
+ Max = max,
+ Min = min
+ };
+
+ return record;
+ }
+
+ ///
+ /// Group metric record by attributes and timestamp.
+ ///
+ private (string, long) GroupByAttributesAndTimestamp(MetricRecord record)
+ {
+ var attrsKey = GetAttributesKey(record.Attributes);
+ return (attrsKey, record.Timestamp);
+ }
+
+ ///
+ /// Method to handle safely pushing a MetricRecord into a Map of a Map of a list of MetricRecords.
+ ///
+ private void PushMetricRecordIntoGroupedMetrics(
+ Dictionary>> groupedMetrics,
+ string groupAttribute,
+ long groupTimestamp,
+ MetricRecord record)
+ {
+ if (!groupedMetrics.ContainsKey(groupAttribute))
+ {
+ groupedMetrics[groupAttribute] = new Dictionary>();
+ }
+
+ if (!groupedMetrics[groupAttribute].ContainsKey(groupTimestamp))
+ {
+ groupedMetrics[groupAttribute][groupTimestamp] = new List();
+ }
+
+ groupedMetrics[groupAttribute][groupTimestamp].Add(record);
+ }
+
+ ///
+ /// Get CloudWatch unit from unit in MetricRecord.
+ ///
+ private string? GetUnit(MetricRecord record)
+ {
+ var unit = record.Unit;
+
+ if (_emfSupportedUnits.Contains(unit))
+ {
+ return unit;
+ }
+
+ return _unitMapping.TryGetValue(unit, out var mappedUnit) ? mappedUnit : null;
+ }
+
+ ///
+ /// Extract dimension names from attributes.
+ ///
+ private string[] GetDimensionNames(Dictionary attributes)
+ {
+ return attributes.Keys.ToArray();
+ }
+
+ ///
+ /// Create a hashable key from attributes for grouping metrics.
+ ///
+ private string GetAttributesKey(Dictionary attributes)
+ {
+ var sortedAttrs = attributes.OrderBy(kvp => kvp.Key).ToArray();
+ return string.Join(",", sortedAttrs.Select(kvp => $"{kvp.Key}={kvp.Value}"));
+ }
+
+ ///
+ /// Create EMF log from metric records.
+ ///
+ private EmfLog CreateEmfLog(List metricRecords, Resource resource, long? timestamp = null)
+ {
+ var emfLog = new EmfLog();
+
+ var awsMetadata = new AwsMetadata
+ {
+ Timestamp = timestamp ?? DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ CloudWatchMetrics = Array.Empty()
+ };
+
+ emfLog["_aws"] = awsMetadata;
+
+ // Add resource attributes
+ if (resource?.Attributes != null)
+ {
+ foreach (var attr in resource.Attributes)
+ {
+ emfLog[$"otel.resource.{attr.Key}"] = attr.Value?.ToString() ?? "undefined";
+ }
+ }
+
+ var metricDefinitions = new List();
+ var allAttributes = metricRecords.Count > 0 ? metricRecords[0].Attributes : new Dictionary();
+
+ // Process each metric record
+ foreach (var record in metricRecords)
+ {
+ if (string.IsNullOrEmpty(record.Name))
+ continue;
+
+ if (record.ExpHistogramData != null)
+ {
+ emfLog[record.Name] = record.ExpHistogramData;
+ }
+ else if (record.HistogramData != null)
+ {
+ emfLog[record.Name] = record.HistogramData;
+ }
+ else if (record.Value.HasValue)
+ {
+ emfLog[record.Name] = record.Value.Value;
+ }
+ else
+ {
+ //[] Debug Log here diag.debug(`Skipping metric ${metricName} as it does not have valid metric value`);
+ continue;
+ }
+
+ var metricDef = new MetricDefinition { Name = record.Name };
+ var unit = GetUnit(record);
+ if (!string.IsNullOrEmpty(unit))
+ {
+ metricDef.Unit = unit;
+ }
+ metricDefinitions.Add(metricDef);
+ }
+
+ var dimensionNames = GetDimensionNames(allAttributes);
+
+ // Add attribute values to the root of the EMF log
+ foreach (var attr in allAttributes)
+ {
+ emfLog[attr.Key] = attr.Value?.ToString() ?? "undefined";
+ }
+
+ // Add CloudWatch Metrics
+ if (metricDefinitions.Count > 0)
+ {
+ var cloudWatchMetric = new CloudWatchMetric
+ {
+ Namespace = _namespace,
+ Metrics = metricDefinitions.ToArray()
+ };
+
+ if (dimensionNames.Length > 0)
+ {
+ cloudWatchMetric.Dimensions = new[] { dimensionNames };
+ }
+
+ awsMetadata.CloudWatchMetrics = new[] { cloudWatchMetric };
+ }
+
+ return emfLog;
+ }
+
+ ///
+ /// Export metrics as EMF logs.
+ /// Groups metrics by attributes and timestamp before creating EMF logs.
+ ///
+ public override ExportResult Export(in Batch batch)
+ {
+ Console.WriteLine($"\nEMF Export called with {batch.Count} metrics.");
+ File.AppendAllText("/app/logs/plugin-debug.log", $"[{DateTime.Now}] EMF Export called with {batch.Count} metrics\n");
+ try
+ {
+ var resource = ParentProvider?.GetResource() ?? Resource.Empty;
+ var groupedMetrics = new Dictionary>>();
+
+ var metricNames = new List();
+ foreach (var metric in batch)
+ {
+ metricNames.Add($"{metric.Name}({metric.MeterName})");
+ }
+ Console.WriteLine($"All metrics in batch: {string.Join(", ", metricNames)}");
+ File.AppendAllText("/app/logs/plugin-debug.log", $"[{DateTime.Now}] All metrics: {string.Join(", ", metricNames)}\n");
+
+ foreach (var metric in batch)
+ {
+ try
+ {
+ Console.WriteLine($"Processing metric: {metric.Name} from meter: {metric.MeterName} (type: {metric.MetricType})");
+ File.AppendAllText("/app/logs/plugin-debug.log", $"[{DateTime.Now}] Processing metric: {metric.Name} from meter: {metric.MeterName} (type: {metric.MetricType})\n");
+
+ if (metric.MeterName == "dice-lib")
+ {
+ Console.WriteLine($"*** FOUND DICE-LIB METRIC: {metric.Name} ***");
+ File.AppendAllText("/app/logs/plugin-debug.log", $"[{DateTime.Now}] *** FOUND DICE-LIB METRIC: {metric.Name} ***\n");
+ }
+
+ foreach (var metricPoint in metric.GetMetricPoints())
+ {
+ MetricRecord record = metric.MetricType switch
+ {
+ MetricType.LongSum or MetricType.LongGauge or MetricType.DoubleSum or MetricType.DoubleGauge or MetricType.LongSumNonMonotonic or MetricType.DoubleSumNonMonotonic or MetricType.LongGaugeNonMonotonic or MetricType.DoubleGaugeNonMonotonic => ConvertGaugeAndSum(metric, in metricPoint),
+ MetricType.Histogram => ConvertHistogram(metric, in metricPoint),
+ MetricType.ExponentialHistogram => ConvertExpHistogram(metric, in metricPoint),
+ _ => throw new NotSupportedException($"Unsupported metric type: {metric.MetricType}")
+ };
+
+ var (groupAttribute, groupTimestamp) = GroupByAttributesAndTimestamp(record);
+ PushMetricRecordIntoGroupedMetrics(groupedMetrics, groupAttribute, groupTimestamp, record);
+ }
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Error processing metric {metric.Name}: {ex.Message}");
+ File.AppendAllText("/app/logs/plugin-debug.log", $"[{DateTime.Now}] Error processing metric {metric.Name}: {ex.Message}\n");
+ }
+ }
+
+ Console.WriteLine($"Finished processing all {batch.Count} metrics. Creating EMF logs...");
+ File.AppendAllText("/app/logs/plugin-debug.log", $"[{DateTime.Now}] Finished processing all {batch.Count} metrics. Creating EMF logs...\n");
+
+ // Process each group separately to create one EMF log per group
+ foreach (var metricsRecordsGroupedByTimestamp in groupedMetrics.Values)
+ {
+ foreach (var (timestampMs, metricRecords) in metricsRecordsGroupedByTimestamp)
+ {
+ if (metricRecords != null)
+ {
+ var logEvent = new LogEvent
+ {
+ Message = JsonSerializer.Serialize(CreateEmfLog(metricRecords, resource, timestampMs)),
+ Timestamp = timestampMs
+ };
+
+ SendLogEventAsync(logEvent).GetAwaiter().GetResult();
+ }
+ }
+ }
+
+ return ExportResult.Success;
+ }
+ catch (Exception)
+ {
+ //[] diag.error(`Failed to export metrics: ${e}`);
+ return ExportResult.Failure;
+ }
+ }
+
+
+
+ ///
+ /// Send a log event to the destination.
+ ///
+ protected abstract Task SendLogEventAsync(LogEvent logEvent);
+
+ ///
+ /// Force flush any pending metrics.
+ ///
+ public abstract Task ForceFlushAsync(CancellationToken cancellationToken);
+
+ ///
+ /// Shutdown the exporter.
+ ///
+ public abstract Task ShutdownAsync(CancellationToken cancellationToken);
+
+ }
+}
\ No newline at end of file
diff --git a/src/AWS.Distro.OpenTelemetry.AutoInstrumentation/Exporters/Aws/Metrics/MeterProviderBuilderExtensions.cs b/src/AWS.Distro.OpenTelemetry.AutoInstrumentation/Exporters/Aws/Metrics/MeterProviderBuilderExtensions.cs
new file mode 100644
index 00000000..da289397
--- /dev/null
+++ b/src/AWS.Distro.OpenTelemetry.AutoInstrumentation/Exporters/Aws/Metrics/MeterProviderBuilderExtensions.cs
@@ -0,0 +1,54 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+using System;
+using Amazon.CloudWatchLogs;
+using OpenTelemetry.Metrics;
+
+namespace AWS.Distro.OpenTelemetry.AutoInstrumentation.Exporters.Aws.Metrics
+{
+ ///
+ /// Extension methods for MeterProviderBuilder to add AWS EMF exporters.
+ ///
+ public static class MeterProviderBuilderExtensions
+ {
+ ///
+ /// Adds AWS CloudWatch EMF exporter to the MeterProvider.
+ ///
+ /// The MeterProviderBuilder instance.
+ /// CloudWatch namespace for metrics.
+ /// CloudWatch log group name.
+ /// CloudWatch log stream name (optional).
+ /// Optional configuration action for CloudWatch Logs client.
+ /// The MeterProviderBuilder instance for chaining.
+ public static MeterProviderBuilder AddAwsCloudWatchEmfExporter(
+ this MeterProviderBuilder builder,
+ string namespaceName = "default",
+ string logGroupName = "aws/otel/metrics",
+ string? logStreamName = null,
+ Action? configure = null)
+ {
+ var config = new AmazonCloudWatchLogsConfig();
+ configure?.Invoke(config);
+
+ return builder.AddReader(new PeriodicExportingMetricReader(
+ new AwsCloudWatchEmfExporter(namespaceName, logGroupName, logStreamName, config),
+ exportIntervalMilliseconds: 60000)); // Export every 60 seconds
+ }
+
+ ///
+ /// Adds Console EMF exporter to the MeterProvider for debugging purposes.
+ ///
+ /// The MeterProviderBuilder instance.
+ /// CloudWatch namespace for metrics.
+ /// The MeterProviderBuilder instance for chaining.
+ public static MeterProviderBuilder AddConsoleEmfExporter(
+ this MeterProviderBuilder builder,
+ string namespaceName = "default")
+ {
+ return builder.AddReader(new PeriodicExportingMetricReader(
+ new ConsoleEmfExporter(namespaceName),
+ exportIntervalMilliseconds: 5000)); // Export every 5 seconds for debugging
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/AWS.Distro.OpenTelemetry.AutoInstrumentation/Exporters/Aws/Metrics/README.md b/src/AWS.Distro.OpenTelemetry.AutoInstrumentation/Exporters/Aws/Metrics/README.md
new file mode 100644
index 00000000..4ef14551
--- /dev/null
+++ b/src/AWS.Distro.OpenTelemetry.AutoInstrumentation/Exporters/Aws/Metrics/README.md
@@ -0,0 +1,42 @@
+# AWS CloudWatch EMF Exporters for .NET
+
+This directory contains OpenTelemetry metrics exporters that convert metrics to CloudWatch Embedded Metric Format (EMF).
+
+## Exporters
+
+### AwsCloudWatchEmfExporter
+Exports metrics to CloudWatch Logs in EMF format, which are automatically extracted as CloudWatch metrics.
+
+### ConsoleEmfExporter
+Exports metrics to console output in EMF format for debugging and testing purposes.
+
+## Usage
+
+```csharp
+using OpenTelemetry.Metrics;
+using AWS.Distro.OpenTelemetry.AutoInstrumentation.Exporters.Aws.Metrics;
+
+// Add CloudWatch EMF exporter
+var meterProvider = Meter.CreateMeterProvider(builder =>
+ builder.AddAwsCloudWatchEmfExporter(
+ namespaceName: "MyApplication",
+ logGroupName: "my-app-metrics",
+ logStreamName: "my-stream"));
+
+// Add Console EMF exporter for debugging
+var meterProvider = Meter.CreateMeterProvider(builder =>
+ builder.AddConsoleEmfExporter("MyApplication"));
+```
+
+## Features
+
+- Converts OpenTelemetry metrics to CloudWatch EMF format
+- Batches log events for efficient CloudWatch Logs delivery
+- Handles CloudWatch Logs constraints (message size, batch size, timestamp limits)
+- Supports all OpenTelemetry metric types (Counter, Gauge, Histogram)
+- Automatic log group and stream creation
+- Resource attributes included as metadata
+
+## CloudWatch EMF Format
+
+The exporters generate JSON logs following the [CloudWatch EMF specification](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Embedded_Metric_Format_Specification.html).
\ No newline at end of file
diff --git a/src/AWS.Distro.OpenTelemetry.AutoInstrumentation/Plugin.cs b/src/AWS.Distro.OpenTelemetry.AutoInstrumentation/Plugin.cs
index ad73650e..029c495a 100644
--- a/src/AWS.Distro.OpenTelemetry.AutoInstrumentation/Plugin.cs
+++ b/src/AWS.Distro.OpenTelemetry.AutoInstrumentation/Plugin.cs
@@ -21,6 +21,7 @@
using System.Runtime.CompilerServices;
using System.Text.RegularExpressions;
using AWS.Distro.OpenTelemetry.AutoInstrumentation.Logging;
+using AWS.Distro.OpenTelemetry.AutoInstrumentation.Exporters.Aws.Metrics;
using AWS.Distro.OpenTelemetry.Exporter.Xray.Udp;
using OpenTelemetry.Instrumentation.Http;
using OpenTelemetry.Metrics;
@@ -86,12 +87,23 @@ public class Plugin
};
private Sampler? sampler;
+ private MetricReader? emfMetricReader;
///
/// To configure plugin, before OTel SDK configuration is called.
- /// public void Initializing()
+ ///
public void Initializing()
{
+ try
+ {
+ File.AppendAllText("/app/logs/plugin-debug.log", $"[{DateTime.Now}] Plugin.Initializing() called\n");
+ Console.WriteLine("Plugin.Initializing() called");
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Error in Initializing: {ex.Message}");
+ }
+ this.CustomizeMetricReader();
}
///
@@ -193,6 +205,15 @@ public void TracerProviderInitialized(TracerProvider tracerProvider)
/// Returns configured builder
public TracerProviderBuilder BeforeConfigureTracerProvider(TracerProviderBuilder builder)
{
+ try
+ {
+ File.AppendAllText("/app/logs/plugin-debug.log", $"[{DateTime.Now}] BeforeConfigureTracerProvider called\n");
+ Console.WriteLine("BeforeConfigureTracerProvider called");
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Error in BeforeConfigureTracerProvider logging: {ex.Message}");
+ }
if (this.IsApplicationSignalsEnabled())
{
var resourceBuilder = ResourceBuilder
@@ -267,6 +288,27 @@ public TracerProviderBuilder AfterConfigureTracerProvider(TracerProviderBuilder
/// The configured metric provider builder
public MeterProviderBuilder AfterConfigureMeterProvider(MeterProviderBuilder builder)
{
+ // Add EMF metric reader if configured
+
+ Logger.Log(
+ LogLevel.Error, "Check if EMF Metric Reader is configured");
+ Console.WriteLine("\nCheck if EMF Metric Reader is configured.");
+ if (this.emfMetricReader != null)
+ {
+ Logger.Log(
+ LogLevel.Error, "EMF Metric READER is CONFIGURED");
+ Console.WriteLine("\nEMF Metric READER is CONFIGURED.");
+ builder.AddReader(this.emfMetricReader);
+ // Configure exponential histogram aggregation for histogram instruments
+ builder.AddView(instrument =>
+ {
+ return instrument.GetType().GetGenericTypeDefinition() == typeof(Histogram<>)
+ ? new Base2ExponentialBucketHistogramConfiguration()
+ : null;
+ });
+ Logger.Log(LogLevel.Information, "AWS EMF exporter enabled with DELTA temporality and exponential histograms.");
+ }
+
if (!this.IsApplicationSignalsRuntimeEnabled())
{
return builder;
@@ -532,6 +574,80 @@ private bool IsApplicationSignalsRuntimeEnabled()
!"false".Equals(System.Environment.GetEnvironmentVariable(ApplicationSignalsRuntimeEnabledConfig));
}
+ private void CustomizeMetricReader()
+ {
+ Logger.Log(
+ LogLevel.Error, "CustomizeMetricReader()");
+ Console.WriteLine("\nCustomizeMetricReader().");
+ File.AppendAllText("/app/logs/plugin-debug.log", $"[{DateTime.Now}] CustomizeMetricReader()\n");
+
+ bool isEmfEnabled = this.CheckEmfExporterEnabled();
+ if (isEmfEnabled)
+ {
+ Logger.Log(
+ LogLevel.Error, "EMF IS Enabled");
+ Console.WriteLine("\nEMF IS Enabled.");
+ File.AppendAllText("/app/logs/plugin-debug.log", $"[{DateTime.Now}] EMF IS Enabled\n");
+
+ var emfExporter = this.CreateEmfExporter();
+ if (emfExporter != null)
+ {
+ Logger.Log(
+ LogLevel.Error, "EMF Exporter is NOT null");
+ Console.WriteLine("\nEMF Exporter is NOT null.");
+ File.AppendAllText("/app/logs/plugin-debug.log", $"[{DateTime.Now}] EMF Exporter is NOT null\n");
+
+ this.emfMetricReader = new PeriodicExportingMetricReader(emfExporter, GetMetricExportInterval())
+ {
+ TemporalityPreference = MetricReaderTemporalityPreference.Delta
+ };
+ }
+ }
+ }
+
+ private bool CheckEmfExporterEnabled()
+ {
+ var exporterValue = System.Environment.GetEnvironmentVariable(MetricExporterConfig);
+ if (string.IsNullOrEmpty(exporterValue))
+ {
+ return false;
+ }
+
+ var exporters = exporterValue.Split(',').Select(e => e.Trim()).ToList();
+ var index = exporters.IndexOf("awsemf");
+ if (index == -1)
+ {
+ return false;
+ }
+
+ exporters.RemoveAt(index);
+ var newValue = exporters.Count > 0 ? string.Join(",", exporters) : null;
+
+ if (!string.IsNullOrEmpty(newValue))
+ {
+ System.Environment.SetEnvironmentVariable(MetricExporterConfig, newValue);
+ }
+ else
+ {
+ System.Environment.SetEnvironmentVariable(MetricExporterConfig, null);
+ }
+
+ return true;
+ }
+
+ private EmfExporterBase? CreateEmfExporter()
+ {
+ if (AwsSpanProcessingUtil.IsLambdaEnvironment())
+ {
+ // Lambda environment - use Console EMF exporter
+ return new ConsoleEmfExporter();
+ }
+
+ // Non-Lambda environment - would use CloudWatch EMF exporter if headers are valid
+ // For now, return Console EMF exporter for debugging
+ return new ConsoleEmfExporter();
+ }
+
private ResourceBuilder ResourceBuilderCustomizer(ResourceBuilder builder, Resource? existingResource = null)
{
// base case: If there is an already existing resource passed as a parameter, we will copy
diff --git a/test/AWS.Distro.OpenTelemetry.AutoInstrumentation.Tests/Exporters/Aws/Metrics/AwsCloudWatchEmfExporterTest.cs b/test/AWS.Distro.OpenTelemetry.AutoInstrumentation.Tests/Exporters/Aws/Metrics/AwsCloudWatchEmfExporterTest.cs
new file mode 100644
index 00000000..617e4323
--- /dev/null
+++ b/test/AWS.Distro.OpenTelemetry.AutoInstrumentation.Tests/Exporters/Aws/Metrics/AwsCloudWatchEmfExporterTest.cs
@@ -0,0 +1,637 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using AWS.Distro.OpenTelemetry.AutoInstrumentation.Exporters.Aws.Metrics;
+using FluentAssertions;
+using OpenTelemetry.Resources;
+using OpenTelemetry.Metrics;
+using Xunit;
+
+namespace AWS.Distro.OpenTelemetry.AutoInstrumentation.Tests;
+
+[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1600:Elements should be documented", Justification = "Tests")]
+public class AwsCloudWatchEmfExporterTest
+{
+ private readonly AwsCloudWatchEmfExporter exporter;
+
+ public AwsCloudWatchEmfExporterTest()
+ {
+ this.exporter = new AwsCloudWatchEmfExporter("TestNamespace", "test-log-group", "test-stream");
+ }
+
+ [Fact]
+ public void TestInitialization()
+ {
+ // Test exporter initialization
+ this.exporter.Should().NotBeNull();
+
+ // Access private fields using reflection for testing
+ var namespaceField = typeof(EmfExporterBase).GetField("_namespace", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+ namespaceField?.GetValue(this.exporter).Should().Be("TestNamespace");
+
+ var logClientField = typeof(AwsCloudWatchEmfExporter).GetField("_logClient", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+ logClientField?.GetValue(this.exporter).Should().NotBeNull();
+ }
+
+ [Fact]
+ public void TestInitializationWithCustomParams()
+ {
+ // Test exporter initialization with custom parameters
+ var customExporter = new AwsCloudWatchEmfExporter("CustomNamespace", "custom-log-group", "custom-stream");
+
+ customExporter.Should().NotBeNull();
+
+ var namespaceField = typeof(EmfExporterBase).GetField("_namespace", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+ namespaceField?.GetValue(customExporter).Should().Be("CustomNamespace");
+ }
+
+ [Fact]
+ public void TestGetUnitMapping()
+ {
+ // Test unit mapping functionality using reflection to access private method
+ var getUnitMethod = typeof(EmfExporterBase).GetMethod("GetUnit", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+
+ // Test known units
+ var msRecord = new MetricRecord { Name = "testName", Unit = "ms", Description = "testDescription", Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), Attributes = new Dictionary() };
+ getUnitMethod?.Invoke(this.exporter, new object[] { msRecord }).Should().Be("Milliseconds");
+
+ var sRecord = new MetricRecord { Name = "testName", Unit = "s", Description = "testDescription", Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), Attributes = new Dictionary() };
+ getUnitMethod?.Invoke(this.exporter, new object[] { sRecord }).Should().Be("Seconds");
+
+ var byRecord = new MetricRecord { Name = "testName", Unit = "By", Description = "testDescription", Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), Attributes = new Dictionary() };
+ getUnitMethod?.Invoke(this.exporter, new object[] { byRecord }).Should().Be("Bytes");
+
+ var percentRecord = new MetricRecord { Name = "testName", Unit = "%", Description = "testDescription", Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), Attributes = new Dictionary() };
+ getUnitMethod?.Invoke(this.exporter, new object[] { percentRecord }).Should().BeNull();
+
+ // Test unknown unit
+ var unknownRecord = new MetricRecord { Name = "testName", Unit = "unknown", Description = "testDescription", Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), Attributes = new Dictionary() };
+ getUnitMethod?.Invoke(this.exporter, new object[] { unknownRecord }).Should().BeNull();
+
+ // Test empty unit
+ var emptyRecord = new MetricRecord { Name = "testName", Unit = string.Empty, Description = "testDescription", Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), Attributes = new Dictionary() };
+ getUnitMethod?.Invoke(this.exporter, new object[] { emptyRecord }).Should().BeNull();
+ }
+
+ [Fact]
+ public void TestGetDimensionNames()
+ {
+ // Test dimension names extraction using reflection
+ var getDimensionNamesMethod = typeof(EmfExporterBase).GetMethod("GetDimensionNames", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+
+ var attributes = new Dictionary { { "service.name", "test-service" }, { "env", "prod" }, { "region", "us-east-1" } };
+ var result = getDimensionNamesMethod?.Invoke(this.exporter, new object[] { attributes }) as string[];
+
+ result.Should().Contain("service.name");
+ result.Should().Contain("env");
+ result.Should().Contain("region");
+ }
+
+ [Fact]
+ public void TestGetAttributesKey()
+ {
+ // Test attributes key generation using reflection
+ var getAttributesKeyMethod = typeof(EmfExporterBase).GetMethod("GetAttributesKey", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+
+ var attributes = new Dictionary { { "service", "test" }, { "env", "prod" } };
+ var result = getAttributesKeyMethod?.Invoke(this.exporter, new object[] { attributes }) as string;
+
+ result.Should().BeOfType();
+ result.Should().Contain("service");
+ result.Should().Contain("test");
+ result.Should().Contain("env");
+ result.Should().Contain("prod");
+ }
+
+ [Fact]
+ public void TestGetAttributesKeyConsistent()
+ {
+ // Test that attributes key generation is consistent
+ var getAttributesKeyMethod = typeof(EmfExporterBase).GetMethod("GetAttributesKey", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+
+ // Same attributes in different order should produce same key
+ var attrs1 = new Dictionary { { "b", "2" }, { "a", "1" } };
+ var attrs2 = new Dictionary { { "a", "1" }, { "b", "2" } };
+
+ var key1 = getAttributesKeyMethod?.Invoke(this.exporter, new object[] { attrs1 }) as string;
+ var key2 = getAttributesKeyMethod?.Invoke(this.exporter, new object[] { attrs2 }) as string;
+
+ key1.Should().Be(key2);
+ }
+
+ [Fact]
+ public void TestCreateEmfLog()
+ {
+ // Test EMF log creation using reflection
+ var createEmfLogMethod = typeof(EmfExporterBase).GetMethod("CreateEmfLog", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+
+ var gaugeRecord = new MetricRecord
+ {
+ Name = "gauge_metric",
+ Unit = "Count",
+ Description = "Gauge",
+ Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ Attributes = new Dictionary { { "env", "test" } },
+ Value = 50.0,
+ };
+
+ var sumRecord = new MetricRecord
+ {
+ Name = "sum_metric",
+ Unit = "Count",
+ Description = "Sum",
+ Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ Attributes = new Dictionary { { "env", "test" } },
+ Value = 100.0,
+ };
+
+ var records = new List { gaugeRecord, sumRecord };
+ var resource = new Resource(new Dictionary { { "service.name", "test-service" } });
+
+ var result = createEmfLogMethod?.Invoke(this.exporter, new object[] { records, resource, null! }) as EmfLog;
+
+ result.Should().NotBeNull();
+ result.Should().ContainKey("_aws");
+ result.Should().ContainKey("Version");
+ result["Version"].Should().Be("1");
+ result.Should().ContainKey("otel.resource.service.name");
+ result["otel.resource.service.name"].Should().Be("test-service");
+ result.Should().ContainKey("gauge_metric");
+ result["gauge_metric"].Should().Be(50.0);
+ result.Should().ContainKey("sum_metric");
+ result["sum_metric"].Should().Be(100.0);
+ result.Should().ContainKey("env");
+ result["env"].Should().Be("test");
+ }
+
+ [Fact]
+ public void TestMetricRecordCreation()
+ {
+ // Test metric record creation
+ var record = new MetricRecord
+ {
+ Name = "test_metric",
+ Unit = "Count",
+ Description = "Test description",
+ Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ Attributes = new Dictionary(),
+ Value = 42.0,
+ };
+
+ record.Should().NotBeNull();
+ record.Name.Should().Be("test_metric");
+ record.Unit.Should().Be("Count");
+ record.Description.Should().Be("Test description");
+ record.Value.Should().Be(42.0);
+ }
+
+ [Fact]
+ public async Task TestForceFlushWithPendingEvents()
+ {
+ // Test force flush functionality with pending events
+ await this.exporter.ForceFlushAsync(CancellationToken.None);
+
+ // Should complete without throwing
+ true.Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task TestShutdown()
+ {
+ // Test shutdown functionality
+ await this.exporter.ShutdownAsync(CancellationToken.None);
+
+ // Should complete without throwing
+ true.Should().BeTrue();
+ }
+
+ [Fact]
+ public void TestLogEventBatchCreation()
+ {
+ // Test LogEventBatch creation and functionality
+ var batch = new LogEventBatch();
+
+ batch.Should().NotBeNull();
+ batch.IsEmpty().Should().BeTrue();
+ batch.Size().Should().Be(0);
+ batch.ByteTotal.Should().Be(0);
+ }
+
+ [Fact]
+ public void TestHistogramMetricRecordData()
+ {
+ // Test histogram metric record data
+ var histogramData = new HistogramMetricRecordData
+ {
+ Count = 10,
+ Sum = 150.0,
+ Min = 5.0,
+ Max = 25.0,
+ };
+
+ histogramData.Should().NotBeNull();
+ histogramData.Count.Should().Be(10);
+ histogramData.Sum.Should().Be(150.0);
+ histogramData.Min.Should().Be(5.0);
+ histogramData.Max.Should().Be(25.0);
+ }
+
+ [Fact]
+ public void TestExponentialHistogramMetricRecordData()
+ {
+ // Test exponential histogram metric record data
+ var expHistogramData = new ExponentialHistogramMetricRecordData
+ {
+ Count = 8,
+ Sum = 64.0,
+ Min = 2.0,
+ Max = 32.0,
+ Values = new double[] { 1.0, 2.0, 4.0 },
+ Counts = new long[] { 1, 2, 5 },
+ };
+
+ expHistogramData.Should().NotBeNull();
+ expHistogramData.Count.Should().Be(8);
+ expHistogramData.Sum.Should().Be(64.0);
+ expHistogramData.Min.Should().Be(2.0);
+ expHistogramData.Max.Should().Be(32.0);
+ expHistogramData.Values.Should().HaveCount(3);
+ expHistogramData.Counts.Should().HaveCount(3);
+ }
+
+ [Fact]
+ public void TestCloudWatchMetricDefinition()
+ {
+ // Test CloudWatch metric definition
+ var metricDef = new MetricDefinition
+ {
+ Name = "test_metric",
+ Unit = "Count",
+ };
+
+ metricDef.Should().NotBeNull();
+ metricDef.Name.Should().Be("test_metric");
+ metricDef.Unit.Should().Be("Count");
+ }
+
+ [Fact]
+ public void TestEmfLogStructure()
+ {
+ // Test EMF log structure
+ var emfLog = new EmfLog();
+ emfLog["test_metric"] = 42.0;
+ emfLog["env"] = "test";
+
+ emfLog.Should().NotBeNull();
+ emfLog.Should().ContainKey("Version");
+ emfLog["Version"].Should().Be("1");
+ emfLog.Should().ContainKey("test_metric");
+ emfLog["test_metric"].Should().Be(42.0);
+ emfLog.Should().ContainKey("env");
+ emfLog["env"].Should().Be("test");
+ }
+
+ [Fact]
+ public void TestMetricRecordWithHistogramData()
+ {
+ // Test metric record with histogram data
+ var record = new MetricRecord
+ {
+ Name = "histogram_metric",
+ Unit = "ms",
+ Description = "Histogram description",
+ Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ Attributes = new Dictionary { { "region", "us-east-1" } },
+ HistogramData = new HistogramMetricRecordData
+ {
+ Count = 10,
+ Sum = 150.0,
+ Min = 5.0,
+ Max = 25.0,
+ },
+ };
+
+ record.Should().NotBeNull();
+ record.Name.Should().Be("histogram_metric");
+ record.HistogramData.Should().NotBeNull();
+ record.HistogramData.Count.Should().Be(10);
+ record.HistogramData.Sum.Should().Be(150.0);
+ record.Attributes.Should().ContainKey("region");
+ record.Attributes["region"].Should().Be("us-east-1");
+ }
+
+ [Fact]
+ public void TestMetricRecordWithExpHistogramData()
+ {
+ // Test metric record with exponential histogram data
+ var record = new MetricRecord
+ {
+ Name = "exp_histogram_metric",
+ Unit = "s",
+ Description = "Exponential histogram description",
+ Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ Attributes = new Dictionary { { "service", "api" } },
+ ExpHistogramData = new ExponentialHistogramMetricRecordData
+ {
+ Count = 8,
+ Sum = 64.0,
+ Min = 2.0,
+ Max = 32.0,
+ Values = new double[] { 1.0, 2.0, 4.0 },
+ Counts = new long[] { 1, 2, 5 },
+ },
+ };
+
+ record.Should().NotBeNull();
+ record.Name.Should().Be("exp_histogram_metric");
+ record.ExpHistogramData.Should().NotBeNull();
+ record.ExpHistogramData.Count.Should().Be(8);
+ record.ExpHistogramData.Sum.Should().Be(64.0);
+ record.Attributes.Should().ContainKey("service");
+ record.Attributes["service"].Should().Be("api");
+ }
+
+ [Fact]
+ public void TestAwsMetadata()
+ {
+ // Test AWS metadata structure
+ var awsMetadata = new AwsMetadata
+ {
+ Timestamp = 1234567890L,
+ CloudWatchMetrics = new[]
+ {
+ new CloudWatchMetric
+ {
+ Namespace = "TestNamespace",
+ Metrics = new[]
+ {
+ new MetricDefinition { Name = "test_metric", Unit = "Count" },
+ },
+ Dimensions = new[] { new[] { "env", "service" } },
+ },
+ },
+ };
+
+ awsMetadata.Should().NotBeNull();
+ awsMetadata.Timestamp.Should().Be(1234567890L);
+ awsMetadata.CloudWatchMetrics.Should().HaveCount(1);
+ awsMetadata.CloudWatchMetrics[0].Namespace.Should().Be("TestNamespace");
+ awsMetadata.CloudWatchMetrics[0].Metrics.Should().HaveCount(1);
+ awsMetadata.CloudWatchMetrics[0].Metrics[0].Name.Should().Be("test_metric");
+ }
+
+ [Fact]
+ public void TestLogEvent()
+ {
+ // Test log event structure
+ var logEvent = new LogEvent
+ {
+ Message = "test message",
+ Timestamp = 1234567890L,
+ };
+
+ logEvent.Should().NotBeNull();
+ logEvent.Message.Should().Be("test message");
+ logEvent.Timestamp.Should().Be(1234567890L);
+ }
+
+ [Fact]
+ public void TestCloudWatchMetric()
+ {
+ // Test CloudWatch metric structure
+ var cloudWatchMetric = new CloudWatchMetric
+ {
+ Namespace = "TestNamespace",
+ Metrics = new[]
+ {
+ new MetricDefinition { Name = "test_metric", Unit = "Count" },
+ },
+ Dimensions = new[] { new[] { "env" } },
+ };
+
+ cloudWatchMetric.Should().NotBeNull();
+ cloudWatchMetric.Namespace.Should().Be("TestNamespace");
+ cloudWatchMetric.Metrics.Should().HaveCount(1);
+ cloudWatchMetric.Dimensions.Should().HaveCount(1);
+ cloudWatchMetric.Dimensions[0].Should().Contain("env");
+ }
+
+ [Fact]
+ public void TestNormalizeTimestamp()
+ {
+ // Test timestamp normalization using reflection
+ var normalizeTimestampMethod = typeof(EmfExporterBase).GetMethod("NormalizeTimestamp", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+
+ var testTime = DateTimeOffset.UtcNow;
+ var result = normalizeTimestampMethod?.Invoke(this.exporter, new object[] { testTime });
+
+ result.Should().Be(testTime.ToUnixTimeMilliseconds());
+ }
+
+ [Fact]
+ public void TestGroupByAttributesAndTimestamp()
+ {
+ // Test grouping by attributes and timestamp using reflection
+ var groupByMethod = typeof(EmfExporterBase).GetMethod("GroupByAttributesAndTimestamp", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+
+ var record = new MetricRecord
+ {
+ Name = "test_metric",
+ Unit = "ms",
+ Description = "test description",
+ Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ Attributes = new Dictionary { { "env", "test" } },
+ };
+
+ var result = groupByMethod?.Invoke(this.exporter, new object[] { record });
+
+ result.Should().NotBeNull();
+
+ // The method returns a ValueTuple, check if we can access its properties
+ var resultType = result!.GetType();
+ resultType.Name.Should().Contain("ValueTuple");
+
+ // Use reflection to get the tuple items
+ var item1 = resultType.GetField("Item1")?.GetValue(result);
+ var item2 = resultType.GetField("Item2")?.GetValue(result);
+
+ item1.Should().BeOfType();
+ item2.Should().BeOfType();
+ item2.Should().Be(record.Timestamp);
+ }
+
+ [Fact]
+ public void TestConvertSumMethodExists()
+ {
+ // Test that the ConvertGaugeAndSum method exists and has correct signature
+ var convertMethod = typeof(EmfExporterBase).GetMethod("ConvertGaugeAndSum", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+
+ convertMethod.Should().NotBeNull();
+ convertMethod.Name.Should().Be("ConvertGaugeAndSum");
+ convertMethod.ReturnType.Should().Be(typeof(MetricRecord));
+
+ var parameters = convertMethod.GetParameters();
+ parameters.Should().HaveCount(2);
+ parameters[0].ParameterType.Name.Should().Be("Metric");
+ parameters[1].ParameterType.Name.Should().Contain("MetricPoint");
+ }
+
+ [Fact]
+ public void TestCreateEmfLogWithResource()
+ {
+ // Test EMF log creation with resource attributes using reflection
+ var createEmfLogMethod = typeof(EmfExporterBase).GetMethod("CreateEmfLog", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+
+ var gaugeRecord = new MetricRecord
+ {
+ Name = "gauge_metric",
+ Unit = "Count",
+ Description = "Gauge",
+ Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ Attributes = new Dictionary { { "env", "test" }, { "service", "api" } },
+ Value = 50.0,
+ };
+
+ var records = new List { gaugeRecord };
+ var resource = new Resource(new Dictionary { { "service.name", "test-service" }, { "service.version", "1.0.0" } });
+ var timestamp = 1234567890L;
+
+ var result = createEmfLogMethod?.Invoke(this.exporter, new object[] { records, resource, timestamp }) as EmfLog;
+
+ result.Should().NotBeNull();
+ result.Should().ContainKey("_aws");
+ result.Should().ContainKey("Version");
+ result["Version"].Should().Be("1");
+ result.Should().ContainKey("otel.resource.service.name");
+ result["otel.resource.service.name"].Should().Be("test-service");
+ result.Should().ContainKey("otel.resource.service.version");
+ result["otel.resource.service.version"].Should().Be("1.0.0");
+ result.Should().ContainKey("gauge_metric");
+ result["gauge_metric"].Should().Be(50.0);
+ result.Should().ContainKey("env");
+ result["env"].Should().Be("test");
+ result.Should().ContainKey("service");
+ result["service"].Should().Be("api");
+ }
+
+ [Fact]
+ public void TestCreateEmfLogWithoutDimensions()
+ {
+ // Test EMF log creation with metrics but no dimensions
+ var createEmfLogMethod = typeof(EmfExporterBase).GetMethod("CreateEmfLog", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+
+ var gaugeRecord = new MetricRecord
+ {
+ Name = "gauge_metric",
+ Unit = "Count",
+ Description = "Gauge",
+ Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ Attributes = new Dictionary(), // Empty attributes (no dimensions)
+ Value = 75.0,
+ };
+
+ var records = new List { gaugeRecord };
+ var resource = new Resource(new Dictionary { { "service.name", "test-service" }, { "service.version", "1.0.0" } });
+ var timestamp = 1234567890L;
+
+ var result = createEmfLogMethod?.Invoke(this.exporter, new object[] { records, resource, timestamp }) as EmfLog;
+
+ result.Should().NotBeNull();
+ result.Should().ContainKey("_aws");
+ result.Should().ContainKey("Version");
+ result["Version"].Should().Be("1");
+ result.Should().ContainKey("otel.resource.service.name");
+ result["otel.resource.service.name"].Should().Be("test-service");
+ result.Should().ContainKey("otel.resource.service.version");
+ result["otel.resource.service.version"].Should().Be("1.0.0");
+ result.Should().ContainKey("gauge_metric");
+ result["gauge_metric"].Should().Be(75.0);
+ }
+
+ [Fact]
+ public void TestCreateEmfLogSkipsEmptyMetricNames()
+ {
+ // Test that EMF log creation skips records with empty metric names
+ var createEmfLogMethod = typeof(EmfExporterBase).GetMethod("CreateEmfLog", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+
+ var recordWithoutName = new MetricRecord
+ {
+ Name = string.Empty,
+ Unit = string.Empty,
+ Description = string.Empty,
+ Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ Attributes = new Dictionary { { "key", "value" } },
+ Value = 10.0,
+ };
+
+ var validRecord = new MetricRecord
+ {
+ Name = "valid_metric",
+ Unit = "Count",
+ Description = "Valid metric",
+ Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ Attributes = new Dictionary { { "key", "value" } },
+ Value = 20.0,
+ };
+
+ var records = new List { recordWithoutName, validRecord };
+ var resource = new Resource(new Dictionary { { "service.name", "test-service" } });
+ var timestamp = 1234567890L;
+
+ var result = createEmfLogMethod?.Invoke(this.exporter, new object[] { records, resource, timestamp }) as EmfLog;
+
+ result.Should().NotBeNull();
+ result.Should().ContainKey("valid_metric");
+ result["valid_metric"].Should().Be(20.0);
+ result.Should().NotContainKey(string.Empty); // Empty name should be skipped
+ }
+
+ [Fact]
+ public async Task TestExportSuccess()
+ {
+ // Test successful export - simplified test since we can't easily create ResourceMetrics
+ await this.exporter.ForceFlushAsync(CancellationToken.None);
+
+ // Test passes if no exception is thrown
+ true.Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task TestExportFailure()
+ {
+ // Test export failure handling - simplified test
+ await this.exporter.ShutdownAsync(CancellationToken.None);
+
+ // Test passes if no exception is thrown
+ true.Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task TestSendLogEvent()
+ {
+ // Test that sendLogEvent method exists and can be called
+ var sendLogEventMethod = typeof(AwsCloudWatchEmfExporter).GetMethod("SendLogEvent", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+
+ var logEvent = new LogEvent
+ {
+ Message = "test message",
+ Timestamp = 1234567890L,
+ };
+
+ // Should not throw an exception
+ var task = sendLogEventMethod?.Invoke(this.exporter, new object[] { logEvent }) as Task;
+ if (task != null)
+ {
+ await task;
+ }
+
+ // Test passes if no exception is thrown
+ true.Should().BeTrue();
+ }
+
+
+}
\ No newline at end of file
diff --git a/test/AWS.Distro.OpenTelemetry.AutoInstrumentation.Tests/Exporters/Aws/Metrics/CloudWatchLogsClientTest.cs b/test/AWS.Distro.OpenTelemetry.AutoInstrumentation.Tests/Exporters/Aws/Metrics/CloudWatchLogsClientTest.cs
new file mode 100644
index 00000000..d2d89b42
--- /dev/null
+++ b/test/AWS.Distro.OpenTelemetry.AutoInstrumentation.Tests/Exporters/Aws/Metrics/CloudWatchLogsClientTest.cs
@@ -0,0 +1,457 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+using System;
+using System.Threading.Tasks;
+using Amazon.CloudWatchLogs;
+using Amazon.CloudWatchLogs.Model;
+using AWS.Distro.OpenTelemetry.AutoInstrumentation.Exporters.Aws.Metrics;
+using FluentAssertions;
+using Moq;
+using Xunit;
+
+namespace AWS.Distro.OpenTelemetry.AutoInstrumentation.Tests;
+
+[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1600:Elements should be documented", Justification = "Tests")]
+public class CloudWatchLogsClientTest
+{
+ private readonly Mock mockLogsClient;
+ private readonly CloudWatchLogsClient logClient;
+
+ public CloudWatchLogsClientTest()
+ {
+ mockLogsClient = new Mock();
+
+ // Setup default mock responses
+ mockLogsClient.Setup(x => x.CreateLogGroupAsync(It.IsAny(), default))
+ .ReturnsAsync(new CreateLogGroupResponse());
+ mockLogsClient.Setup(x => x.CreateLogStreamAsync(It.IsAny(), default))
+ .ReturnsAsync(new CreateLogStreamResponse());
+ mockLogsClient.Setup(x => x.PutLogEventsAsync(It.IsAny(), default))
+ .ReturnsAsync(new PutLogEventsResponse { NextSequenceToken = "12345" });
+
+ // Create client with mocked AWS client using reflection
+ logClient = new CloudWatchLogsClient("test-log-group");
+ var logsClientField = typeof(CloudWatchLogsClient).GetField("_logsClient", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+ logsClientField?.SetValue(logClient, mockLogsClient.Object);
+ }
+
+ [Fact]
+ public void TestInitialization()
+ {
+ var logGroupField = typeof(CloudWatchLogsClient).GetField("_logGroupName", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+ var logStreamField = typeof(CloudWatchLogsClient).GetField("_logStreamName", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+
+ logGroupField?.GetValue(logClient).Should().Be("test-log-group");
+ var logStreamName = logStreamField?.GetValue(logClient) as string;
+ logStreamName.Should().NotBeNullOrEmpty();
+ logStreamName.Should().StartWith("otel-dotnet-");
+ }
+
+ [Fact]
+ public void TestInitializationWithCustomParams()
+ {
+ var customClient = new CloudWatchLogsClient("custom-log-group", "custom-stream");
+
+ var logGroupField = typeof(CloudWatchLogsClient).GetField("_logGroupName", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+ var logStreamField = typeof(CloudWatchLogsClient).GetField("_logStreamName", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+
+ logGroupField?.GetValue(customClient).Should().Be("custom-log-group");
+ logStreamField?.GetValue(customClient).Should().Be("custom-stream");
+ }
+
+ [Fact]
+ public void TestGenerateLogStreamName()
+ {
+ var generateMethod = typeof(CloudWatchLogsClient).GetMethod("GenerateLogStreamName", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
+
+ var name1 = generateMethod?.Invoke(null, null) as string;
+ var name2 = generateMethod?.Invoke(null, null) as string;
+
+ name1.Should().NotBe(name2);
+ name1.Should().StartWith("otel-dotnet-");
+ name2.Should().StartWith("otel-dotnet-");
+ name1.Should().HaveLength("otel-dotnet-".Length + 8);
+ }
+
+ [Fact]
+ public async Task TestEnsureLogGroupExists()
+ {
+ var ensureMethod = typeof(CloudWatchLogsClient).GetMethod("EnsureLogGroupExistsAsync", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+
+ await (Task)ensureMethod?.Invoke(logClient, null)!;
+
+ mockLogsClient.Verify(x => x.CreateLogGroupAsync(It.IsAny(), default), Times.Once);
+ }
+
+ [Fact]
+ public async Task TestEnsureLogGroupExistsAlreadyExists()
+ {
+ mockLogsClient.Setup(x => x.CreateLogGroupAsync(It.IsAny(), default))
+ .ThrowsAsync(new ResourceAlreadyExistsException("Already exists"));
+
+ var ensureMethod = typeof(CloudWatchLogsClient).GetMethod("EnsureLogGroupExistsAsync", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+
+ await (Task)ensureMethod?.Invoke(logClient, null)!;
+
+ mockLogsClient.Verify(x => x.CreateLogGroupAsync(It.IsAny(), default), Times.Once);
+ }
+
+ [Fact]
+ public async Task TestEnsureLogGroupExistsFailure()
+ {
+ mockLogsClient.Setup(x => x.CreateLogGroupAsync(It.IsAny(), default))
+ .ThrowsAsync(new AmazonCloudWatchLogsException("Access denied"));
+
+ var ensureMethod = typeof(CloudWatchLogsClient).GetMethod("EnsureLogGroupExistsAsync", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+
+ await Assert.ThrowsAsync(async () =>
+ await (Task)ensureMethod?.Invoke(logClient, null)!);
+ }
+
+ [Fact]
+ public void TestCreateEventBatch()
+ {
+ var createMethod = typeof(CloudWatchLogsClient).GetMethod("CreateEventBatch", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
+
+ var batch = createMethod?.Invoke(null, null) as LogEventBatch;
+
+ batch.Should().NotBeNull();
+ batch!.LogEvents.Should().BeEmpty();
+ batch.ByteTotal.Should().Be(0);
+ batch.MinTimestampMs.Should().Be(0);
+ batch.MaxTimestampMs.Should().Be(0);
+ batch.CreatedTimestampMs.Should().BeGreaterThan(0);
+ }
+
+ [Fact]
+ public void TestValidateLogEventValid()
+ {
+ var validateMethod = typeof(CloudWatchLogsClient).GetMethod("ValidateLogEvent", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+
+ var logEvent = new InputLogEvent
+ {
+ Message = "test message",
+ Timestamp = DateTime.UtcNow
+ };
+
+ var result = (bool)validateMethod?.Invoke(logClient, new object[] { logEvent })!;
+ result.Should().BeTrue();
+ }
+
+ [Fact]
+ public void TestValidateLogEventEmptyMessage()
+ {
+ var validateMethod = typeof(CloudWatchLogsClient).GetMethod("ValidateLogEvent", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+
+ var logEvent = new InputLogEvent
+ {
+ Message = "",
+ Timestamp = DateTime.UtcNow
+ };
+
+ var result = (bool)validateMethod?.Invoke(logClient, new object[] { logEvent })!;
+ result.Should().BeFalse();
+ }
+
+ [Fact]
+ public void TestValidateLogEventWhitespaceMessage()
+ {
+ var validateMethod = typeof(CloudWatchLogsClient).GetMethod("ValidateLogEvent", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+
+ var logEvent = new InputLogEvent
+ {
+ Message = " ",
+ Timestamp = DateTime.UtcNow
+ };
+
+ var result = (bool)validateMethod?.Invoke(logClient, new object[] { logEvent })!;
+ result.Should().BeFalse();
+ }
+
+ [Fact]
+ public void TestValidateLogEventOversizedMessage()
+ {
+ var validateMethod = typeof(CloudWatchLogsClient).GetMethod("ValidateLogEvent", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+
+ var largeMessage = new string('x', CloudWatchLogsClient.CwMaxEventPayloadBytes + 100);
+ var logEvent = new InputLogEvent
+ {
+ Message = largeMessage,
+ Timestamp = DateTime.UtcNow
+ };
+
+ var result = (bool)validateMethod?.Invoke(logClient, new object[] { logEvent })!;
+ result.Should().BeTrue();
+ logEvent.Message.Length.Should().BeLessThan(largeMessage.Length);
+ logEvent.Message.Should().EndWith(CloudWatchLogsClient.CwTruncatedSuffix);
+ }
+
+ [Fact]
+ public void TestValidateLogEventOldTimestamp()
+ {
+ var validateMethod = typeof(CloudWatchLogsClient).GetMethod("ValidateLogEvent", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+
+ var oldTimestamp = DateTime.UtcNow.AddDays(-15);
+ var logEvent = new InputLogEvent
+ {
+ Message = "test message",
+ Timestamp = oldTimestamp
+ };
+
+ var result = (bool)validateMethod?.Invoke(logClient, new object[] { logEvent })!;
+ result.Should().BeFalse();
+ }
+
+ [Fact]
+ public void TestValidateLogEventFutureTimestamp()
+ {
+ var validateMethod = typeof(CloudWatchLogsClient).GetMethod("ValidateLogEvent", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+
+ var futureTimestamp = DateTime.UtcNow.AddHours(3);
+ var logEvent = new InputLogEvent
+ {
+ Message = "test message",
+ Timestamp = futureTimestamp
+ };
+
+ var result = (bool)validateMethod?.Invoke(logClient, new object[] { logEvent })!;
+ result.Should().BeFalse();
+ }
+
+ [Fact]
+ public void TestEventBatchExceedsLimitByCount()
+ {
+ var exceedsMethod = typeof(CloudWatchLogsClient).GetMethod("EventBatchExceedsLimit", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
+
+ var batch = new LogEventBatch();
+ for (int i = 0; i < CloudWatchLogsClient.CwMaxRequestEventCount; i++)
+ {
+ batch.LogEvents.Add(new InputLogEvent { Message = "test" });
+ }
+
+ var result = (bool)exceedsMethod?.Invoke(null, new object[] { batch, 100 })!;
+ result.Should().BeTrue();
+ }
+
+ [Fact]
+ public void TestEventBatchExceedsLimitBySize()
+ {
+ var exceedsMethod = typeof(CloudWatchLogsClient).GetMethod("EventBatchExceedsLimit", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
+
+ var batch = new LogEventBatch();
+ batch.ByteTotal = CloudWatchLogsClient.CwMaxRequestPayloadBytes - 50;
+
+ var result = (bool)exceedsMethod?.Invoke(null, new object[] { batch, 100 })!;
+ result.Should().BeTrue();
+ }
+
+ [Fact]
+ public void TestEventBatchWithinLimits()
+ {
+ var exceedsMethod = typeof(CloudWatchLogsClient).GetMethod("EventBatchExceedsLimit", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
+
+ var batch = new LogEventBatch();
+ for (int i = 0; i < 10; i++)
+ {
+ batch.LogEvents.Add(new InputLogEvent { Message = "test" });
+ }
+ batch.ByteTotal = 1000;
+
+ var result = (bool)exceedsMethod?.Invoke(null, new object[] { batch, 100 })!;
+ result.Should().BeFalse();
+ }
+
+ [Fact]
+ public void TestIsBatchActiveNewBatch()
+ {
+ var isActiveMethod = typeof(CloudWatchLogsClient).GetMethod("IsBatchActive", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
+
+ var batch = new LogEventBatch();
+ var currentTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
+
+ var result = (bool)isActiveMethod?.Invoke(null, new object[] { batch, currentTime })!;
+ result.Should().BeTrue();
+ }
+
+ [Fact]
+ public void TestIsBatchActive24HourSpan()
+ {
+ var isActiveMethod = typeof(CloudWatchLogsClient).GetMethod("IsBatchActive", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
+
+ var batch = new LogEventBatch();
+ var currentTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
+ batch.MinTimestampMs = currentTime;
+ batch.MaxTimestampMs = currentTime;
+
+ var futureTimestamp = currentTime + (25 * 60 * 60 * 1000);
+
+ var result = (bool)isActiveMethod?.Invoke(null, new object[] { batch, futureTimestamp })!;
+ result.Should().BeFalse();
+ }
+
+ [Fact]
+ public void TestAppendToBatch()
+ {
+ var batch = new LogEventBatch();
+ var currentTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
+ var logEvent = new InputLogEvent
+ {
+ Message = "test message",
+ Timestamp = DateTimeOffset.FromUnixTimeMilliseconds(currentTime).UtcDateTime
+ };
+ var eventSize = 100;
+
+ batch.AddEvent(logEvent, eventSize);
+
+ batch.LogEvents.Count.Should().Be(1);
+ batch.ByteTotal.Should().Be(eventSize);
+ batch.MinTimestampMs.Should().Be(currentTime);
+ batch.MaxTimestampMs.Should().Be(currentTime);
+ }
+
+ [Fact]
+ public void TestSortLogEvents()
+ {
+ var sortMethod = typeof(CloudWatchLogsClient).GetMethod("SortLogEvents", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
+
+ var batch = new LogEventBatch();
+ var currentTime = DateTime.UtcNow;
+
+ var events = new[]
+ {
+ new InputLogEvent { Message = "third", Timestamp = currentTime.AddSeconds(2) },
+ new InputLogEvent { Message = "first", Timestamp = currentTime },
+ new InputLogEvent { Message = "second", Timestamp = currentTime.AddSeconds(1) }
+ };
+
+ batch.LogEvents.AddRange(events);
+ sortMethod?.Invoke(null, new object[] { batch });
+
+ batch.LogEvents[0].Message.Should().Be("first");
+ batch.LogEvents[1].Message.Should().Be("second");
+ batch.LogEvents[2].Message.Should().Be("third");
+ }
+
+ [Fact]
+ public async Task TestFlushPendingEvents()
+ {
+ var eventBatchField = typeof(CloudWatchLogsClient).GetField("_eventBatch", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+
+ var batch = new LogEventBatch();
+ batch.AddEvent(new InputLogEvent { Message = "test", Timestamp = DateTime.UtcNow }, 10);
+ eventBatchField?.SetValue(logClient, batch);
+
+ await logClient.FlushPendingEventsAsync();
+
+ mockLogsClient.Verify(x => x.PutLogEventsAsync(It.IsAny(), default), Times.Once);
+ }
+
+ [Fact]
+ public async Task TestFlushPendingEventsNoPendingEvents()
+ {
+ var eventBatchField = typeof(CloudWatchLogsClient).GetField("_eventBatch", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+ eventBatchField?.SetValue(logClient, null);
+
+ await logClient.FlushPendingEventsAsync();
+
+ mockLogsClient.Verify(x => x.PutLogEventsAsync(It.IsAny(), default), Times.Never);
+ }
+
+ [Fact]
+ public async Task TestSendLogEvent()
+ {
+ var logEvent = new AWS.Distro.OpenTelemetry.AutoInstrumentation.Exporters.Aws.Metrics.LogEvent { Message = "test message", Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() };
+
+ await this.logClient.SendLogEventAsync(logEvent);
+
+ var eventBatchField = typeof(CloudWatchLogsClient).GetField("_eventBatch", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+ var batch = eventBatchField?.GetValue(this.logClient) as LogEventBatch;
+
+ batch.Should().NotBeNull();
+ batch!.Size().Should().Be(1);
+
+ // Verify the mock was not called since we're just batching
+ this.mockLogsClient.Verify(x => x.PutLogEventsAsync(It.IsAny(), default), Times.Never);
+ }
+
+ [Fact]
+ public async Task TestSendLogBatchWithResourceNotFound()
+ {
+ var sendBatchMethod = typeof(CloudWatchLogsClient).GetMethod("SendLogBatchAsync", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+
+ var batch = new LogEventBatch();
+ batch.AddEvent(new InputLogEvent { Message = "test message", Timestamp = DateTime.UtcNow }, 10);
+
+ mockLogsClient.SetupSequence(x => x.PutLogEventsAsync(It.IsAny(), default))
+ .ThrowsAsync(new ResourceNotFoundException("Not found"))
+ .ReturnsAsync(new PutLogEventsResponse { NextSequenceToken = "12345" });
+
+ var result = await (Task)sendBatchMethod?.Invoke(logClient, new object[] { batch })!;
+
+ result.Should().NotBeNull();
+ result!.NextSequenceToken.Should().Be("12345");
+ mockLogsClient.Verify(x => x.CreateLogGroupAsync(It.IsAny(), default), Times.Once);
+ mockLogsClient.Verify(x => x.CreateLogStreamAsync(It.IsAny(), default), Times.Once);
+ mockLogsClient.Verify(x => x.PutLogEventsAsync(It.IsAny(), default), Times.Exactly(2));
+ }
+
+ [Fact]
+ public async Task TestSendLogBatchEmptyBatch()
+ {
+ var sendBatchMethod = typeof(CloudWatchLogsClient).GetMethod("SendLogBatchAsync", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+
+ var batch = new LogEventBatch();
+
+ var result = await (Task)sendBatchMethod?.Invoke(logClient, new object[] { batch })!;
+
+ result.Should().BeNull();
+ mockLogsClient.Verify(x => x.PutLogEventsAsync(It.IsAny(), default), Times.Never);
+ }
+
+ [Fact]
+ public async Task TestSendLogEventWithInvalidEvent()
+ {
+ var logEvent = new AWS.Distro.OpenTelemetry.AutoInstrumentation.Exporters.Aws.Metrics.LogEvent { Message = string.Empty, Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() };
+
+ await logClient.SendLogEventAsync(logEvent);
+
+ mockLogsClient.Verify(x => x.PutLogEventsAsync(It.IsAny(), default), Times.Never);
+ }
+
+ [Fact]
+ public void TestLogEventBatchClear()
+ {
+ var batch = new LogEventBatch();
+ batch.AddEvent(new InputLogEvent { Message = "test", Timestamp = DateTime.UtcNow }, 100);
+
+ batch.IsEmpty().Should().BeFalse();
+ batch.Size().Should().Be(1);
+
+ batch.Clear();
+ batch.IsEmpty().Should().BeTrue();
+ batch.Size().Should().Be(0);
+ batch.ByteTotal.Should().Be(0);
+ }
+
+ [Fact]
+ public void TestLogEventBatchTimestampTracking()
+ {
+ var batch = new LogEventBatch();
+ var currentTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
+
+ batch.AddEvent(new InputLogEvent { Message = "first", Timestamp = DateTimeOffset.FromUnixTimeMilliseconds(currentTime).UtcDateTime }, 10);
+ batch.MinTimestampMs.Should().Be(currentTime);
+ batch.MaxTimestampMs.Should().Be(currentTime);
+
+ var earlierTime = currentTime - 1000;
+ batch.AddEvent(new InputLogEvent { Message = "earlier", Timestamp = DateTimeOffset.FromUnixTimeMilliseconds(earlierTime).UtcDateTime }, 10);
+ batch.MinTimestampMs.Should().Be(earlierTime);
+ batch.MaxTimestampMs.Should().Be(currentTime);
+
+ var laterTime = currentTime + 1000;
+ batch.AddEvent(new InputLogEvent { Message = "later", Timestamp = DateTimeOffset.FromUnixTimeMilliseconds(laterTime).UtcDateTime }, 10);
+ batch.MinTimestampMs.Should().Be(earlierTime);
+ batch.MaxTimestampMs.Should().Be(laterTime);
+ }
+}
\ No newline at end of file
diff --git a/test/AWS.Distro.OpenTelemetry.AutoInstrumentation.Tests/Exporters/Aws/Metrics/ConsoleEmfExporterTest.cs b/test/AWS.Distro.OpenTelemetry.AutoInstrumentation.Tests/Exporters/Aws/Metrics/ConsoleEmfExporterTest.cs
new file mode 100644
index 00000000..da977a0a
--- /dev/null
+++ b/test/AWS.Distro.OpenTelemetry.AutoInstrumentation.Tests/Exporters/Aws/Metrics/ConsoleEmfExporterTest.cs
@@ -0,0 +1,117 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+using System;
+using System.IO;
+using System.Text.Json;
+using System.Threading.Tasks;
+using AWS.Distro.OpenTelemetry.AutoInstrumentation.Exporters.Aws.Metrics;
+using FluentAssertions;
+using Xunit;
+
+namespace AWS.Distro.OpenTelemetry.AutoInstrumentation.Tests;
+
+[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1600:Elements should be documented", Justification = "Tests")]
+public class ConsoleEmfExporterTest
+{
+ [Fact]
+ public void TestNamespaceInitialization()
+ {
+ // Test default namespace
+ var defaultExporter = new ConsoleEmfExporter();
+ var namespaceField = typeof(EmfExporterBase).GetField("_namespace", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+ namespaceField?.GetValue(defaultExporter).Should().Be("default");
+
+ // Test custom namespace
+ var customExporter = new ConsoleEmfExporter("CustomNamespace");
+ namespaceField?.GetValue(customExporter).Should().Be("CustomNamespace");
+
+ // Test null namespace (passes null as-is since constructor doesn't handle null)
+ var nullNamespaceExporter = new ConsoleEmfExporter(null!);
+ namespaceField?.GetValue(nullNamespaceExporter).Should().BeNull();
+ }
+
+ [Fact]
+ public async Task TestSendLogEvent()
+ {
+ var exporter = new ConsoleEmfExporter();
+
+ // Create a test EMF log structure
+ var testMessage = new EmfLog
+ {
+ ["_aws"] = new AwsMetadata
+ {
+ Timestamp = 1640995200000,
+ CloudWatchMetrics = new[]
+ {
+ new CloudWatchMetric
+ {
+ Namespace = "TestNamespace",
+ Dimensions = new[] { new[] { "Service" } },
+ Metrics = new[]
+ {
+ new MetricDefinition
+ {
+ Name = "TestMetric",
+ Unit = "Count"
+ }
+ }
+ }
+ }
+ },
+ ["Service"] = "test-service",
+ ["TestMetric"] = 42
+ };
+
+ var logEvent = new LogEvent
+ {
+ Message = JsonSerializer.Serialize(testMessage),
+ Timestamp = 1640995200000
+ };
+
+ // Capture console output
+ var originalOut = Console.Out;
+ using var stringWriter = new StringWriter();
+ Console.SetOut(stringWriter);
+
+ try
+ {
+ // Call the method using reflection to access protected method
+ var sendLogEventMethod = typeof(ConsoleEmfExporter).GetMethod("SendLogEventAsync", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+ await (Task)sendLogEventMethod?.Invoke(exporter, new object[] { logEvent })!;
+
+ // Verify the message was printed to console
+ var output = stringWriter.ToString().Trim();
+ output.Should().Be(logEvent.Message);
+
+ // Verify the content of the logged message
+ var loggedMessage = JsonSerializer.Deserialize(output);
+ loggedMessage.Should().NotBeNull();
+ loggedMessage!.Should().ContainKey("Service");
+ loggedMessage.Should().ContainKey("TestMetric");
+ loggedMessage.Should().ContainKey("_aws");
+ }
+ finally
+ {
+ Console.SetOut(originalOut);
+ }
+ }
+
+ [Fact]
+ public async Task TestForceFlushAsync()
+ {
+ var exporter = new ConsoleEmfExporter();
+
+ // Should complete without throwing
+ await exporter.ForceFlushAsync(default);
+ }
+
+ [Fact]
+ public async Task TestShutdownAsync()
+ {
+ var exporter = new ConsoleEmfExporter();
+
+ // Should complete without throwing
+ await exporter.ShutdownAsync(default);
+ }
+}
\ No newline at end of file