Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

import com.amazonaws.services.lambda.runtime.Context;
import java.time.Instant;
import java.util.function.Consumer;

import software.amazon.lambda.powertools.metrics.model.DimensionSet;
import software.amazon.lambda.powertools.metrics.model.MetricResolution;
Expand Down Expand Up @@ -162,7 +163,15 @@ default void captureColdStartMetric() {
}

/**
* Flush a single metric with custom dimensions. This creates a separate metrics context
* Flush a separate metrics context that inherits the namespace, dimensions and metadata. This creates a separate metrics context
* that doesn't affect the default metrics context.
*
* @param metricsConsumer the consumer to use to edit the metrics instance (e.g. add metrics, override namespace) before flushing
*/
void flushMetrics(Consumer<Metrics> metricsConsumer);

/**
* Flush a single metric with custom namespace and dimensions. This creates a separate metrics context
* that doesn't affect the default metrics context.
*
* @param name the name of the metric
Expand All @@ -171,10 +180,17 @@ default void captureColdStartMetric() {
* @param namespace the namespace for the metric
* @param dimensions custom dimensions for this metric (optional)
*/
void flushSingleMetric(String name, double value, MetricUnit unit, String namespace, DimensionSet dimensions);
default void flushSingleMetric(String name, double value, MetricUnit unit, String namespace, DimensionSet dimensions) {
flushMetrics(metrics -> {
metrics.setNamespace(namespace);
metrics.setDefaultDimensions(dimensions);
metrics.addMetric(name, value, unit);
});

}

/**
* Flush a single metric with custom dimensions. This creates a separate metrics context
* Flush a single metric with custom namespace. This creates a separate metrics context
* that doesn't affect the default metrics context.
*
* @param name the name of the metric
Expand All @@ -183,6 +199,9 @@ default void captureColdStartMetric() {
* @param namespace the namespace for the metric
*/
default void flushSingleMetric(String name, double value, MetricUnit unit, String namespace) {
flushSingleMetric(name, value, unit, namespace, null);
flushMetrics(metrics -> {
metrics.setNamespace(namespace);
metrics.addMetric(name, value, unit);
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,14 @@

package software.amazon.lambda.powertools.metrics.internal;

import static software.amazon.lambda.powertools.common.internal.LambdaHandlerProcessor.getXrayTraceId;
import static software.amazon.lambda.powertools.common.internal.LambdaHandlerProcessor.isColdStart;

import java.time.Instant;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
Expand All @@ -43,16 +43,15 @@
*/
public class EmfMetricsLogger implements Metrics {
private static final Logger LOGGER = LoggerFactory.getLogger(EmfMetricsLogger.class);
private static final String TRACE_ID_PROPERTY = "xray_trace_id";
private static final String REQUEST_ID_PROPERTY = "function_request_id";
private static final String COLD_START_METRIC = "ColdStart";
private static final String METRICS_DISABLED_ENV_VAR = "POWERTOOLS_METRICS_DISABLED";

private final software.amazon.cloudwatchlogs.emf.logger.MetricsLogger emfLogger;
private final EnvironmentProvider environmentProvider;
private AtomicBoolean raiseOnEmptyMetrics = new AtomicBoolean(false);
private final AtomicBoolean raiseOnEmptyMetrics = new AtomicBoolean(false);
private String namespace;
private Map<String, String> defaultDimensions = new HashMap<>();
private final Map<String, Object> properties = new HashMap<>();
private final AtomicBoolean hasMetrics = new AtomicBoolean(false);

public EmfMetricsLogger(EnvironmentProvider environmentProvider, MetricsContext metricsContext) {
Expand All @@ -79,8 +78,6 @@ public void addDimension(software.amazon.lambda.powertools.metrics.model.Dimensi
dimensionSet.getDimensions().forEach((key, val) -> {
try {
emfDimensionSet.addDimension(key, val);
// Update our local copy of default dimensions
defaultDimensions.put(key, val);
} catch (Exception e) {
// Ignore dimension errors
}
Expand All @@ -91,7 +88,8 @@ public void addDimension(software.amazon.lambda.powertools.metrics.model.Dimensi

@Override
public void addMetadata(String key, Object value) {
emfLogger.putMetadata(key, value);
emfLogger.putProperty(key, value);
properties.put(key, value);
}

@Override
Expand Down Expand Up @@ -173,45 +171,13 @@ public void flush() {
public void captureColdStartMetric(Context context,
software.amazon.lambda.powertools.metrics.model.DimensionSet dimensions) {
if (isColdStart()) {
if (isMetricsDisabled()) {
LOGGER.debug("Metrics are disabled, skipping cold start metric capture");
return;
}

Validator.validateNamespace(namespace);

software.amazon.cloudwatchlogs.emf.logger.MetricsLogger coldStartLogger = new software.amazon.cloudwatchlogs.emf.logger.MetricsLogger();

try {
coldStartLogger.setNamespace(namespace);
} catch (Exception e) {
LOGGER.error("Namespace cannot be set for cold start metrics due to an error in EMF", e);
}

coldStartLogger.putMetric(COLD_START_METRIC, 1, Unit.COUNT);

// Set dimensions if provided
if (dimensions != null) {
DimensionSet emfDimensionSet = new DimensionSet();
dimensions.getDimensions().forEach((key, val) -> {
try {
emfDimensionSet.addDimension(key, val);
} catch (Exception e) {
// Ignore dimension errors
}
});
coldStartLogger.setDimensions(emfDimensionSet);
}

// Add request ID from context if available
if (context != null && context.getAwsRequestId() != null) {
coldStartLogger.putProperty(REQUEST_ID_PROPERTY, context.getAwsRequestId());
}

// Add trace ID using the standard logic
getXrayTraceId().ifPresent(traceId -> coldStartLogger.putProperty(TRACE_ID_PROPERTY, traceId));

coldStartLogger.flush();
flushMetrics(metrics -> {
MetricsUtils.addRequestIdAndXrayTraceIdIfAvailable(context, metrics);
if (dimensions != null) {
metrics.setDefaultDimensions(dimensions);
}
metrics.addMetric(COLD_START_METRIC, 1, MetricUnit.COUNT);
});
}
}

Expand All @@ -221,43 +187,24 @@ public void captureColdStartMetric(software.amazon.lambda.powertools.metrics.mod
}

@Override
public void flushSingleMetric(String name, double value, MetricUnit unit, String namespace,
software.amazon.lambda.powertools.metrics.model.DimensionSet dimensions) {
public void flushMetrics(Consumer<Metrics> metricsConsumer) {
if (isMetricsDisabled()) {
LOGGER.debug("Metrics are disabled, skipping single metric flush");
return;
}

Validator.validateNamespace(namespace);

// Create a new logger for this single metric
software.amazon.cloudwatchlogs.emf.logger.MetricsLogger singleMetricLogger = new software.amazon.cloudwatchlogs.emf.logger.MetricsLogger(
environmentProvider);

try {
singleMetricLogger.setNamespace(namespace);
} catch (Exception e) {
LOGGER.error("Namespace cannot be set for single metric due to an error in EMF", e);
// Create a new instance, inheriting namespace/dimensions state
EmfMetricsLogger metrics = new EmfMetricsLogger(environmentProvider, new MetricsContext());
if (namespace != null) {
metrics.setNamespace(this.namespace);
}

// Add the metric
singleMetricLogger.putMetric(name, value, convertUnit(unit));

// Set dimensions if provided
if (dimensions != null) {
DimensionSet emfDimensionSet = new DimensionSet();
dimensions.getDimensions().forEach((key, val) -> {
try {
emfDimensionSet.addDimension(key, val);
} catch (Exception e) {
// Ignore dimension errors
}
});
singleMetricLogger.setDimensions(emfDimensionSet);
if (!defaultDimensions.isEmpty()) {
metrics.setDefaultDimensions(software.amazon.lambda.powertools.metrics.model.DimensionSet.of(defaultDimensions));
}
properties.forEach(metrics::addMetadata);

metricsConsumer.accept(metrics);

// Flush the metric
singleMetricLogger.flush();
metrics.flush();
}

private boolean isMetricsDisabled() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,6 @@

@Aspect
public class LambdaMetricsAspect {
public static final String TRACE_ID_PROPERTY = "xray_trace_id";
public static final String REQUEST_ID_PROPERTY = "function_request_id";
private static final String SERVICE_DIMENSION = "Service";
private static final String FUNCTION_NAME_ENV_VAR = "POWERTOOLS_METRICS_FUNCTION_NAME";

Expand Down Expand Up @@ -90,11 +88,10 @@ public Object around(ProceedingJoinPoint pjp,

metricsInstance.setRaiseOnEmptyMetrics(metrics.raiseOnEmptyMetrics());

// Add trace ID metadata if available
LambdaHandlerProcessor.getXrayTraceId()
.ifPresent(traceId -> metricsInstance.addMetadata(TRACE_ID_PROPERTY, traceId));
Context extractedContext = extractContext(pjp);
MetricsUtils.addRequestIdAndXrayTraceIdIfAvailable(extractedContext, metricsInstance);

captureColdStartMetricIfEnabled(extractContext(pjp), metrics);
captureColdStartMetricIfEnabled(extractedContext, metrics);

try {
return pjp.proceed(proceedArgs);
Expand All @@ -107,40 +104,36 @@ public Object around(ProceedingJoinPoint pjp,
return pjp.proceed(proceedArgs);
}

private void captureColdStartMetricIfEnabled(Context extractedContext, FlushMetrics metrics) {
private void captureColdStartMetricIfEnabled(Context extractedContext, FlushMetrics flushMetrics) {
if (extractedContext == null) {
return;
}

Metrics metricsInstance = MetricsFactory.getMetricsInstance();
// This can be null e.g. during unit tests when mocking the Lambda context
if (extractedContext.getAwsRequestId() != null) {
metricsInstance.addMetadata(REQUEST_ID_PROPERTY, extractedContext.getAwsRequestId());
}
Metrics metrics = MetricsFactory.getMetricsInstance();

// Only capture cold start metrics if enabled on annotation
if (metrics.captureColdStart()) {
if (flushMetrics.captureColdStart()) {
// Get function name from annotation or context
String funcName = functionName(metrics, extractedContext);
String funcName = functionName(flushMetrics, extractedContext);

DimensionSet coldStartDimensions = new DimensionSet();
DimensionSet dimensionSet = new DimensionSet();

// Get service name from metrics instance default dimensions or fallback
String serviceName = metricsInstance.getDefaultDimensions().getDimensions().getOrDefault(
String serviceName = metrics.getDefaultDimensions().getDimensions().getOrDefault(
SERVICE_DIMENSION,
serviceNameWithFallback(metrics));
serviceNameWithFallback(flushMetrics));

// Only add service if it is not undefined
if (!LambdaConstants.SERVICE_UNDEFINED.equals(serviceName)) {
coldStartDimensions.addDimension(SERVICE_DIMENSION, serviceName);
dimensionSet.addDimension(SERVICE_DIMENSION, serviceName);
}

// Add function name
if (funcName != null) {
coldStartDimensions.addDimension("FunctionName", funcName);
dimensionSet.addDimension("FunctionName", funcName);
}

metricsInstance.captureColdStartMetric(extractedContext, coldStartDimensions);
metrics.captureColdStartMetric(extractedContext, dimensionSet);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package software.amazon.lambda.powertools.metrics.internal;

import com.amazonaws.services.lambda.runtime.Context;
import software.amazon.lambda.powertools.metrics.Metrics;

import static software.amazon.lambda.powertools.common.internal.LambdaHandlerProcessor.getXrayTraceId;

class MetricsUtils {

Check failure on line 8 in powertools-metrics/src/main/java/software/amazon/lambda/powertools/metrics/internal/MetricsUtils.java

View workflow job for this annotation

GitHub Actions / pmd_analyse

All methods are static. Consider adding a private no-args constructor to prevent instantiation.

For classes that only have static methods, consider making them utility classes. Note that this doesn't apply to abstract classes, since their subclasses may well include non-static methods. Also, if you want this class to be a utility class, remember to add a private constructor to prevent instantiation. (Note, that this use was known before PMD 5.1.0 as UseSingleton). UseUtilityClass (Priority: 1, Ruleset: Design) https://docs.pmd-code.org/snapshot/pmd_rules_java_design.html#useutilityclass
private static final String TRACE_ID_PROPERTY = "xray_trace_id";
private static final String REQUEST_ID_PROPERTY = "function_request_id";

static void addRequestIdAndXrayTraceIdIfAvailable(Context context, Metrics metrics) {
if (context != null && context.getAwsRequestId() != null) {
metrics.addMetadata(REQUEST_ID_PROPERTY, context.getAwsRequestId());
}
getXrayTraceId().ifPresent(traceId -> metrics.addMetadata(TRACE_ID_PROPERTY, traceId));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ void shouldAddDimensionSet() throws Exception {
@Test
void shouldThrowExceptionWhenDimensionSetIsNull() {
// When/Then
assertThatThrownBy(() -> metrics.addDimension((DimensionSet) null))
assertThatThrownBy(() -> metrics.addDimension(null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("DimensionSet cannot be null");
}
Expand All @@ -263,8 +263,8 @@ void shouldAddMetadata() throws Exception {
JsonNode rootNode = objectMapper.readTree(emfOutput);

// The metadata is added to the _aws section in the EMF output
assertThat(rootNode.get("_aws").has("CustomMetadata")).isTrue();
assertThat(rootNode.get("_aws").get("CustomMetadata").asText()).isEqualTo("MetadataValue");
assertThat(rootNode.has("CustomMetadata")).isTrue();
assertThat(rootNode.get("CustomMetadata").asText()).isEqualTo("MetadataValue");
}

@Test
Expand Down Expand Up @@ -304,7 +304,7 @@ void shouldGetDefaultDimensions() {
@Test
void shouldThrowExceptionWhenDefaultDimensionSetIsNull() {
// When/Then
assertThatThrownBy(() -> metrics.setDefaultDimensions((DimensionSet) null))
assertThatThrownBy(() -> metrics.setDefaultDimensions(null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("DimensionSet cannot be null");
}
Expand Down Expand Up @@ -346,7 +346,7 @@ void shouldLogWarningOnEmptyMetrics() throws Exception {

// Then
// Read the log file and check for the warning
String logContent = new String(Files.readAllBytes(logFile.toPath()), StandardCharsets.UTF_8);
String logContent = Files.readString(logFile.toPath(), StandardCharsets.UTF_8);
assertThat(logContent).contains("No metrics were emitted");
// No EMF output should be generated
assertThat(outputStreamCaptor.toString().trim()).isEmpty();
Expand Down Expand Up @@ -446,6 +446,37 @@ void shouldReuseNamespaceForColdStartMetric() throws Exception {
.isEqualTo(customNamespace);
}

@Test
void shouldFlushMetrics() throws Exception {
// Given
metrics.setNamespace("MainNamespace");
metrics.setDefaultDimensions(DimensionSet.of("CustomDim", "CustomValue"));
metrics.addDimension(DimensionSet.of("CustomDim2", "CustomValue2"));
metrics.addMetadata("CustomMetadata", "MetadataValue");

// When
metrics.flushMetrics(m -> {
m.addMetric("metric-one", 200, MetricUnit.COUNT);
m.addMetric("metric-two", 100, MetricUnit.COUNT);
});

// Then
String emfOutput = outputStreamCaptor.toString().trim();
JsonNode rootNode = objectMapper.readTree(emfOutput);

assertThat(rootNode.has("metric-one")).isTrue();
assertThat(rootNode.get("metric-one").asDouble()).isEqualTo(200.0);
assertThat(rootNode.has("metric-two")).isTrue();
assertThat(rootNode.get("metric-two").asDouble()).isEqualTo(100);
assertThat(rootNode.has("CustomDim")).isTrue();
assertThat(rootNode.get("CustomDim").asText()).isEqualTo("CustomValue");
assertThat(rootNode.get("CustomDim2")).isNull();
assertThat(rootNode.get("_aws").get("CloudWatchMetrics").get(0).get("Namespace").asText())
.isEqualTo("MainNamespace");
assertThat(rootNode.has("CustomMetadata")).isTrue();
assertThat(rootNode.get("CustomMetadata").asText()).isEqualTo("MetadataValue");
}

@Test
void shouldFlushSingleMetric() throws Exception {
// Given
Expand Down
Loading