From 26fc3038d566036e7e743f48c5ad928bb0aeb19f Mon Sep 17 00:00:00 2001 From: Rajkumar Rangaraj Date: Mon, 23 Feb 2026 15:56:48 -0800 Subject: [PATCH 1/8] Add Customer SDK Stats tracking for telemetry pipeline --- .../AzureMonitorExporterBuilder.java | 66 +++- .../pipeline/TelemetryItemExporter.java | 69 +++- .../pipeline/TelemetryPipeline.java | 12 +- .../pipeline/TelemetryPipelineRequest.java | 39 ++ .../pipeline/TelemetryPipelineResponse.java | 2 +- .../statsbeat/CustomerSdkStats.java | 359 +++++++++++++++++ .../CustomerSdkStatsExceptionCategory.java | 106 +++++ ...omerSdkStatsTelemetryPipelineListener.java | 123 ++++++ .../CustomerSdkStatsTelemetryType.java | 47 +++ .../utils/AzureMonitorHelper.java | 15 +- .../SimpleWebAppSample-README.md | 142 +++++++ .../autoconfigure/SimpleWebAppSample.java | 364 ++++++++++++++++++ ...CustomerSdkStatsExceptionCategoryTest.java | 77 ++++ ...SdkStatsTelemetryPipelineListenerTest.java | 155 ++++++++ .../CustomerSdkStatsTelemetryTypeTest.java | 61 +++ .../statsbeat/CustomerSdkStatsTest.java | 170 ++++++++ 16 files changed, 1798 insertions(+), 9 deletions(-) create mode 100644 sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStats.java create mode 100644 sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsExceptionCategory.java create mode 100644 sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsTelemetryPipelineListener.java create mode 100644 sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsTelemetryType.java create mode 100644 sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/samples/java/com/azure/monitor/opentelemetry/autoconfigure/SimpleWebAppSample-README.md create mode 100644 sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/samples/java/com/azure/monitor/opentelemetry/autoconfigure/SimpleWebAppSample.java create mode 100644 sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/test/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsExceptionCategoryTest.java create mode 100644 sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/test/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsTelemetryPipelineListenerTest.java create mode 100644 sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/test/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsTelemetryTypeTest.java create mode 100644 sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/test/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsTest.java diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/AzureMonitorExporterBuilder.java b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/AzureMonitorExporterBuilder.java index 2218794d2e2a..8b8ba745fbff 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/AzureMonitorExporterBuilder.java +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/AzureMonitorExporterBuilder.java @@ -24,14 +24,17 @@ import com.azure.monitor.opentelemetry.autoconfigure.implementation.configuration.StatsbeatConnectionString; import com.azure.monitor.opentelemetry.autoconfigure.implementation.heartbeat.HeartbeatExporter; import com.azure.monitor.opentelemetry.autoconfigure.implementation.models.ContextTagKeys; +import com.azure.monitor.opentelemetry.autoconfigure.implementation.models.TelemetryItem; import com.azure.monitor.opentelemetry.autoconfigure.implementation.pipeline.TelemetryItemExporter; import com.azure.monitor.opentelemetry.autoconfigure.implementation.quickpulse.QuickPulse; +import com.azure.monitor.opentelemetry.autoconfigure.implementation.statsbeat.CustomerSdkStats; import com.azure.monitor.opentelemetry.autoconfigure.implementation.statsbeat.Feature; import com.azure.monitor.opentelemetry.autoconfigure.implementation.statsbeat.StatsbeatModule; import com.azure.monitor.opentelemetry.autoconfigure.implementation.utils.AzureMonitorHelper; import com.azure.monitor.opentelemetry.autoconfigure.implementation.utils.PropertyHelper; import com.azure.monitor.opentelemetry.autoconfigure.implementation.utils.ResourceParser; import com.azure.monitor.opentelemetry.autoconfigure.implementation.utils.TempDirs; +import com.azure.monitor.opentelemetry.autoconfigure.implementation.utils.ThreadPoolUtils; import com.azure.monitor.opentelemetry.autoconfigure.implementation.utils.VersionGenerator; import io.opentelemetry.api.common.AttributeKey; import io.opentelemetry.sdk.autoconfigure.spi.ConfigProperties; @@ -47,6 +50,9 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; import java.util.function.BiConsumer; import static java.util.concurrent.TimeUnit.DAYS; @@ -61,6 +67,10 @@ class AzureMonitorExporterBuilder { private static final String STATSBEAT_SHORT_INTERVAL_SECONDS_PROPERTY_NAME = "STATSBEAT_SHORT_INTERVAL_SECONDS_PROPERTY_NAME"; + private static final String SDKSTATS_DISABLED_ENV_VAR = "APPLICATIONINSIGHTS_SDKSTATS_DISABLED"; + private static final String SDKSTATS_EXPORT_INTERVAL_ENV_VAR = "APPLICATIONINSIGHTS_SDKSTATS_EXPORT_INTERVAL"; + private static final long SDKSTATS_DEFAULT_EXPORT_INTERVAL_SECONDS = 900; // 15 minutes + private static final Map PROPERTIES = CoreUtils.getProperties("azure-monitor-opentelemetry-exporter.properties"); @@ -93,13 +103,16 @@ void initializeIfNot(AzureMonitorAutoConfigureOptions exporterOptions, ConfigPro this.spanDataMapper = createSpanDataMapper(); File tempDir = TempDirs.getApplicationInsightsTempDir(LOGGER, "Telemetry will not be stored to disk and retried on sporadic network failures"); + // Create customer-facing SDKStats accumulator + CustomerSdkStats customerSdkStats = createCustomerSdkStats(); // TODO (heya) change LocalStorageStats.noop() to statsbeatModule.getNonessentialStatsbeat() when we decide to collect non-essential Statsbeat by default. this.builtTelemetryItemExporter = AzureMonitorHelper.createTelemetryItemExporter(httpPipeline, statsbeatModule, - tempDir, LocalStorageStats.noop()); + tempDir, LocalStorageStats.noop(), customerSdkStats); if (LiveMetrics.isEnabled(configProperties)) { this.quickPulse = createQuickPulse(resource); } startStatsbeatModule(statsbeatModule, configProperties, tempDir); // wait till TelemetryItemExporter has been initialized before starting StatsbeatModule + startCustomerSdkStats(customerSdkStats, resource); } private QuickPulse createQuickPulse(Resource resource) { @@ -240,6 +253,57 @@ private void startStatsbeatModule(StatsbeatModule statsbeatModule, ConfigPropert false, initStatsbeatFeatures()); } + private CustomerSdkStats createCustomerSdkStats() { + // Use the raw SDK version (without prefixes) for the customer-facing stats + String version = VersionGenerator.getSdkVersion(); + return CustomerSdkStats.create(version); + } + + private void startCustomerSdkStats(CustomerSdkStats customerSdkStats, Resource resource) { + // Check if customer SDKStats is disabled via env var + String disabledEnvVar = System.getenv(SDKSTATS_DISABLED_ENV_VAR); + if ("true".equalsIgnoreCase(disabledEnvVar)) { + LOGGER.verbose("Customer SDKStats is disabled via environment variable {}.", SDKSTATS_DISABLED_ENV_VAR); + return; + } + + // Get export interval from env var or use default + long exportIntervalSeconds = SDKSTATS_DEFAULT_EXPORT_INTERVAL_SECONDS; + String intervalEnvVar = System.getenv(SDKSTATS_EXPORT_INTERVAL_ENV_VAR); + if (intervalEnvVar != null && !intervalEnvVar.isEmpty()) { + try { + exportIntervalSeconds = Long.parseLong(intervalEnvVar); + } catch (NumberFormatException e) { + LOGGER.warning("Invalid value for {}: {}. Using default {} seconds.", SDKSTATS_EXPORT_INTERVAL_ENV_VAR, + intervalEnvVar, SDKSTATS_DEFAULT_EXPORT_INTERVAL_SECONDS); + } + } + + ConnectionString connectionString = getConnectionString(); + String sdkVersion = VersionGenerator.getSdkVersion(); + String cloudRole = resource.getAttribute(AttributeKey.stringKey("service.name")); + String cloudRoleInstance = resource.getAttribute(AttributeKey.stringKey("service.instance.id")); + + ScheduledExecutorService customerSdkStatsExecutor = Executors + .newSingleThreadScheduledExecutor(ThreadPoolUtils.createDaemonThreadFactory(CustomerSdkStats.class)); + + LOGGER.info("Customer SDKStats scheduler starting with interval {} seconds.", exportIntervalSeconds); + customerSdkStatsExecutor.scheduleWithFixedDelay(() -> { + try { + List items + = customerSdkStats.collectAndReset(connectionString, sdkVersion, cloudRole, cloudRoleInstance); + if (!items.isEmpty()) { + LOGGER.info("Customer SDKStats: exporting {} metric items.", items.size()); + builtTelemetryItemExporter.sendWithoutTracking(items); + } else { + LOGGER.info("Customer SDKStats: no items to export (counters were zero)."); + } + } catch (RuntimeException e) { + LOGGER.warning("Error sending customer SDKStats", e); + } + }, exportIntervalSeconds, exportIntervalSeconds, TimeUnit.SECONDS); + } + private HttpPipeline createStatsbeatHttpPipeline() { if (exporterOptions.httpPipeline != null) { return exporterOptions.httpPipeline; diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/pipeline/TelemetryItemExporter.java b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/pipeline/TelemetryItemExporter.java index 0992704183d7..007759e326fa 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/pipeline/TelemetryItemExporter.java +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/pipeline/TelemetryItemExporter.java @@ -10,7 +10,11 @@ import com.azure.monitor.opentelemetry.autoconfigure.implementation.builders.MetricTelemetryBuilder; import com.azure.monitor.opentelemetry.autoconfigure.implementation.logging.OperationLogger; import com.azure.monitor.opentelemetry.autoconfigure.implementation.models.ContextTagKeys; +import com.azure.monitor.opentelemetry.autoconfigure.implementation.models.MonitorDomain; +import com.azure.monitor.opentelemetry.autoconfigure.implementation.models.RemoteDependencyData; +import com.azure.monitor.opentelemetry.autoconfigure.implementation.models.RequestData; import com.azure.monitor.opentelemetry.autoconfigure.implementation.models.TelemetryItem; +import com.azure.monitor.opentelemetry.autoconfigure.implementation.statsbeat.CustomerSdkStatsTelemetryType; import com.azure.monitor.opentelemetry.autoconfigure.implementation.utils.AksResourceAttributes; import com.azure.monitor.opentelemetry.autoconfigure.implementation.utils.IKeyMasker; import io.opentelemetry.api.common.AttributeKey; @@ -70,6 +74,29 @@ public CompletableResultCode send(List telemetryItems) { return CompletableResultCode.ofAll(resultCodeList); } + /** + * Sends telemetry items without computing customer-facing SDKStats metadata. + * Used for sending customer SDKStats metrics themselves to prevent recursive counting. + * Items sent via this method will NOT trigger CustomerSdkStatsTelemetryPipelineListener + * since the TelemetryPipelineRequest will have empty item count maps. + */ + public CompletableResultCode sendWithoutTracking(List telemetryItems) { + Map> batches = splitIntoBatches(telemetryItems); + List resultCodeList = new ArrayList<>(); + for (Map.Entry> batch : batches.entrySet()) { + try { + List byteBuffers = serialize(batch.getValue()); + encodeBatchOperationLogger.recordSuccess(); + resultCodeList.add(telemetryPipeline.send(byteBuffers, batch.getKey().connectionString, listener)); + } catch (Throwable t) { + encodeBatchOperationLogger.recordFailure(t.getMessage(), t); + resultCodeList.add(CompletableResultCode.ofFailure()); + } + } + maybeAddToActiveExportResults(resultCodeList); + return CompletableResultCode.ofAll(resultCodeList); + } + // visible for tests Map> splitIntoBatches(List telemetryItems) { @@ -118,6 +145,13 @@ CompletableResultCode internalSendByBatch(TelemetryItemBatchKey telemetryItemBat && AksResourceAttributes.isAks(telemetryItemBatchKey.resource)) { telemetryItems.add(0, createOtelResourceMetric(telemetryItemBatchKey)); } + + // Compute per-type item counts before serialization for customer-facing SDKStats + Map itemCountsByType = new HashMap<>(); + Map successItemCountsByType = new HashMap<>(); + Map failureItemCountsByType = new HashMap<>(); + computeItemCountMetadata(telemetryItems, itemCountsByType, successItemCountsByType, failureItemCountsByType); + try { byteBuffers = serialize(telemetryItems); encodeBatchOperationLogger.recordSuccess(); @@ -125,7 +159,40 @@ CompletableResultCode internalSendByBatch(TelemetryItemBatchKey telemetryItemBat encodeBatchOperationLogger.recordFailure(t.getMessage(), t); return CompletableResultCode.ofFailure(); } - return telemetryPipeline.send(byteBuffers, telemetryItemBatchKey.connectionString, listener); + return telemetryPipeline.send(byteBuffers, telemetryItemBatchKey.connectionString, listener, itemCountsByType, + successItemCountsByType, failureItemCountsByType); + } + + static void computeItemCountMetadata(List telemetryItems, Map itemCountsByType, + Map successItemCountsByType, Map failureItemCountsByType) { + for (TelemetryItem item : telemetryItems) { + String telemetryType = CustomerSdkStatsTelemetryType.fromTelemetryItemName(item.getName()); + if (telemetryType == null) { + // Skip internal items (e.g. Statsbeat) + continue; + } + + itemCountsByType.merge(telemetryType, 1L, Long::sum); + + // Track success/failure for Request and Dependency types + if ("REQUEST".equals(telemetryType) || "DEPENDENCY".equals(telemetryType)) { + MonitorDomain baseData = item.getData() != null ? item.getData().getBaseData() : null; + if (baseData instanceof RequestData) { + if (((RequestData) baseData).isSuccess()) { + successItemCountsByType.merge(telemetryType, 1L, Long::sum); + } else { + failureItemCountsByType.merge(telemetryType, 1L, Long::sum); + } + } else if (baseData instanceof RemoteDependencyData) { + Boolean success = ((RemoteDependencyData) baseData).isSuccess(); + if (success != null && success) { + successItemCountsByType.merge(telemetryType, 1L, Long::sum); + } else { + failureItemCountsByType.merge(telemetryType, 1L, Long::sum); + } + } + } + } } // serialize an array of TelemetryItems to an array of byte buffers diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/pipeline/TelemetryPipeline.java b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/pipeline/TelemetryPipeline.java index 0bd0929e6ce6..9f3f66b7d1c7 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/pipeline/TelemetryPipeline.java +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/pipeline/TelemetryPipeline.java @@ -45,14 +45,22 @@ public TelemetryPipeline(HttpPipeline pipeline, Runnable statsbeatShutdown) { public CompletableResultCode send(List telemetry, String connectionString, TelemetryPipelineListener listener) { + return send(telemetry, connectionString, listener, Collections.emptyMap(), Collections.emptyMap(), + Collections.emptyMap()); + } + + public CompletableResultCode send(List telemetry, String connectionString, + TelemetryPipelineListener listener, Map itemCountsByType, + Map successItemCountsByType, Map failureItemCountsByType) { ConnectionString connectionStringObj = ConnectionString.parse(connectionString); URL url = redirectCache.computeIfAbsent(connectionString, k -> getFullIngestionUrl(connectionStringObj.getIngestionEndpoint())); - TelemetryPipelineRequest request = new TelemetryPipelineRequest(url, connectionString, - connectionStringObj.getInstrumentationKey(), telemetry); + TelemetryPipelineRequest request + = new TelemetryPipelineRequest(url, connectionString, connectionStringObj.getInstrumentationKey(), + telemetry, itemCountsByType, successItemCountsByType, failureItemCountsByType); try { CompletableResultCode result = new CompletableResultCode(); diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/pipeline/TelemetryPipelineRequest.java b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/pipeline/TelemetryPipelineRequest.java index 43ca4257367c..cd1770e9dfb6 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/pipeline/TelemetryPipelineRequest.java +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/pipeline/TelemetryPipelineRequest.java @@ -10,7 +10,9 @@ import java.net.URL; import java.nio.ByteBuffer; +import java.util.Collections; import java.util.List; +import java.util.Map; public class TelemetryPipelineRequest { @@ -20,13 +22,28 @@ public class TelemetryPipelineRequest { private final List byteBuffers; private final int contentLength; + // Customer-facing SDKStats metadata: item counts by telemetry type + private final Map itemCountsByType; + private final Map successItemCountsByType; + private final Map failureItemCountsByType; + TelemetryPipelineRequest(URL url, String connectionString, String instrumentationKey, List byteBuffers) { + this(url, connectionString, instrumentationKey, byteBuffers, Collections.emptyMap(), Collections.emptyMap(), + Collections.emptyMap()); + } + + public TelemetryPipelineRequest(URL url, String connectionString, String instrumentationKey, + List byteBuffers, Map itemCountsByType, Map successItemCountsByType, + Map failureItemCountsByType) { this.url = url; this.connectionString = connectionString; this.instrumentationKey = instrumentationKey; this.byteBuffers = byteBuffers; contentLength = byteBuffers.stream().mapToInt(ByteBuffer::limit).sum(); + this.itemCountsByType = itemCountsByType; + this.successItemCountsByType = successItemCountsByType; + this.failureItemCountsByType = failureItemCountsByType; } public URL getUrl() { @@ -50,6 +67,28 @@ public String getInstrumentationKey() { return instrumentationKey; } + /** + * Returns item counts by telemetry type (e.g. "REQUEST" -> 200, "DEPENDENCY" -> 300). + * Empty map for batches where item counting is not applicable (e.g. statsbeat, disk retries). + */ + public Map getItemCountsByType() { + return itemCountsByType; + } + + /** + * Returns counts of successful REQUEST/DEPENDENCY items (where isSuccess() == true). + */ + public Map getSuccessItemCountsByType() { + return successItemCountsByType; + } + + /** + * Returns counts of failed REQUEST/DEPENDENCY items (where isSuccess() == false). + */ + public Map getFailureItemCountsByType() { + return failureItemCountsByType; + } + HttpRequest createHttpRequest() { HttpRequest request = new HttpRequest(HttpMethod.POST, url); request.setBody(Flux.fromIterable(byteBuffers)); diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/pipeline/TelemetryPipelineResponse.java b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/pipeline/TelemetryPipelineResponse.java index 142e964c2dd8..2909f195bd90 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/pipeline/TelemetryPipelineResponse.java +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/pipeline/TelemetryPipelineResponse.java @@ -21,7 +21,7 @@ public class TelemetryPipelineResponse { private final int statusCode; private final String body; - TelemetryPipelineResponse(int statusCode, String body) { + public TelemetryPipelineResponse(int statusCode, String body) { this.statusCode = statusCode; this.body = body; } diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStats.java b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStats.java new file mode 100644 index 000000000000..377c6fd2e412 --- /dev/null +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStats.java @@ -0,0 +1,359 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.monitor.opentelemetry.autoconfigure.implementation.statsbeat; + +import com.azure.monitor.opentelemetry.autoconfigure.implementation.builders.MetricTelemetryBuilder; +import com.azure.monitor.opentelemetry.autoconfigure.implementation.configuration.ConnectionString; +import com.azure.monitor.opentelemetry.autoconfigure.implementation.models.ContextTagKeys; +import com.azure.monitor.opentelemetry.autoconfigure.implementation.models.TelemetryItem; +import com.azure.monitor.opentelemetry.autoconfigure.implementation.utils.FormattedTime; +import reactor.util.annotation.Nullable; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Accumulates customer-facing SDKStats metrics: Item_Success_Count, Item_Dropped_Count, + * Item_Retry_Count. These metrics are sent to the customer's own Application Insights resource. + */ +public class CustomerSdkStats { + + static final String ITEM_SUCCESS_COUNT = "Item_Success_Count"; + static final String ITEM_DROPPED_COUNT = "Item_Dropped_Count"; + static final String ITEM_RETRY_COUNT = "Item_Retry_Count"; + + // Drop code constants + public static final String DROP_CODE_CLIENT_EXCEPTION = "CLIENT_EXCEPTION"; + public static final String DROP_CODE_CLIENT_READONLY = "CLIENT_READONLY"; + public static final String DROP_CODE_CLIENT_PERSISTENCE_CAPACITY = "CLIENT_PERSISTENCE_CAPACITY"; + public static final String DROP_CODE_CLIENT_STORAGE_DISABLED = "CLIENT_STORAGE_DISABLED"; + + // Retry code constants + public static final String RETRY_CODE_CLIENT_EXCEPTION = "CLIENT_EXCEPTION"; + public static final String RETRY_CODE_CLIENT_TIMEOUT = "CLIENT_TIMEOUT"; + + private final ConcurrentHashMap successCounts = new ConcurrentHashMap<>(); + private final ConcurrentHashMap droppedCounts = new ConcurrentHashMap<>(); + private final ConcurrentHashMap retryCounts = new ConcurrentHashMap<>(); + + private final String computeType; + private final String language; + private final String version; + + /** + * Creates a CustomerSdkStats instance using the detected resource provider for compute type. + */ + public static CustomerSdkStats create(String version) { + String computeType = ResourceProvider.initResourceProvider().getValue(); + return new CustomerSdkStats(computeType, "java", version); + } + + // visible for testing + public CustomerSdkStats(String computeType, String language, String version) { + this.computeType = computeType; + this.language = language; + this.version = version; + } + + /** + * Increment success counts for all telemetry types in the batch. + */ + public void incrementSuccessCount(Map itemCountsByType) { + for (Map.Entry entry : itemCountsByType.entrySet()) { + SuccessKey key = new SuccessKey(entry.getKey()); + successCounts.computeIfAbsent(key, k -> new AtomicLong()).addAndGet(entry.getValue()); + } + } + + /** + * Increment dropped counts for all telemetry types in the batch. + * + * @param itemCountsByType the per-type item counts + * @param dropCode the drop code (e.g. "CLIENT_EXCEPTION", "402", etc.) + * @param dropReason the drop reason (e.g. "Exceeded daily quota") + * @param successItemCountsByType success items by type (for telemetry_success dimension on + * REQUEST/DEPENDENCY) + * @param failureItemCountsByType failure items by type + */ + public void incrementDroppedCount(Map itemCountsByType, String dropCode, @Nullable String dropReason, + Map successItemCountsByType, Map failureItemCountsByType) { + for (Map.Entry entry : itemCountsByType.entrySet()) { + String telemetryType = entry.getKey(); + long totalCount = entry.getValue(); + + // For REQUEST and DEPENDENCY, split by telemetry_success + if ("REQUEST".equals(telemetryType) || "DEPENDENCY".equals(telemetryType)) { + long successCount = successItemCountsByType.getOrDefault(telemetryType, 0L); + long failureCount = failureItemCountsByType.getOrDefault(telemetryType, 0L); + // Any items not accounted for in success/failure maps go to the "unknown" bucket + long unaccounted = totalCount - successCount - failureCount; + + if (successCount > 0) { + DroppedKey key = new DroppedKey(telemetryType, dropCode, dropReason, Boolean.TRUE); + droppedCounts.computeIfAbsent(key, k -> new AtomicLong()).addAndGet(successCount); + } + if (failureCount > 0) { + DroppedKey key = new DroppedKey(telemetryType, dropCode, dropReason, Boolean.FALSE); + droppedCounts.computeIfAbsent(key, k -> new AtomicLong()).addAndGet(failureCount); + } + if (unaccounted > 0) { + // Items where we couldn't determine success/failure + DroppedKey key = new DroppedKey(telemetryType, dropCode, dropReason, null); + droppedCounts.computeIfAbsent(key, k -> new AtomicLong()).addAndGet(unaccounted); + } + } else { + // For non-REQUEST/DEPENDENCY types, telemetry_success is not applicable + DroppedKey key = new DroppedKey(telemetryType, dropCode, dropReason, null); + droppedCounts.computeIfAbsent(key, k -> new AtomicLong()).addAndGet(totalCount); + } + } + } + + /** + * Increment retry counts for all telemetry types in the batch. + */ + public void incrementRetryCount(Map itemCountsByType, String retryCode, + @Nullable String retryReason) { + for (Map.Entry entry : itemCountsByType.entrySet()) { + RetryKey key = new RetryKey(entry.getKey(), retryCode, retryReason); + retryCounts.computeIfAbsent(key, k -> new AtomicLong()).addAndGet(entry.getValue()); + } + } + + /** + * Atomically snapshots and clears all counters, returning a list of TelemetryItem metrics + * to send to the customer's AI resource. + * + * @param connectionString the customer's connection string + * @param sdkVersion the full SDK version string for ai.internal.sdkVersion tag + * @param cloudRole the cloud role name (nullable) + * @param cloudRoleInstance the cloud role instance (nullable) + * @return list of TelemetryItem metrics, empty if no non-zero counters + */ + public List collectAndReset(ConnectionString connectionString, String sdkVersion, + @Nullable String cloudRole, @Nullable String cloudRoleInstance) { + List telemetryItems = new ArrayList<>(); + + // Snapshot and clear success counts + Map successSnapshot = snapshotAndClear(successCounts); + for (Map.Entry entry : successSnapshot.entrySet()) { + long count = entry.getValue(); + if (count == 0) { + continue; + } + SuccessKey key = entry.getKey(); + MetricTelemetryBuilder builder = MetricTelemetryBuilder.create(ITEM_SUCCESS_COUNT, (double) count); + builder.setConnectionString(connectionString); + builder.setTime(FormattedTime.offSetDateTimeFromNow()); + addCommonTags(builder, sdkVersion, cloudRole, cloudRoleInstance); + addCommonProperties(builder); + builder.addProperty("telemetry_type", key.telemetryType); + telemetryItems.add(builder.build()); + } + + // Snapshot and clear dropped counts + Map droppedSnapshot = snapshotAndClear(droppedCounts); + for (Map.Entry entry : droppedSnapshot.entrySet()) { + long count = entry.getValue(); + if (count == 0) { + continue; + } + DroppedKey key = entry.getKey(); + MetricTelemetryBuilder builder = MetricTelemetryBuilder.create(ITEM_DROPPED_COUNT, (double) count); + builder.setConnectionString(connectionString); + builder.setTime(FormattedTime.offSetDateTimeFromNow()); + addCommonTags(builder, sdkVersion, cloudRole, cloudRoleInstance); + addCommonProperties(builder); + builder.addProperty("telemetry_type", key.telemetryType); + builder.addProperty("drop.code", key.dropCode); + if (key.dropReason != null) { + builder.addProperty("drop.reason", key.dropReason); + } + if (key.telemetrySuccess != null) { + builder.addProperty("telemetry_success", key.telemetrySuccess.toString()); + } + telemetryItems.add(builder.build()); + } + + // Snapshot and clear retry counts + Map retrySnapshot = snapshotAndClear(retryCounts); + for (Map.Entry entry : retrySnapshot.entrySet()) { + long count = entry.getValue(); + if (count == 0) { + continue; + } + RetryKey key = entry.getKey(); + MetricTelemetryBuilder builder = MetricTelemetryBuilder.create(ITEM_RETRY_COUNT, (double) count); + builder.setConnectionString(connectionString); + builder.setTime(FormattedTime.offSetDateTimeFromNow()); + addCommonTags(builder, sdkVersion, cloudRole, cloudRoleInstance); + addCommonProperties(builder); + builder.addProperty("telemetry_type", key.telemetryType); + builder.addProperty("retry.code", key.retryCode); + if (key.retryReason != null) { + builder.addProperty("retry.reason", key.retryReason); + } + telemetryItems.add(builder.build()); + } + + return telemetryItems; + } + + private void addCommonTags(MetricTelemetryBuilder builder, String sdkVersion, @Nullable String cloudRole, + @Nullable String cloudRoleInstance) { + builder.addTag(ContextTagKeys.AI_INTERNAL_SDK_VERSION.toString(), sdkVersion); + if (cloudRole != null) { + builder.addTag(ContextTagKeys.AI_CLOUD_ROLE.toString(), cloudRole); + } + if (cloudRoleInstance != null) { + builder.addTag(ContextTagKeys.AI_CLOUD_ROLE_INSTANCE.toString(), cloudRoleInstance); + } + } + + private void addCommonProperties(MetricTelemetryBuilder builder) { + builder.addProperty("computeType", computeType); + builder.addProperty("language", language); + builder.addProperty("version", version); + } + + private static Map snapshotAndClear(ConcurrentHashMap map) { + Map snapshot = new HashMap<>(); + // We iterate and getAndSet(0) to avoid losing concurrent increments + for (Map.Entry entry : map.entrySet()) { + long value = entry.getValue().getAndSet(0); + if (value != 0) { + snapshot.put(entry.getKey(), value); + } + } + // Remove zero-valued entries to prevent unbounded growth + map.entrySet().removeIf(entry -> entry.getValue().get() == 0); + return snapshot; + } + + // visible for testing + long getSuccessCount(String telemetryType) { + AtomicLong count = successCounts.get(new SuccessKey(telemetryType)); + return count == null ? 0L : count.get(); + } + + // visible for testing + long getDroppedCount(String telemetryType, String dropCode) { + long total = 0; + for (Map.Entry entry : droppedCounts.entrySet()) { + DroppedKey key = entry.getKey(); + if (key.telemetryType.equals(telemetryType) && key.dropCode.equals(dropCode)) { + total += entry.getValue().get(); + } + } + return total; + } + + // visible for testing + long getRetryCount(String telemetryType, String retryCode) { + long total = 0; + for (Map.Entry entry : retryCounts.entrySet()) { + RetryKey key = entry.getKey(); + if (key.telemetryType.equals(telemetryType) && key.retryCode.equals(retryCode)) { + total += entry.getValue().get(); + } + } + return total; + } + + static final class SuccessKey { + final String telemetryType; + + SuccessKey(String telemetryType) { + this.telemetryType = telemetryType; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof SuccessKey)) { + return false; + } + SuccessKey that = (SuccessKey) o; + return telemetryType.equals(that.telemetryType); + } + + @Override + public int hashCode() { + return telemetryType.hashCode(); + } + } + + static final class DroppedKey { + final String telemetryType; + final String dropCode; + final String dropReason; + final Boolean telemetrySuccess; + + DroppedKey(String telemetryType, String dropCode, @Nullable String dropReason, + @Nullable Boolean telemetrySuccess) { + this.telemetryType = telemetryType; + this.dropCode = dropCode; + this.dropReason = dropReason; + this.telemetrySuccess = telemetrySuccess; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof DroppedKey)) { + return false; + } + DroppedKey that = (DroppedKey) o; + return telemetryType.equals(that.telemetryType) + && dropCode.equals(that.dropCode) + && Objects.equals(dropReason, that.dropReason) + && Objects.equals(telemetrySuccess, that.telemetrySuccess); + } + + @Override + public int hashCode() { + return Objects.hash(telemetryType, dropCode, dropReason, telemetrySuccess); + } + } + + static final class RetryKey { + final String telemetryType; + final String retryCode; + final String retryReason; + + RetryKey(String telemetryType, String retryCode, @Nullable String retryReason) { + this.telemetryType = telemetryType; + this.retryCode = retryCode; + this.retryReason = retryReason; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof RetryKey)) { + return false; + } + RetryKey that = (RetryKey) o; + return telemetryType.equals(that.telemetryType) + && retryCode.equals(that.retryCode) + && Objects.equals(retryReason, that.retryReason); + } + + @Override + public int hashCode() { + return Objects.hash(telemetryType, retryCode, retryReason); + } + } +} diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsExceptionCategory.java b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsExceptionCategory.java new file mode 100644 index 000000000000..e780d080f1b5 --- /dev/null +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsExceptionCategory.java @@ -0,0 +1,106 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.monitor.opentelemetry.autoconfigure.implementation.statsbeat; + +import java.net.ConnectException; +import java.net.NoRouteToHostException; +import java.net.SocketTimeoutException; +import java.net.UnknownHostException; +import java.util.concurrent.TimeoutException; + +/** + * Categorizes exceptions into low-cardinality reason strings for customer-facing SDKStats + * drop.reason and retry.reason dimensions. + */ +final class CustomerSdkStatsExceptionCategory { + + static final String TIMEOUT_EXCEPTION = "Timeout exception"; + static final String NETWORK_EXCEPTION = "Network exception"; + static final String STORAGE_EXCEPTION = "Storage exception"; + static final String CLIENT_EXCEPTION = "Client exception"; + + /** + * Returns a low-cardinality exception category string. + */ + static String categorize(Throwable throwable) { + if (throwable == null) { + return CLIENT_EXCEPTION; + } + return categorizeByType(throwable); + } + + /** + * Returns true if the exception represents a timeout scenario (use CLIENT_TIMEOUT retry code), + * false for other exceptions (use CLIENT_EXCEPTION retry code). + */ + static boolean isTimeout(Throwable throwable) { + if (throwable == null) { + return false; + } + return isTimeoutType(throwable); + } + + private static String categorizeByType(Throwable throwable) { + // Traverse the cause chain to find the most specific category + Throwable current = throwable; + int depth = 0; + while (current != null && depth < 10) { + if (isTimeoutType(current)) { + return TIMEOUT_EXCEPTION; + } + if (isNetworkType(current)) { + return NETWORK_EXCEPTION; + } + if (isStorageType(current)) { + return STORAGE_EXCEPTION; + } + current = current.getCause(); + depth++; + } + return CLIENT_EXCEPTION; + } + + private static boolean isTimeoutType(Throwable throwable) { + // Check the cause chain + Throwable current = throwable; + int depth = 0; + while (current != null && depth < 10) { + if (current instanceof SocketTimeoutException || current instanceof TimeoutException) { + return true; + } + // Check class name for netty timeout types without creating a hard dependency + String className = current.getClass().getName(); + if (className.contains("TimeoutException") + || className.contains("ReadTimeoutException") + || className.contains("WriteTimeoutException")) { + return true; + } + current = current.getCause(); + depth++; + } + return false; + } + + private static boolean isNetworkType(Throwable throwable) { + return throwable instanceof UnknownHostException + || throwable instanceof ConnectException + || throwable instanceof NoRouteToHostException; + } + + private static boolean isStorageType(Throwable throwable) { + // Storage-related IOExceptions typically involve file system operations + String message = throwable.getMessage(); + if (message == null) { + return false; + } + String lowerMessage = message.toLowerCase(); + return lowerMessage.contains("no space left") + || lowerMessage.contains("disk full") + || lowerMessage.contains("read-only file system") + || lowerMessage.contains("permission denied"); + } + + private CustomerSdkStatsExceptionCategory() { + } +} diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsTelemetryPipelineListener.java b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsTelemetryPipelineListener.java new file mode 100644 index 000000000000..ab9adf16cc3a --- /dev/null +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsTelemetryPipelineListener.java @@ -0,0 +1,123 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.monitor.opentelemetry.autoconfigure.implementation.statsbeat; + +import com.azure.core.util.logging.ClientLogger; +import com.azure.monitor.opentelemetry.autoconfigure.implementation.pipeline.TelemetryPipelineListener; +import com.azure.monitor.opentelemetry.autoconfigure.implementation.pipeline.TelemetryPipelineRequest; +import com.azure.monitor.opentelemetry.autoconfigure.implementation.pipeline.TelemetryPipelineResponse; +import com.azure.monitor.opentelemetry.autoconfigure.implementation.utils.StatusCode; +import io.opentelemetry.sdk.common.CompletableResultCode; +import reactor.util.annotation.Nullable; + +import java.util.Map; + +/** + * TelemetryPipelineListener that observes HTTP responses and exceptions from the telemetry + * pipeline, recording item-level success/drop/retry counts into the CustomerSdkStats accumulator. + */ +public class CustomerSdkStatsTelemetryPipelineListener implements TelemetryPipelineListener { + + private static final ClientLogger LOGGER = new ClientLogger(CustomerSdkStatsTelemetryPipelineListener.class); + + private final CustomerSdkStats customerSdkStats; + + public CustomerSdkStatsTelemetryPipelineListener(CustomerSdkStats customerSdkStats) { + this.customerSdkStats = customerSdkStats; + } + + @Override + public void onResponse(TelemetryPipelineRequest request, TelemetryPipelineResponse response) { + Map itemCountsByType = request.getItemCountsByType(); + if (itemCountsByType.isEmpty()) { + // No item metadata (e.g. from local storage resend) — skip tracking + LOGGER.info("CustomerSdkStats onResponse: itemCountsByType is empty, skipping."); + return; + } + + int statusCode = response.getStatusCode(); + LOGGER.info("CustomerSdkStats onResponse: statusCode={}, itemCountsByType={}", statusCode, itemCountsByType); + + if (statusCode == 200) { + customerSdkStats.incrementSuccessCount(itemCountsByType); + } else if (StatusCode.isRetryable(statusCode)) { + // Retryable status codes: items will be retried via local storage + String retryCode = String.valueOf(statusCode); + String retryReason = getReasonPhraseForStatusCode(statusCode); + customerSdkStats.incrementRetryCount(itemCountsByType, retryCode, retryReason); + } else if (StatusCode.isRedirect(statusCode)) { + // Redirects are handled transparently by the HTTP client; do not count + } else { + // Non-retryable status codes: items are dropped + String dropCode = String.valueOf(statusCode); + String dropReason = getReasonPhraseForStatusCode(statusCode); + customerSdkStats.incrementDroppedCount(itemCountsByType, dropCode, dropReason, + request.getSuccessItemCountsByType(), request.getFailureItemCountsByType()); + } + } + + @Override + public void onException(TelemetryPipelineRequest request, String errorMessage, Throwable throwable) { + Map itemCountsByType = request.getItemCountsByType(); + if (itemCountsByType.isEmpty()) { + return; + } + + // Exceptions result in retry via local storage persistence + boolean isTimeout = CustomerSdkStatsExceptionCategory.isTimeout(throwable); + String retryCode + = isTimeout ? CustomerSdkStats.RETRY_CODE_CLIENT_TIMEOUT : CustomerSdkStats.RETRY_CODE_CLIENT_EXCEPTION; + String retryReason = CustomerSdkStatsExceptionCategory.categorize(throwable); + customerSdkStats.incrementRetryCount(itemCountsByType, retryCode, retryReason); + } + + @Override + public CompletableResultCode shutdown() { + return CompletableResultCode.ofSuccess(); + } + + @Nullable + static String getReasonPhraseForStatusCode(int statusCode) { + switch (statusCode) { + case 400: + return "Bad request"; + + case 401: + return "Unauthorized"; + + case 402: + return "Exceeded daily quota"; + + case 403: + return "Forbidden"; + + case 404: + return "Not found"; + + case 408: + return "Request timeout"; + + case 429: + return "Too many requests"; + + case 439: + return "Too many requests"; + + case 500: + return "Internal server error"; + + case 502: + return "Bad gateway"; + + case 503: + return "Service unavailable"; + + case 504: + return "Gateway timeout"; + + default: + return null; + } + } +} diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsTelemetryType.java b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsTelemetryType.java new file mode 100644 index 000000000000..8f42e144f080 --- /dev/null +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsTelemetryType.java @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.monitor.opentelemetry.autoconfigure.implementation.statsbeat; + +import reactor.util.annotation.Nullable; + +import java.util.HashMap; +import java.util.Map; + +/** + * Maps TelemetryItem.getName() values to the telemetry_type dimension values + * used in customer-facing SDKStats metrics. + */ +public final class CustomerSdkStatsTelemetryType { + + private static final Map MAPPING = new HashMap<>(); + + static { + MAPPING.put("Request", "REQUEST"); + MAPPING.put("RemoteDependency", "DEPENDENCY"); + MAPPING.put("Message", "TRACE"); + MAPPING.put("Exception", "EXCEPTION"); + MAPPING.put("Metric", "CUSTOM_METRIC"); + MAPPING.put("Event", "CUSTOM_EVENT"); + MAPPING.put("PageView", "PAGE_VIEW"); + MAPPING.put("Availability", "AVAILABILITY"); + } + + /** + * Maps a TelemetryItem name to its customer-facing telemetry_type value. + * + * @param telemetryItemName the value from TelemetryItem.getName() + * @return the telemetry_type dimension value, or null if the item should be skipped + * (e.g. "Statsbeat" internal metrics) + */ + @Nullable + public static String fromTelemetryItemName(@Nullable String telemetryItemName) { + if (telemetryItemName == null) { + return null; + } + return MAPPING.get(telemetryItemName); + } + + private CustomerSdkStatsTelemetryType() { + } +} diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/utils/AzureMonitorHelper.java b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/utils/AzureMonitorHelper.java index 645a6a56f364..334fbf36f953 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/utils/AzureMonitorHelper.java +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/utils/AzureMonitorHelper.java @@ -10,6 +10,8 @@ import com.azure.monitor.opentelemetry.autoconfigure.implementation.pipeline.TelemetryItemExporter; import com.azure.monitor.opentelemetry.autoconfigure.implementation.pipeline.TelemetryPipeline; import com.azure.monitor.opentelemetry.autoconfigure.implementation.pipeline.TelemetryPipelineListener; +import com.azure.monitor.opentelemetry.autoconfigure.implementation.statsbeat.CustomerSdkStats; +import com.azure.monitor.opentelemetry.autoconfigure.implementation.statsbeat.CustomerSdkStatsTelemetryPipelineListener; import com.azure.monitor.opentelemetry.autoconfigure.implementation.statsbeat.StatsbeatModule; import com.azure.monitor.opentelemetry.autoconfigure.implementation.statsbeat.StatsbeatTelemetryPipelineListener; @@ -18,13 +20,17 @@ public final class AzureMonitorHelper { public static TelemetryItemExporter createTelemetryItemExporter(HttpPipeline httpPipeline, - StatsbeatModule statsbeatModule, File tempDir, LocalStorageStats localStorageStats) { + StatsbeatModule statsbeatModule, File tempDir, LocalStorageStats localStorageStats, + CustomerSdkStats customerSdkStats) { TelemetryPipeline telemetryPipeline = new TelemetryPipeline(httpPipeline, statsbeatModule::shutdown); TelemetryPipelineListener telemetryPipelineListener; + CustomerSdkStatsTelemetryPipelineListener customerSdkStatsListener + = new CustomerSdkStatsTelemetryPipelineListener(customerSdkStats); if (tempDir == null) { - telemetryPipelineListener = new DiagnosticTelemetryPipelineListener( - "Sending telemetry to the ingestion service", true, " (telemetry will be lost)"); + telemetryPipelineListener = TelemetryPipelineListener + .composite(new DiagnosticTelemetryPipelineListener("Sending telemetry to the ingestion service", true, + " (telemetry will be lost)"), customerSdkStatsListener); } else { telemetryPipelineListener = TelemetryPipelineListener.composite( // suppress warnings on retryable failures, in order to reduce sporadic/annoying @@ -32,7 +38,8 @@ public static TelemetryItemExporter createTelemetryItemExporter(HttpPipeline htt // will log if that retry from disk fails new DiagnosticTelemetryPipelineListener("Sending telemetry to the ingestion service", false, ""), new LocalStorageTelemetryPipelineListener(50, // default to 50MB - TempDirs.getSubDir(tempDir, "telemetry"), telemetryPipeline, localStorageStats, false)); + TempDirs.getSubDir(tempDir, "telemetry"), telemetryPipeline, localStorageStats, false), + customerSdkStatsListener); } return new TelemetryItemExporter(telemetryPipeline, telemetryPipelineListener); diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/samples/java/com/azure/monitor/opentelemetry/autoconfigure/SimpleWebAppSample-README.md b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/samples/java/com/azure/monitor/opentelemetry/autoconfigure/SimpleWebAppSample-README.md new file mode 100644 index 000000000000..50919b28b5d1 --- /dev/null +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/samples/java/com/azure/monitor/opentelemetry/autoconfigure/SimpleWebAppSample-README.md @@ -0,0 +1,142 @@ +# SimpleWebAppSample — Manual Execution Guide + +A long-running web app that generates telemetry and validates customer-facing SDKStats metrics (`Item_Success_Count`, `Item_Dropped_Count`, `Item_Retry_Count`). + +--- + +## Prerequisites + +- **Java 21**: `C:\Program Files\Microsoft\jdk-21.0.10.7-hotspot` +- **Maven 3.9.9**: `C:\apache-maven\apache-maven-3.9.9` +- The `azure-monitor-opentelemetry-autoconfigure` module must be compiled (including test sources). + +--- + +## Step-by-Step + +### 1. Set up environment variables + +Open a PowerShell terminal: + +```powershell +$env:JAVA_HOME = "C:\Program Files\Microsoft\jdk-21.0.10.7-hotspot" +$env:PATH = "C:\apache-maven\apache-maven-3.9.9\bin;$env:JAVA_HOME\bin;$env:PATH" +``` + +### 2. Navigate to the project + +```powershell +cd c:\repo\azure-sdk-for-java\sdk\monitor\azure-monitor-opentelemetry-autoconfigure +``` + +### 3. Compile + +```powershell +mvn compile test-compile +``` + +### 4. Choose a test mode + +Set the SDKStats export interval to 60 seconds (instead of the default 900) so results appear sooner: + +```powershell +$env:APPLICATIONINSIGHTS_SDKSTATS_EXPORT_INTERVAL = "60" +``` + +Then pick **one** of the following modes: + +#### Success mode (default) + +Telemetry is sent to real Azure Monitor. You will see `Item_Success_Count` in the `customMetrics` table. + +```powershell +# No TEST_MODE needed — defaults to "success" +``` + +#### Drop mode + +A local mock server on port 9090 returns **400** for all requests. You will see `Item_Dropped_Count` printed in the console. + +```powershell +$env:TEST_MODE = "drop" +``` + +#### Retry mode + +A local mock server on port 9090 returns **500** for all requests. You will see `Item_Retry_Count` printed in the console. + +```powershell +$env:TEST_MODE = "retry" +``` + +#### Optional: enable Azure SDK logging + +```powershell +$env:AZURE_LOG_LEVEL = "informational" +``` + +### 5. Run the sample + +```powershell +mvn exec:java "-Dexec.mainClass=com.azure.monitor.opentelemetry.autoconfigure.SimpleWebAppSample" "-Dexec.classpathScope=test" +``` + +You should see: + +``` +======================================================== + SimpleWebAppSample running on http://localhost:8080 + Mode: SUCCESS — real Azure Monitor → Item_Success_Count +======================================================== +``` + +### 6. Generate telemetry + +Open a **second** PowerShell terminal and hit the endpoints: + +```powershell +Invoke-WebRequest http://localhost:8080/ +Invoke-WebRequest http://localhost:8080/dependency +Invoke-WebRequest http://localhost:8080/error +Invoke-WebRequest http://localhost:8080/exception +Invoke-WebRequest http://localhost:8080/load +``` + +| Endpoint | What it does | +|----------------|-------------------------------------------| +| `GET /` | Returns "Hello!" (request span) | +| `GET /dependency` | Makes an outbound HTTP call (dependency span) | +| `GET /error` | Simulates an error span | +| `GET /exception` | Throws & records an exception | +| `GET /load` | Fires 20 mixed child spans for volume | + +### 7. Wait ~60 seconds + +After the export interval elapses, you will see SDKStats metrics: + +- **Success mode**: Query `customMetrics` in Azure Monitor: + ```kusto + customMetrics + | where name startswith "Item_" + | project timestamp, name, value, customDimensions + | order by timestamp desc + ``` +- **Drop/Retry mode**: SDKStats payloads appear in the **first terminal's console output**, tagged with `[SDKStats]`: + ``` + ╔══ MOCK INGESTION #4 [SDKStats] ══════════════════════════════ + ║ POST /v2.1/track → returning 400 + ║ {"name":"Metric","data":{"baseData":{"metrics":[{"name":"Item_Dropped_Count",...}]}}} + ╚═══════════════════════════════════════════════════════════════ + ``` + +### 8. Stop the sample + +Press `Ctrl+C` in the terminal running the sample. + +--- + +## Notes + +- In **drop/retry** modes, both application telemetry and SDKStats metrics go through the same mock pipeline. SDKStats metrics are visible only in the console output, not in Azure Monitor. +- The mock server automatically gunzips incoming payloads and prints each telemetry item on its own line. +- SDKStats dimensions include: `computeType`, `language`, `version`, `telemetry_type`, `telemetry_success`, and mode-specific fields (`drop.code`/`drop.reason` or `retry.code`/`retry.reason`). diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/samples/java/com/azure/monitor/opentelemetry/autoconfigure/SimpleWebAppSample.java b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/samples/java/com/azure/monitor/opentelemetry/autoconfigure/SimpleWebAppSample.java new file mode 100644 index 000000000000..55c2354f9a74 --- /dev/null +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/samples/java/com/azure/monitor/opentelemetry/autoconfigure/SimpleWebAppSample.java @@ -0,0 +1,364 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.monitor.opentelemetry.autoconfigure; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.metrics.LongCounter; +import io.opentelemetry.api.metrics.Meter; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.api.trace.StatusCode; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.context.Scope; +import io.opentelemetry.sdk.autoconfigure.AutoConfiguredOpenTelemetrySdk; +import io.opentelemetry.sdk.autoconfigure.AutoConfiguredOpenTelemetrySdkBuilder; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.InetSocketAddress; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.zip.GZIPInputStream; + +/** + * A simple long-running web app sample that generates requests, dependencies, traces, and + * exceptions — ideal for observing customer-facing SDKStats metrics in Azure Monitor. + * + *

Test Modes (set via {@code TEST_MODE} environment variable):

+ *
    + *
  • success (default) — normal operation, telemetry reaches ingestion + * → {@code Item_Success_Count} visible in Azure Monitor
  • + *
  • drop — a local mock server returns 400 for all requests + * → {@code Item_Dropped_Count} with drop code 400 (visible in mock server console output)
  • + *
  • retry — a local mock server returns 500 for all requests + * → {@code Item_Retry_Count} with retry code 500 (visible in mock server console output)
  • + *
+ * + *

In drop/retry modes, a mock ingestion server runs on port 9090. Both application telemetry + * and SDKStats metrics go through the same pipeline, so SDKStats metrics also hit the mock. + * The mock server logs all received payloads (gunzipped) so you can inspect the generated + * {@code Item_Dropped_Count} and {@code Item_Retry_Count} TelemetryItems in the console.

+ * + *

Set {@code APPLICATIONINSIGHTS_SDKSTATS_EXPORT_INTERVAL} to a lower value (e.g. 60) + * so you don't have to wait 15 minutes for the first SDKStats export.

+ * + *

Endpoints:

+ *
    + *
  • {@code GET /} — returns "Hello!"
  • + *
  • {@code GET /dependency} — makes an outbound HTTP call
  • + *
  • {@code GET /error} — simulates an error span
  • + *
  • {@code GET /exception} — throws and records an exception
  • + *
  • {@code GET /load} — fires a batch of 20 mixed requests internally (for quick volume)
  • + *
+ * + *

Usage:

+ *
+ * # Test Item_Success_Count (default — goes to real Azure Monitor):
+ * set APPLICATIONINSIGHTS_SDKSTATS_EXPORT_INTERVAL=60
+ * mvn compile test-compile exec:java -Dexec.mainClass=...SimpleWebAppSample -Dexec.classpathScope=test
+ *
+ * # Test Item_Dropped_Count (mock server returns 400):
+ * set TEST_MODE=drop
+ * set APPLICATIONINSIGHTS_SDKSTATS_EXPORT_INTERVAL=60
+ * mvn compile test-compile exec:java -Dexec.mainClass=...SimpleWebAppSample -Dexec.classpathScope=test
+ *
+ * # Test Item_Retry_Count (mock server returns 500):
+ * set TEST_MODE=retry
+ * set APPLICATIONINSIGHTS_SDKSTATS_EXPORT_INTERVAL=60
+ * mvn compile test-compile exec:java -Dexec.mainClass=...SimpleWebAppSample -Dexec.classpathScope=test
+ * 
+ */ +public class SimpleWebAppSample { + + // ── Connection string for success mode (real Azure Monitor) ───────────── + private static final String CONNECTION_STRING_SUCCESS + = "InstrumentationKey=66f50587-a821-4a96-8511-fa96e8f28fd7" + + ";IngestionEndpoint=https://westus2-1.in.applicationinsights.azure.com/" + + ";LiveEndpoint=https://westus2.livediagnostics.monitor.azure.com/" + + ";ApplicationId=b9fa6c07-f06f-414c-b222-f3207aab66cd"; + + // ── Connection string for mock server (drop/retry modes) ──────────────── + private static final int MOCK_PORT = 9090; + private static final String CONNECTION_STRING_MOCK + = "InstrumentationKey=66f50587-a821-4a96-8511-fa96e8f28fd7" + + ";IngestionEndpoint=http://localhost:" + MOCK_PORT + "/" + + ";LiveEndpoint=http://localhost:" + MOCK_PORT + "/"; + + private static final int APP_PORT = 8080; + + private static Tracer tracer; + private static LongCounter requestCounter; + + public static void main(String[] args) throws IOException { + // ── 0. Determine test mode ────────────────────────────────────────── + String testMode = System.getenv("TEST_MODE"); + if (testMode == null || testMode.isEmpty()) { + testMode = "success"; + } + testMode = testMode.toLowerCase(); + + String connectionString; + String modeDescription; + int mockStatusCode = 0; + switch (testMode) { + case "drop": + connectionString = CONNECTION_STRING_MOCK; + modeDescription = "DROP — mock server returns 400 → Item_Dropped_Count"; + mockStatusCode = 400; + break; + case "retry": + connectionString = CONNECTION_STRING_MOCK; + modeDescription = "RETRY — mock server returns 500 → Item_Retry_Count"; + mockStatusCode = 500; + break; + default: + connectionString = CONNECTION_STRING_SUCCESS; + modeDescription = "SUCCESS — real Azure Monitor → Item_Success_Count"; + break; + } + + // ── 1. Start mock ingestion server for drop/retry modes ───────────── + if (mockStatusCode > 0) { + startMockIngestionServer(mockStatusCode); + } + + // ── 2. Configure OpenTelemetry + Azure Monitor ────────────────────── + AutoConfiguredOpenTelemetrySdkBuilder sdkBuilder = AutoConfiguredOpenTelemetrySdk.builder(); + AzureMonitorAutoConfigure.customize(sdkBuilder, connectionString); + OpenTelemetry openTelemetry = sdkBuilder.build().getOpenTelemetrySdk(); + + tracer = openTelemetry.getTracer("SimpleWebApp"); + Meter meter = openTelemetry.meterBuilder("SimpleWebApp").build(); + requestCounter = meter.counterBuilder("http.server.request_count").build(); + + // ── 3. Start the web app HTTP server ──────────────────────────────── + HttpServer server = HttpServer.create(new InetSocketAddress(APP_PORT), 0); + server.createContext("/", SimpleWebAppSample::handleRoot); + server.createContext("/dependency", SimpleWebAppSample::handleDependency); + server.createContext("/error", SimpleWebAppSample::handleError); + server.createContext("/exception", SimpleWebAppSample::handleException); + server.createContext("/load", SimpleWebAppSample::handleLoad); + server.start(); + + System.out.println("========================================================"); + System.out.println(" SimpleWebAppSample running on http://localhost:" + APP_PORT); + System.out.println(" Mode: " + modeDescription); + System.out.println("========================================================"); + System.out.println(" Endpoints:"); + System.out.println(" GET / — hello"); + System.out.println(" GET /dependency — outbound HTTP call"); + System.out.println(" GET /error — simulated error span"); + System.out.println(" GET /exception — throws & records exception"); + System.out.println(" GET /load — fires 20 mixed requests"); + System.out.println(); + System.out.println(" Test modes (set TEST_MODE env var):"); + System.out.println(" success (default) — Item_Success_Count (real Azure Monitor)"); + System.out.println(" drop — Item_Dropped_Count (mock → 400)"); + System.out.println(" retry — Item_Retry_Count (mock → 500)"); + System.out.println(); + System.out.println(" Tip: set APPLICATIONINSIGHTS_SDKSTATS_EXPORT_INTERVAL=60"); + System.out.println(" to see customer SDKStats sooner."); + System.out.println("========================================================"); + } + + // ── Mock Ingestion Server ─────────────────────────────────────────────── + + /** + * Starts a local HTTP server on {@link #MOCK_PORT} that simulates ingestion failures. + * It handles POST /v2.1/track by returning the given status code and logging the + * gunzipped request payload so you can inspect SDKStats TelemetryItems in the console. + */ + @SuppressWarnings("try") + private static void startMockIngestionServer(int statusCode) throws IOException { + AtomicInteger requestCount = new AtomicInteger(); + HttpServer mock = HttpServer.create(new InetSocketAddress(MOCK_PORT), 0); + mock.createContext("/", exchange -> { + int reqNum = requestCount.incrementAndGet(); + String method = exchange.getRequestMethod(); + String path = exchange.getRequestURI().getPath(); + + // Read and gunzip the request body + String body = "(empty)"; + try { + byte[] rawBytes = readAllBytes(exchange.getRequestBody()); + if (rawBytes.length > 0) { + String contentEncoding = exchange.getRequestHeaders().getFirst("Content-Encoding"); + if ("gzip".equalsIgnoreCase(contentEncoding)) { + body = gunzip(rawBytes); + } else { + body = new String(rawBytes, StandardCharsets.UTF_8); + } + } + } catch (Exception e) { + body = "(failed to read body: " + e.getMessage() + ")"; + } + + // Log the request to console + boolean isSdkStats = body.contains("Item_Success_Count") + || body.contains("Item_Dropped_Count") + || body.contains("Item_Retry_Count"); + String tag = isSdkStats ? " [SDKStats]" : ""; + System.out.println(); + System.out.println("╔══ MOCK INGESTION #" + reqNum + tag + " ══════════════════════════════"); + System.out.println("║ " + method + " " + path + " → returning " + statusCode); + // Print each telemetry item on its own line for readability + for (String line : body.split("\n")) { + System.out.println("║ " + line.trim()); + } + System.out.println("╚═══════════════════════════════════════════════════════"); + + // Return the configured error status with a minimal JSON body + String responseBody = "{\"itemsReceived\":0,\"itemsAccepted\":0,\"errors\":[]}"; + byte[] responseBytes = responseBody.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(statusCode, responseBytes.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(responseBytes); + } + }); + mock.start(); + System.out.println("[Mock] Ingestion server started on http://localhost:" + MOCK_PORT + + " — returning " + statusCode + " for all requests"); + } + + private static byte[] readAllBytes(InputStream is) throws IOException { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + byte[] tmp = new byte[4096]; + int n; + while ((n = is.read(tmp)) != -1) { + buffer.write(tmp, 0, n); + } + return buffer.toByteArray(); + } + + private static String gunzip(byte[] compressed) throws IOException { + try (GZIPInputStream gis = new GZIPInputStream(new java.io.ByteArrayInputStream(compressed))) { + return new String(readAllBytes(gis), StandardCharsets.UTF_8); + } + } + + // ── Handlers ──────────────────────────────────────────────────────────── + + @SuppressWarnings("try") + private static void handleRoot(HttpExchange exchange) throws IOException { + Span span = tracer.spanBuilder("GET /").setSpanKind(SpanKind.SERVER).startSpan(); + try (Scope ignored = span.makeCurrent()) { + requestCounter.add(1, Attributes.of(AttributeKey.stringKey("path"), "/")); + respond(exchange, 200, "Hello from SimpleWebAppSample!"); + } finally { + span.end(); + } + } + + @SuppressWarnings("try") + private static void handleDependency(HttpExchange exchange) throws IOException { + Span span = tracer.spanBuilder("GET /dependency").setSpanKind(SpanKind.SERVER).startSpan(); + try (Scope ignored = span.makeCurrent()) { + requestCounter.add(1, Attributes.of(AttributeKey.stringKey("path"), "/dependency")); + + // Simulate an outbound dependency call + Span depSpan = tracer.spanBuilder("HTTP GET httpbin.org").setSpanKind(SpanKind.CLIENT).startSpan(); + try (Scope depScope = depSpan.makeCurrent()) { + URL url = new URL("https://httpbin.org/get"); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setConnectTimeout(5000); + conn.setReadTimeout(5000); + int status = conn.getResponseCode(); + depSpan.setAttribute("http.status_code", status); + conn.disconnect(); + respond(exchange, 200, "Dependency call returned " + status); + } catch (Exception e) { + depSpan.recordException(e); + depSpan.setStatus(StatusCode.ERROR, e.getMessage()); + respond(exchange, 502, "Dependency call failed: " + e.getMessage()); + } finally { + depSpan.end(); + } + } finally { + span.end(); + } + } + + @SuppressWarnings("try") + private static void handleError(HttpExchange exchange) throws IOException { + Span span = tracer.spanBuilder("GET /error").setSpanKind(SpanKind.SERVER).startSpan(); + try (Scope ignored = span.makeCurrent()) { + requestCounter.add(1, Attributes.of(AttributeKey.stringKey("path"), "/error")); + span.setStatus(StatusCode.ERROR, "simulated server error"); + respond(exchange, 500, "Simulated 500 error"); + } finally { + span.end(); + } + } + + @SuppressWarnings("try") + private static void handleException(HttpExchange exchange) throws IOException { + Span span = tracer.spanBuilder("GET /exception").setSpanKind(SpanKind.SERVER).startSpan(); + try (Scope ignored = span.makeCurrent()) { + requestCounter.add(1, Attributes.of(AttributeKey.stringKey("path"), "/exception")); + try { + throw new RuntimeException("Something went wrong!"); + } catch (RuntimeException e) { + span.recordException(e); + span.setStatus(StatusCode.ERROR, e.getMessage()); + respond(exchange, 500, "Exception recorded: " + e.getMessage()); + } + } finally { + span.end(); + } + } + + @SuppressWarnings("try") + private static void handleLoad(HttpExchange exchange) throws IOException { + Span span = tracer.spanBuilder("GET /load").setSpanKind(SpanKind.SERVER).startSpan(); + try (Scope ignored = span.makeCurrent()) { + // Generate a batch of child spans to create volume + int count = 20; + for (int i = 0; i < count; i++) { + String op = randomOp(); + Span child = tracer.spanBuilder("load-" + op).setSpanKind(SpanKind.INTERNAL).startSpan(); + try (Scope childScope = child.makeCurrent()) { + // simulate some work + Thread.sleep(ThreadLocalRandom.current().nextInt(5, 50)); + if ("error".equals(op)) { + child.setStatus(StatusCode.ERROR, "load error"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + child.end(); + } + } + requestCounter.add(count, Attributes.of(AttributeKey.stringKey("path"), "/load")); + respond(exchange, 200, "Generated " + count + " spans"); + } finally { + span.end(); + } + } + + // ── Helpers ───────────────────────────────────────────────────────────── + + private static void respond(HttpExchange exchange, int status, String body) throws IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "text/plain; charset=UTF-8"); + exchange.sendResponseHeaders(status, bytes.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(bytes); + } + } + + private static String randomOp() { + String[] ops = {"ok", "ok", "ok", "error", "dep"}; + return ops[ThreadLocalRandom.current().nextInt(ops.length)]; + } +} diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/test/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsExceptionCategoryTest.java b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/test/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsExceptionCategoryTest.java new file mode 100644 index 000000000000..8201856acdd0 --- /dev/null +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/test/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsExceptionCategoryTest.java @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.monitor.opentelemetry.autoconfigure.implementation.statsbeat; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.net.ConnectException; +import java.net.SocketTimeoutException; +import java.net.UnknownHostException; +import java.util.concurrent.TimeoutException; + +import static org.assertj.core.api.Assertions.assertThat; + +public class CustomerSdkStatsExceptionCategoryTest { + + @Test + public void testSocketTimeoutException() { + assertThat(CustomerSdkStatsExceptionCategory.categorize(new SocketTimeoutException("Read timed out"))) + .isEqualTo("Timeout exception"); + assertThat(CustomerSdkStatsExceptionCategory.isTimeout(new SocketTimeoutException("Read timed out"))).isTrue(); + } + + @Test + public void testTimeoutException() { + assertThat(CustomerSdkStatsExceptionCategory.categorize(new TimeoutException("timeout"))) + .isEqualTo("Timeout exception"); + assertThat(CustomerSdkStatsExceptionCategory.isTimeout(new TimeoutException("timeout"))).isTrue(); + } + + @Test + public void testUnknownHostException() { + assertThat(CustomerSdkStatsExceptionCategory.categorize(new UnknownHostException("host.example.com"))) + .isEqualTo("Network exception"); + assertThat(CustomerSdkStatsExceptionCategory.isTimeout(new UnknownHostException("host.example.com"))).isFalse(); + } + + @Test + public void testConnectException() { + assertThat(CustomerSdkStatsExceptionCategory.categorize(new ConnectException("Connection refused"))) + .isEqualTo("Network exception"); + } + + @Test + public void testIOException() { + assertThat(CustomerSdkStatsExceptionCategory.categorize(new IOException("Disk full"))) + .isEqualTo("Storage exception"); + } + + @Test + public void testGenericRuntimeException() { + assertThat(CustomerSdkStatsExceptionCategory.categorize(new RuntimeException("Something went wrong"))) + .isEqualTo("Client exception"); + assertThat(CustomerSdkStatsExceptionCategory.isTimeout(new RuntimeException("Something went wrong"))).isFalse(); + } + + @Test + public void testNullThrowable() { + assertThat(CustomerSdkStatsExceptionCategory.categorize(null)).isEqualTo("Client exception"); + assertThat(CustomerSdkStatsExceptionCategory.isTimeout(null)).isFalse(); + } + + @Test + public void testWrappedSocketTimeoutException() { + // A SocketTimeoutException wrapped in a RuntimeException should still be detected via cause + RuntimeException wrapper = new RuntimeException("wrapper", new SocketTimeoutException("Read timed out")); + assertThat(CustomerSdkStatsExceptionCategory.categorize(wrapper)).isEqualTo("Timeout exception"); + assertThat(CustomerSdkStatsExceptionCategory.isTimeout(wrapper)).isTrue(); + } + + @Test + public void testWrappedUnknownHostException() { + RuntimeException wrapper = new RuntimeException("wrapper", new UnknownHostException("host.example.com")); + assertThat(CustomerSdkStatsExceptionCategory.categorize(wrapper)).isEqualTo("Network exception"); + } +} diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/test/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsTelemetryPipelineListenerTest.java b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/test/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsTelemetryPipelineListenerTest.java new file mode 100644 index 000000000000..f57eab078b77 --- /dev/null +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/test/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsTelemetryPipelineListenerTest.java @@ -0,0 +1,155 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.monitor.opentelemetry.autoconfigure.implementation.statsbeat; + +import com.azure.monitor.opentelemetry.autoconfigure.implementation.pipeline.TelemetryPipelineRequest; +import com.azure.monitor.opentelemetry.autoconfigure.implementation.pipeline.TelemetryPipelineResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.net.MalformedURLException; +import java.net.SocketTimeoutException; +import java.net.URL; +import java.net.UnknownHostException; +import java.nio.ByteBuffer; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +public class CustomerSdkStatsTelemetryPipelineListenerTest { + + private CustomerSdkStats customerSdkStats; + private CustomerSdkStatsTelemetryPipelineListener listener; + + @BeforeEach + public void init() { + customerSdkStats = new CustomerSdkStats("unknown", "java", "3.5.1"); + listener = new CustomerSdkStatsTelemetryPipelineListener(customerSdkStats); + } + + @Test + public void testSuccessResponse() { + Map itemCounts = new HashMap<>(); + itemCounts.put("REQUEST", 5L); + itemCounts.put("DEPENDENCY", 3L); + + TelemetryPipelineRequest request = createRequest(itemCounts); + TelemetryPipelineResponse response = new TelemetryPipelineResponse(200, "OK"); + + listener.onResponse(request, response); + + assertThat(customerSdkStats.getSuccessCount("REQUEST")).isEqualTo(5); + assertThat(customerSdkStats.getSuccessCount("DEPENDENCY")).isEqualTo(3); + } + + @Test + public void testRetryableStatusCode429() { + Map itemCounts = Collections.singletonMap("TRACE", 10L); + + TelemetryPipelineRequest request = createRequest(itemCounts); + TelemetryPipelineResponse response = new TelemetryPipelineResponse(429, "Too Many Requests"); + + listener.onResponse(request, response); + + assertThat(customerSdkStats.getRetryCount("TRACE", "429")).isEqualTo(10); + } + + @Test + public void testRetryableStatusCode500() { + Map itemCounts = Collections.singletonMap("DEPENDENCY", 5L); + + TelemetryPipelineRequest request = createRequest(itemCounts); + TelemetryPipelineResponse response = new TelemetryPipelineResponse(500, "Internal Server Error"); + + listener.onResponse(request, response); + + assertThat(customerSdkStats.getRetryCount("DEPENDENCY", "500")).isEqualTo(5); + } + + @Test + public void testNonRetryableStatusCode402() { + Map itemCounts = Collections.singletonMap("CUSTOM_METRIC", 20L); + + TelemetryPipelineRequest request = createRequest(itemCounts); + TelemetryPipelineResponse response = new TelemetryPipelineResponse(402, "Payment Required"); + + listener.onResponse(request, response); + + assertThat(customerSdkStats.getDroppedCount("CUSTOM_METRIC", "402")).isEqualTo(20); + } + + @Test + public void testTimeoutException() { + Map itemCounts = Collections.singletonMap("REQUEST", 7L); + + TelemetryPipelineRequest request = createRequest(itemCounts); + + listener.onException(request, "Read timed out", new SocketTimeoutException("Read timed out")); + + assertThat(customerSdkStats.getRetryCount("REQUEST", "CLIENT_TIMEOUT")).isEqualTo(7); + } + + @Test + public void testNetworkException() { + Map itemCounts = Collections.singletonMap("DEPENDENCY", 12L); + + TelemetryPipelineRequest request = createRequest(itemCounts); + + listener.onException(request, "Unknown host", new UnknownHostException("host.example.com")); + + assertThat(customerSdkStats.getRetryCount("DEPENDENCY", "CLIENT_EXCEPTION")).isEqualTo(12); + } + + @Test + public void testEmptyItemCountsSkipped() { + TelemetryPipelineRequest request = createRequest(Collections.emptyMap()); + TelemetryPipelineResponse response = new TelemetryPipelineResponse(200, "OK"); + + listener.onResponse(request, response); + + // Nothing should be tracked + assertThat(customerSdkStats.getSuccessCount("REQUEST")).isEqualTo(0); + } + + @Test + public void testRedirectSkipped() { + Map itemCounts = Collections.singletonMap("REQUEST", 5L); + + TelemetryPipelineRequest request = createRequest(itemCounts); + TelemetryPipelineResponse response = new TelemetryPipelineResponse(307, "Temporary Redirect"); + + listener.onResponse(request, response); + + // Redirects should not be tracked + assertThat(customerSdkStats.getSuccessCount("REQUEST")).isEqualTo(0); + assertThat(customerSdkStats.getRetryCount("REQUEST", "307")).isEqualTo(0); + assertThat(customerSdkStats.getDroppedCount("REQUEST", "307")).isEqualTo(0); + } + + @Test + public void testReasonPhraseForStatusCode() { + assertThat(CustomerSdkStatsTelemetryPipelineListener.getReasonPhraseForStatusCode(402)) + .isEqualTo("Exceeded daily quota"); + assertThat(CustomerSdkStatsTelemetryPipelineListener.getReasonPhraseForStatusCode(429)) + .isEqualTo("Too many requests"); + assertThat(CustomerSdkStatsTelemetryPipelineListener.getReasonPhraseForStatusCode(500)) + .isEqualTo("Internal server error"); + assertThat(CustomerSdkStatsTelemetryPipelineListener.getReasonPhraseForStatusCode(999)).isNull(); + } + + private static TelemetryPipelineRequest createRequest(Map itemCountsByType) { + List byteBuffers = Collections.singletonList(ByteBuffer.allocate(0)); + try { + URL url = new URL("https://dc.services.visualstudio.com/v2.1/track"); + return new TelemetryPipelineRequest(url, "InstrumentationKey=00000000-0000-0000-0000-0FEEDDADBEEF", + "00000000-0000-0000-0000-0FEEDDADBEEF", byteBuffers, itemCountsByType, Collections.emptyMap(), + Collections.emptyMap()); + } catch (MalformedURLException e) { + throw new RuntimeException(e); + } + } +} diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/test/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsTelemetryTypeTest.java b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/test/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsTelemetryTypeTest.java new file mode 100644 index 000000000000..488012fed406 --- /dev/null +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/test/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsTelemetryTypeTest.java @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.monitor.opentelemetry.autoconfigure.implementation.statsbeat; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class CustomerSdkStatsTelemetryTypeTest { + + @Test + public void testRequestMapping() { + assertThat(CustomerSdkStatsTelemetryType.fromTelemetryItemName("Request")).isEqualTo("REQUEST"); + } + + @Test + public void testDependencyMapping() { + assertThat(CustomerSdkStatsTelemetryType.fromTelemetryItemName("RemoteDependency")).isEqualTo("DEPENDENCY"); + } + + @Test + public void testTraceMapping() { + assertThat(CustomerSdkStatsTelemetryType.fromTelemetryItemName("Message")).isEqualTo("TRACE"); + } + + @Test + public void testExceptionMapping() { + assertThat(CustomerSdkStatsTelemetryType.fromTelemetryItemName("Exception")).isEqualTo("EXCEPTION"); + } + + @Test + public void testMetricMapping() { + assertThat(CustomerSdkStatsTelemetryType.fromTelemetryItemName("Metric")).isEqualTo("CUSTOM_METRIC"); + } + + @Test + public void testEventMapping() { + assertThat(CustomerSdkStatsTelemetryType.fromTelemetryItemName("Event")).isEqualTo("CUSTOM_EVENT"); + } + + @Test + public void testPageViewMapping() { + assertThat(CustomerSdkStatsTelemetryType.fromTelemetryItemName("PageView")).isEqualTo("PAGE_VIEW"); + } + + @Test + public void testAvailabilityMapping() { + assertThat(CustomerSdkStatsTelemetryType.fromTelemetryItemName("Availability")).isEqualTo("AVAILABILITY"); + } + + @Test + public void testStatsbeatReturnsNull() { + assertThat(CustomerSdkStatsTelemetryType.fromTelemetryItemName("Statsbeat")).isNull(); + } + + @Test + public void testUnknownTypeReturnsNull() { + assertThat(CustomerSdkStatsTelemetryType.fromTelemetryItemName("UnknownType")).isNull(); + } +} diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/test/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsTest.java b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/test/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsTest.java new file mode 100644 index 000000000000..767a5ed565dc --- /dev/null +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/test/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsTest.java @@ -0,0 +1,170 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.monitor.opentelemetry.autoconfigure.implementation.statsbeat; + +import com.azure.monitor.opentelemetry.autoconfigure.implementation.configuration.ConnectionString; +import com.azure.monitor.opentelemetry.autoconfigure.implementation.models.TelemetryItem; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +public class CustomerSdkStatsTest { + + private CustomerSdkStats customerSdkStats; + private static final ConnectionString CONNECTION_STRING + = ConnectionString.parse("InstrumentationKey=00000000-0000-0000-0000-0FEEDDADBEEF"); + private static final String SDK_VERSION = "java:3.5.1"; + private static final String CLOUD_ROLE = "TestRole"; + private static final String CLOUD_ROLE_INSTANCE = "TestInstance"; + + @BeforeEach + public void init() { + customerSdkStats = new CustomerSdkStats("unknown", "java", "3.5.1"); + } + + @Test + public void testIncrementSuccessCount() { + Map itemCountsByType = new HashMap<>(); + itemCountsByType.put("REQUEST", 5L); + itemCountsByType.put("DEPENDENCY", 3L); + + customerSdkStats.incrementSuccessCount(itemCountsByType); + + assertThat(customerSdkStats.getSuccessCount("REQUEST")).isEqualTo(5); + assertThat(customerSdkStats.getSuccessCount("DEPENDENCY")).isEqualTo(3); + assertThat(customerSdkStats.getSuccessCount("TRACE")).isEqualTo(0); + } + + @Test + public void testIncrementSuccessCountMultipleTimes() { + Map batch1 = Collections.singletonMap("REQUEST", 3L); + Map batch2 = Collections.singletonMap("REQUEST", 7L); + + customerSdkStats.incrementSuccessCount(batch1); + customerSdkStats.incrementSuccessCount(batch2); + + assertThat(customerSdkStats.getSuccessCount("REQUEST")).isEqualTo(10); + } + + @Test + public void testIncrementDroppedCount() { + Map itemCountsByType = new HashMap<>(); + itemCountsByType.put("TRACE", 10L); + Map successItems = Collections.emptyMap(); + Map failureItems = Collections.emptyMap(); + + customerSdkStats.incrementDroppedCount(itemCountsByType, "402", "Exceeded daily quota", successItems, + failureItems); + + assertThat(customerSdkStats.getDroppedCount("TRACE", "402")).isEqualTo(10); + } + + @Test + public void testIncrementDroppedCountWithTelemetrySuccess() { + Map itemCountsByType = new HashMap<>(); + itemCountsByType.put("REQUEST", 8L); + Map successItems = Collections.singletonMap("REQUEST", 5L); + Map failureItems = Collections.singletonMap("REQUEST", 3L); + + customerSdkStats.incrementDroppedCount(itemCountsByType, "402", "Exceeded daily quota", successItems, + failureItems); + + // Total dropped for REQUEST with code 402 should be 8 (5 success + 3 failure) + assertThat(customerSdkStats.getDroppedCount("REQUEST", "402")).isEqualTo(8); + } + + @Test + public void testIncrementRetryCount() { + Map itemCountsByType = new HashMap<>(); + itemCountsByType.put("DEPENDENCY", 15L); + itemCountsByType.put("REQUEST", 5L); + + customerSdkStats.incrementRetryCount(itemCountsByType, "429", "Too many requests"); + + assertThat(customerSdkStats.getRetryCount("DEPENDENCY", "429")).isEqualTo(15); + assertThat(customerSdkStats.getRetryCount("REQUEST", "429")).isEqualTo(5); + } + + @Test + public void testIncrementRetryCountClientTimeout() { + Map itemCountsByType = Collections.singletonMap("TRACE", 20L); + + customerSdkStats.incrementRetryCount(itemCountsByType, CustomerSdkStats.RETRY_CODE_CLIENT_TIMEOUT, + "Timeout exception"); + + assertThat(customerSdkStats.getRetryCount("TRACE", CustomerSdkStats.RETRY_CODE_CLIENT_TIMEOUT)).isEqualTo(20); + } + + @Test + public void testCollectAndResetReturnsCorrectMetrics() { + // Add some data + customerSdkStats.incrementSuccessCount(Collections.singletonMap("REQUEST", 10L)); + customerSdkStats.incrementDroppedCount(Collections.singletonMap("DEPENDENCY", 5L), "402", + "Exceeded daily quota", Collections.emptyMap(), Collections.emptyMap()); + customerSdkStats.incrementRetryCount(Collections.singletonMap("TRACE", 3L), "429", "Too many requests"); + + List items + = customerSdkStats.collectAndReset(CONNECTION_STRING, SDK_VERSION, CLOUD_ROLE, CLOUD_ROLE_INSTANCE); + + // Should have 3 metric items + assertThat(items).hasSize(3); + + // Verify that counters are cleared + assertThat(customerSdkStats.getSuccessCount("REQUEST")).isEqualTo(0); + assertThat(customerSdkStats.getDroppedCount("DEPENDENCY", "402")).isEqualTo(0); + assertThat(customerSdkStats.getRetryCount("TRACE", "429")).isEqualTo(0); + } + + @Test + public void testCollectAndResetEmptyReturnsEmptyList() { + List items + = customerSdkStats.collectAndReset(CONNECTION_STRING, SDK_VERSION, CLOUD_ROLE, CLOUD_ROLE_INSTANCE); + + assertThat(items).isEmpty(); + } + + @Test + public void testCollectAndResetClearsCounters() { + customerSdkStats.incrementSuccessCount(Collections.singletonMap("REQUEST", 100L)); + + // First collect + List items1 + = customerSdkStats.collectAndReset(CONNECTION_STRING, SDK_VERSION, CLOUD_ROLE, CLOUD_ROLE_INSTANCE); + assertThat(items1).hasSize(1); + + // Second collect should be empty + List items2 + = customerSdkStats.collectAndReset(CONNECTION_STRING, SDK_VERSION, CLOUD_ROLE, CLOUD_ROLE_INSTANCE); + assertThat(items2).isEmpty(); + } + + @Test + public void testConcurrentIncrements() throws InterruptedException { + int threads = 10; + int incrementsPerThread = 1000; + ExecutorService executor = Executors.newFixedThreadPool(threads); + + for (int i = 0; i < threads; i++) { + executor.submit(() -> { + for (int j = 0; j < incrementsPerThread; j++) { + customerSdkStats.incrementSuccessCount(Collections.singletonMap("REQUEST", 1L)); + } + }); + } + + executor.shutdown(); + assertThat(executor.awaitTermination(10, TimeUnit.SECONDS)).isTrue(); + + assertThat(customerSdkStats.getSuccessCount("REQUEST")).isEqualTo(threads * incrementsPerThread); + } +} From d967e756268b16fcfbdf8485b41b15cc34f9a895 Mon Sep 17 00:00:00 2001 From: Rajkumar Rangaraj Date: Mon, 23 Feb 2026 18:05:15 -0800 Subject: [PATCH 2/8] copilot feedback --- .../PR_CHANGES.md | 76 +++++++++++++++++++ .../AzureMonitorExporterBuilder.java | 40 ++++++---- .../pipeline/TelemetryItemExporter.java | 3 +- .../pipeline/TelemetryPipelineRequest.java | 13 +++- .../statsbeat/CustomerSdkStats.java | 8 +- ...omerSdkStatsTelemetryPipelineListener.java | 25 ++++-- .../utils/AzureMonitorHelper.java | 33 ++++---- .../autoconfigure/SimpleWebAppSample.java | 13 ++-- 8 files changed, 164 insertions(+), 47 deletions(-) create mode 100644 sdk/monitor/azure-monitor-opentelemetry-autoconfigure/PR_CHANGES.md diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/PR_CHANGES.md b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/PR_CHANGES.md new file mode 100644 index 000000000000..a7d7c7bb33bc --- /dev/null +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/PR_CHANGES.md @@ -0,0 +1,76 @@ +# PR: Add customer-facing SDKStats metrics (Item_Success_Count, Item_Dropped_Count, Item_Retry_Count) + +Implements customer-facing SDKStats per the [customer_facing_sdk_stats.md](customer_facing_sdk_stats.md) spec. The exporter now tracks per-telemetry-type success, drop, and retry counts and periodically exports them as `Metric` TelemetryItems through the existing pipeline to the customer's own Application Insights resource. + +--- + +## New files + +### Production + +| File | Purpose | +|------|---------| +| `CustomerSdkStats.java` | Thread-safe accumulator (`ConcurrentHashMap`) for three counter families: `successCounts`, `droppedCounts`, `retryCounts`. `collectAndReset()` atomically snapshots and clears all counters, returning a list of `TelemetryItem` metrics with the correct dimensions (`computeType`, `language`, `version`, `telemetry_type`, `telemetry_success`, `drop.code`/`drop.reason`, `retry.code`/`retry.reason`). Static factory `create(version)` auto-detects the resource provider. | +| `CustomerSdkStatsTelemetryType.java` | Maps `TelemetryItem.getName()` → spec dimension strings: `Request`→`REQUEST`, `RemoteDependency`→`DEPENDENCY`, `Message`→`TRACE`, `Exception`→`EXCEPTION`, `Metric`→`CUSTOM_METRIC`, `Event`→`CUSTOM_EVENT`, `PageView`→`PAGE_VIEW`, `Availability`→`AVAILABILITY`. Returns `null` for internal items (e.g. `Statsbeat`) to skip counting. | +| `CustomerSdkStatsExceptionCategory.java` | Classifies exceptions into low-cardinality reason strings for `drop.reason`/`retry.reason`: `"Timeout exception"`, `"Network exception"`, `"Storage exception"`, `"Client exception"`. Traverses the cause chain (depth ≤ 10). Also provides `isTimeout()` to choose between `CLIENT_TIMEOUT` and `CLIENT_EXCEPTION` retry codes. | +| `CustomerSdkStatsTelemetryPipelineListener.java` | `TelemetryPipelineListener` that routes pipeline responses to the accumulator: 200→success, retryable (401/403/408/429/500/502/503/504)→retry, redirect (307/308)→skip, all others→drop. `onException` categorizes the throwable and records a retry. Includes `getReasonPhraseForStatusCode()` for common HTTP status codes. | + +### Tests + +| File | # Tests | +|------|---------| +| `CustomerSdkStatsTest.java` | 10 (accumulation, reset, concurrent increments, telemetry_success split) | +| `CustomerSdkStatsTelemetryTypeTest.java` | 10 (all 8 mappings + Statsbeat→null + unknown→null) | +| `CustomerSdkStatsExceptionCategoryTest.java` | 9 (timeout, network, storage, client, null, wrapped exceptions) | +| `CustomerSdkStatsTelemetryPipelineListenerTest.java` | 9 (success, retry 429/500, drop 402, timeout/network exception, empty skip, redirect skip, reason phrases) | +| **Total** | **38 tests** | + +### Sample + +| File | Purpose | +|------|---------| +| `SimpleWebAppSample.java` | Long-running web app with 5 endpoints and 3 test modes (`success`/`drop`/`retry`). In drop/retry modes, a built-in mock ingestion server on port 9090 returns the configured error status and prints all gunzipped payloads to the console, tagged `[SDKStats]` when applicable. | +| `SimpleWebAppSample-README.md` | Step-by-step manual execution guide with env var setup, endpoint table, Kusto query, and expected console output. | + +--- + +## Modified files + +| File | Change | +|------|--------| +| `TelemetryItemExporter.java` | Added `computeItemCountMetadata()` to compute per-type item counts (with success/failure split for REQUEST/DEPENDENCY) before serialization. Added `sendWithoutTracking()` to send items without triggering recursive customer SDKStats counting. Updated `internalSendByBatch()` to pass item count maps to the pipeline. | +| `TelemetryPipelineRequest.java` | Added `itemCountsByType`, `successItemCountsByType`, `failureItemCountsByType` fields + overloaded public constructor + getters. The original constructor delegates with empty maps. | +| `TelemetryPipelineResponse.java` | Made constructor `public` (was package-private) for test access. | +| `TelemetryPipeline.java` | Added overloaded `send()` accepting item count maps; original `send()` delegates with empty maps. Passes maps into `TelemetryPipelineRequest`. | +| `AzureMonitorHelper.java` | `createTelemetryItemExporter()` now accepts `CustomerSdkStats`, creates a `CustomerSdkStatsTelemetryPipelineListener`, and wires it into the composite listener chain (alongside `DiagnosticTelemetryPipelineListener` and `LocalStorageTelemetryPipelineListener`). | +| `AzureMonitorExporterBuilder.java` | Added `SDKSTATS_DISABLED_ENV_VAR`, `SDKSTATS_EXPORT_INTERVAL_ENV_VAR` constants. Added `createCustomerSdkStats()` and `startCustomerSdkStats()` with a `ScheduledExecutorService` (daemon thread, default 900s interval) that calls `collectAndReset()` and `sendWithoutTracking()`. | + +--- + +## Configuration + +| Environment Variable | Default | Description | +|---------------------|---------|-------------| +| `APPLICATIONINSIGHTS_SDKSTATS_DISABLED` | `false` | Set to `true` to disable customer SDKStats entirely. | +| `APPLICATIONINSIGHTS_SDKSTATS_EXPORT_INTERVAL` | `900` (15 min) | Export interval in seconds. | + +--- + +## Metric details + +| Metric Name | Dimensions | When counted | +|-------------|------------|--------------| +| `Item_Success_Count` | `computeType`, `language`, `version`, `telemetry_type` | HTTP 200 from ingestion | +| `Item_Dropped_Count` | `computeType`, `language`, `version`, `telemetry_type`, `drop.code`, `drop.reason`, `telemetry_success` | Non-retryable HTTP status (e.g. 400, 402, 404) | +| `Item_Retry_Count` | `computeType`, `language`, `version`, `telemetry_type`, `retry.code`, `retry.reason` | Retryable HTTP status (401, 403, 408, 429, 500-504) or client exception/timeout | + +--- + +## Validation + +- **Unit tests**: 38/38 passing +- **Code formatting**: `spotlessApply` clean +- **Compilation**: Module compiles successfully +- **E2E success mode**: `Item_Success_Count` visible in Azure Monitor `customMetrics` table with correct dimensions +- **E2E drop mode**: `Item_Dropped_Count` visible in mock server console with `drop.code=400`, `drop.reason="Bad request"`, per-type/per-success breakdown +- **E2E retry mode**: Mock infrastructure ready (mock server returns 500) diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/AzureMonitorExporterBuilder.java b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/AzureMonitorExporterBuilder.java index 8b8ba745fbff..eeda121d79c5 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/AzureMonitorExporterBuilder.java +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/AzureMonitorExporterBuilder.java @@ -28,6 +28,7 @@ import com.azure.monitor.opentelemetry.autoconfigure.implementation.pipeline.TelemetryItemExporter; import com.azure.monitor.opentelemetry.autoconfigure.implementation.quickpulse.QuickPulse; import com.azure.monitor.opentelemetry.autoconfigure.implementation.statsbeat.CustomerSdkStats; +import com.azure.monitor.opentelemetry.autoconfigure.implementation.statsbeat.CustomerSdkStatsTelemetryPipelineListener; import com.azure.monitor.opentelemetry.autoconfigure.implementation.statsbeat.Feature; import com.azure.monitor.opentelemetry.autoconfigure.implementation.statsbeat.StatsbeatModule; import com.azure.monitor.opentelemetry.autoconfigure.implementation.utils.AzureMonitorHelper; @@ -103,16 +104,21 @@ void initializeIfNot(AzureMonitorAutoConfigureOptions exporterOptions, ConfigPro this.spanDataMapper = createSpanDataMapper(); File tempDir = TempDirs.getApplicationInsightsTempDir(LOGGER, "Telemetry will not be stored to disk and retried on sporadic network failures"); - // Create customer-facing SDKStats accumulator - CustomerSdkStats customerSdkStats = createCustomerSdkStats(); + // Create customer-facing SDKStats if enabled (skip accumulator and listener when disabled) + boolean customerSdkStatsEnabled = isCustomerSdkStatsEnabled(); + CustomerSdkStats customerSdkStats = customerSdkStatsEnabled ? createCustomerSdkStats() : null; + CustomerSdkStatsTelemetryPipelineListener customerSdkStatsListener + = customerSdkStats != null ? new CustomerSdkStatsTelemetryPipelineListener(customerSdkStats) : null; // TODO (heya) change LocalStorageStats.noop() to statsbeatModule.getNonessentialStatsbeat() when we decide to collect non-essential Statsbeat by default. this.builtTelemetryItemExporter = AzureMonitorHelper.createTelemetryItemExporter(httpPipeline, statsbeatModule, - tempDir, LocalStorageStats.noop(), customerSdkStats); + tempDir, LocalStorageStats.noop(), customerSdkStatsListener); if (LiveMetrics.isEnabled(configProperties)) { this.quickPulse = createQuickPulse(resource); } startStatsbeatModule(statsbeatModule, configProperties, tempDir); // wait till TelemetryItemExporter has been initialized before starting StatsbeatModule - startCustomerSdkStats(customerSdkStats, resource); + if (customerSdkStatsEnabled) { + startCustomerSdkStats(customerSdkStats, customerSdkStatsListener, resource); + } } private QuickPulse createQuickPulse(Resource resource) { @@ -254,25 +260,34 @@ private void startStatsbeatModule(StatsbeatModule statsbeatModule, ConfigPropert } private CustomerSdkStats createCustomerSdkStats() { - // Use the raw SDK version (without prefixes) for the customer-facing stats - String version = VersionGenerator.getSdkVersion(); + // Use the raw library version number (e.g. "3.6.0") for the customer-facing stats dimension + String version = PropertyHelper.getSdkVersionNumber(); return CustomerSdkStats.create(version); } - private void startCustomerSdkStats(CustomerSdkStats customerSdkStats, Resource resource) { - // Check if customer SDKStats is disabled via env var + private boolean isCustomerSdkStatsEnabled() { String disabledEnvVar = System.getenv(SDKSTATS_DISABLED_ENV_VAR); if ("true".equalsIgnoreCase(disabledEnvVar)) { LOGGER.verbose("Customer SDKStats is disabled via environment variable {}.", SDKSTATS_DISABLED_ENV_VAR); - return; + return false; } + return true; + } + private void startCustomerSdkStats(CustomerSdkStats customerSdkStats, + CustomerSdkStatsTelemetryPipelineListener customerSdkStatsListener, Resource resource) { // Get export interval from env var or use default long exportIntervalSeconds = SDKSTATS_DEFAULT_EXPORT_INTERVAL_SECONDS; String intervalEnvVar = System.getenv(SDKSTATS_EXPORT_INTERVAL_ENV_VAR); if (intervalEnvVar != null && !intervalEnvVar.isEmpty()) { try { - exportIntervalSeconds = Long.parseLong(intervalEnvVar); + long parsedInterval = Long.parseLong(intervalEnvVar); + if (parsedInterval > 0) { + exportIntervalSeconds = parsedInterval; + } else { + LOGGER.warning("Value for {} must be positive: {}. Using default {} seconds.", + SDKSTATS_EXPORT_INTERVAL_ENV_VAR, intervalEnvVar, SDKSTATS_DEFAULT_EXPORT_INTERVAL_SECONDS); + } } catch (NumberFormatException e) { LOGGER.warning("Invalid value for {}: {}. Using default {} seconds.", SDKSTATS_EXPORT_INTERVAL_ENV_VAR, intervalEnvVar, SDKSTATS_DEFAULT_EXPORT_INTERVAL_SECONDS); @@ -286,6 +301,7 @@ private void startCustomerSdkStats(CustomerSdkStats customerSdkStats, Resource r ScheduledExecutorService customerSdkStatsExecutor = Executors .newSingleThreadScheduledExecutor(ThreadPoolUtils.createDaemonThreadFactory(CustomerSdkStats.class)); + customerSdkStatsListener.setScheduler(customerSdkStatsExecutor); LOGGER.info("Customer SDKStats scheduler starting with interval {} seconds.", exportIntervalSeconds); customerSdkStatsExecutor.scheduleWithFixedDelay(() -> { @@ -293,10 +309,8 @@ private void startCustomerSdkStats(CustomerSdkStats customerSdkStats, Resource r List items = customerSdkStats.collectAndReset(connectionString, sdkVersion, cloudRole, cloudRoleInstance); if (!items.isEmpty()) { - LOGGER.info("Customer SDKStats: exporting {} metric items.", items.size()); + LOGGER.verbose("Customer SDKStats: exporting {} metric items.", items.size()); builtTelemetryItemExporter.sendWithoutTracking(items); - } else { - LOGGER.info("Customer SDKStats: no items to export (counters were zero)."); } } catch (RuntimeException e) { LOGGER.warning("Error sending customer SDKStats", e); diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/pipeline/TelemetryItemExporter.java b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/pipeline/TelemetryItemExporter.java index 007759e326fa..02af6099be94 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/pipeline/TelemetryItemExporter.java +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/pipeline/TelemetryItemExporter.java @@ -187,7 +187,8 @@ static void computeItemCountMetadata(List telemetryItems, Map(itemCountsByType)) + : Collections.emptyMap(); + this.successItemCountsByType = successItemCountsByType != null + ? Collections.unmodifiableMap(new HashMap<>(successItemCountsByType)) + : Collections.emptyMap(); + this.failureItemCountsByType = failureItemCountsByType != null + ? Collections.unmodifiableMap(new HashMap<>(failureItemCountsByType)) + : Collections.emptyMap(); } public URL getUrl() { diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStats.java b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStats.java index 377c6fd2e412..a366c3340c54 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStats.java +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStats.java @@ -92,7 +92,7 @@ public void incrementDroppedCount(Map itemCountsByType, String dro long successCount = successItemCountsByType.getOrDefault(telemetryType, 0L); long failureCount = failureItemCountsByType.getOrDefault(telemetryType, 0L); // Any items not accounted for in success/failure maps go to the "unknown" bucket - long unaccounted = totalCount - successCount - failureCount; + long unaccounted = Math.max(0, totalCount - successCount - failureCount); if (successCount > 0) { DroppedKey key = new DroppedKey(telemetryType, dropCode, dropReason, Boolean.TRUE); @@ -231,8 +231,10 @@ private static Map snapshotAndClear(ConcurrentHashMap entry.getValue().get() == 0); + // Note: we don't remove zero-valued entries here because doing so with removeIf() + // can race with concurrent increment*() calls, causing those increments to be lost. + // The number of distinct keys is bounded by the set of telemetry types and code/reason + // combinations, so unbounded growth is not a concern in practice. return snapshot; } diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsTelemetryPipelineListener.java b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsTelemetryPipelineListener.java index ab9adf16cc3a..ca2497a5722a 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsTelemetryPipelineListener.java +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsTelemetryPipelineListener.java @@ -3,7 +3,6 @@ package com.azure.monitor.opentelemetry.autoconfigure.implementation.statsbeat; -import com.azure.core.util.logging.ClientLogger; import com.azure.monitor.opentelemetry.autoconfigure.implementation.pipeline.TelemetryPipelineListener; import com.azure.monitor.opentelemetry.autoconfigure.implementation.pipeline.TelemetryPipelineRequest; import com.azure.monitor.opentelemetry.autoconfigure.implementation.pipeline.TelemetryPipelineResponse; @@ -12,6 +11,7 @@ import reactor.util.annotation.Nullable; import java.util.Map; +import java.util.concurrent.ScheduledExecutorService; /** * TelemetryPipelineListener that observes HTTP responses and exceptions from the telemetry @@ -19,25 +19,32 @@ */ public class CustomerSdkStatsTelemetryPipelineListener implements TelemetryPipelineListener { - private static final ClientLogger LOGGER = new ClientLogger(CustomerSdkStatsTelemetryPipelineListener.class); - private final CustomerSdkStats customerSdkStats; + private volatile ScheduledExecutorService scheduler; public CustomerSdkStatsTelemetryPipelineListener(CustomerSdkStats customerSdkStats) { this.customerSdkStats = customerSdkStats; } + /** + * Sets the scheduler used for periodic SDKStats export, so it can be shut down + * when the pipeline is shut down. + */ + public void setScheduler(ScheduledExecutorService scheduler) { + this.scheduler = scheduler; + } + @Override public void onResponse(TelemetryPipelineRequest request, TelemetryPipelineResponse response) { Map itemCountsByType = request.getItemCountsByType(); if (itemCountsByType.isEmpty()) { - // No item metadata (e.g. from local storage resend) — skip tracking - LOGGER.info("CustomerSdkStats onResponse: itemCountsByType is empty, skipping."); + // No item metadata (e.g. from local storage resend) — skip tracking. + // NOTE: This means telemetry retried from disk will not be counted in SDKStats. + // To track those, item-count metadata would need to be persisted alongside the payload. return; } int statusCode = response.getStatusCode(); - LOGGER.info("CustomerSdkStats onResponse: statusCode={}, itemCountsByType={}", statusCode, itemCountsByType); if (statusCode == 200) { customerSdkStats.incrementSuccessCount(itemCountsByType); @@ -74,6 +81,10 @@ public void onException(TelemetryPipelineRequest request, String errorMessage, T @Override public CompletableResultCode shutdown() { + ScheduledExecutorService exec = this.scheduler; + if (exec != null) { + exec.shutdown(); + } return CompletableResultCode.ofSuccess(); } @@ -102,7 +113,7 @@ static String getReasonPhraseForStatusCode(int statusCode) { return "Too many requests"; case 439: - return "Too many requests"; + return "Exceeded daily quota"; case 500: return "Internal server error"; diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/utils/AzureMonitorHelper.java b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/utils/AzureMonitorHelper.java index 334fbf36f953..907ed2d04350 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/utils/AzureMonitorHelper.java +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/utils/AzureMonitorHelper.java @@ -10,10 +10,10 @@ import com.azure.monitor.opentelemetry.autoconfigure.implementation.pipeline.TelemetryItemExporter; import com.azure.monitor.opentelemetry.autoconfigure.implementation.pipeline.TelemetryPipeline; import com.azure.monitor.opentelemetry.autoconfigure.implementation.pipeline.TelemetryPipelineListener; -import com.azure.monitor.opentelemetry.autoconfigure.implementation.statsbeat.CustomerSdkStats; import com.azure.monitor.opentelemetry.autoconfigure.implementation.statsbeat.CustomerSdkStatsTelemetryPipelineListener; import com.azure.monitor.opentelemetry.autoconfigure.implementation.statsbeat.StatsbeatModule; import com.azure.monitor.opentelemetry.autoconfigure.implementation.statsbeat.StatsbeatTelemetryPipelineListener; +import reactor.util.annotation.Nullable; import java.io.File; @@ -21,25 +21,28 @@ public final class AzureMonitorHelper { public static TelemetryItemExporter createTelemetryItemExporter(HttpPipeline httpPipeline, StatsbeatModule statsbeatModule, File tempDir, LocalStorageStats localStorageStats, - CustomerSdkStats customerSdkStats) { + @Nullable CustomerSdkStatsTelemetryPipelineListener customerSdkStatsListener) { TelemetryPipeline telemetryPipeline = new TelemetryPipeline(httpPipeline, statsbeatModule::shutdown); TelemetryPipelineListener telemetryPipelineListener; - CustomerSdkStatsTelemetryPipelineListener customerSdkStatsListener - = new CustomerSdkStatsTelemetryPipelineListener(customerSdkStats); if (tempDir == null) { - telemetryPipelineListener = TelemetryPipelineListener - .composite(new DiagnosticTelemetryPipelineListener("Sending telemetry to the ingestion service", true, - " (telemetry will be lost)"), customerSdkStatsListener); + DiagnosticTelemetryPipelineListener diagnosticListener = new DiagnosticTelemetryPipelineListener( + "Sending telemetry to the ingestion service", true, " (telemetry will be lost)"); + telemetryPipelineListener = customerSdkStatsListener != null + ? TelemetryPipelineListener.composite(diagnosticListener, customerSdkStatsListener) + : diagnosticListener; } else { - telemetryPipelineListener = TelemetryPipelineListener.composite( - // suppress warnings on retryable failures, in order to reduce sporadic/annoying - // warnings when storing to disk and retrying shortly afterwards anyways - // will log if that retry from disk fails - new DiagnosticTelemetryPipelineListener("Sending telemetry to the ingestion service", false, ""), - new LocalStorageTelemetryPipelineListener(50, // default to 50MB - TempDirs.getSubDir(tempDir, "telemetry"), telemetryPipeline, localStorageStats, false), - customerSdkStatsListener); + // suppress warnings on retryable failures, in order to reduce sporadic/annoying + // warnings when storing to disk and retrying shortly afterwards anyways + // will log if that retry from disk fails + DiagnosticTelemetryPipelineListener diagnosticListener + = new DiagnosticTelemetryPipelineListener("Sending telemetry to the ingestion service", false, ""); + LocalStorageTelemetryPipelineListener localStorageListener = new LocalStorageTelemetryPipelineListener(50, // default to 50MB + TempDirs.getSubDir(tempDir, "telemetry"), telemetryPipeline, localStorageStats, false); + telemetryPipelineListener = customerSdkStatsListener != null + ? TelemetryPipelineListener.composite(diagnosticListener, localStorageListener, + customerSdkStatsListener) + : TelemetryPipelineListener.composite(diagnosticListener, localStorageListener); } return new TelemetryItemExporter(telemetryPipeline, telemetryPipelineListener); diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/samples/java/com/azure/monitor/opentelemetry/autoconfigure/SimpleWebAppSample.java b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/samples/java/com/azure/monitor/opentelemetry/autoconfigure/SimpleWebAppSample.java index 55c2354f9a74..f39ba06a177e 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/samples/java/com/azure/monitor/opentelemetry/autoconfigure/SimpleWebAppSample.java +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/samples/java/com/azure/monitor/opentelemetry/autoconfigure/SimpleWebAppSample.java @@ -81,16 +81,19 @@ public class SimpleWebAppSample { // ── Connection string for success mode (real Azure Monitor) ───────────── + // Read from APPLICATIONINSIGHTS_CONNECTION_STRING env var; if not set, use a placeholder. private static final String CONNECTION_STRING_SUCCESS - = "InstrumentationKey=66f50587-a821-4a96-8511-fa96e8f28fd7" - + ";IngestionEndpoint=https://westus2-1.in.applicationinsights.azure.com/" - + ";LiveEndpoint=https://westus2.livediagnostics.monitor.azure.com/" - + ";ApplicationId=b9fa6c07-f06f-414c-b222-f3207aab66cd"; + = System.getenv("APPLICATIONINSIGHTS_CONNECTION_STRING") != null + ? System.getenv("APPLICATIONINSIGHTS_CONNECTION_STRING") + : "InstrumentationKey=" + + ";IngestionEndpoint=https://.in.applicationinsights.azure.com/" + + ";LiveEndpoint=https://.livediagnostics.monitor.azure.com/" + + ";ApplicationId="; // ── Connection string for mock server (drop/retry modes) ──────────────── private static final int MOCK_PORT = 9090; private static final String CONNECTION_STRING_MOCK - = "InstrumentationKey=66f50587-a821-4a96-8511-fa96e8f28fd7" + = "InstrumentationKey=00000000-0000-0000-0000-000000000000" + ";IngestionEndpoint=http://localhost:" + MOCK_PORT + "/" + ";LiveEndpoint=http://localhost:" + MOCK_PORT + "/"; From 7a2afd67b9bfa23b98e14609f6eb74afd11300cf Mon Sep 17 00:00:00 2001 From: Rajkumar Rangaraj Date: Mon, 23 Feb 2026 18:12:30 -0800 Subject: [PATCH 3/8] Update checkstyle suppressions and improve SimpleWebAppSample README for clarity --- .../PR_CHANGES.md | 76 ------------------- .../checkstyle-suppressions.xml | 2 + ...omerSdkStatsTelemetryPipelineListener.java | 7 +- .../SimpleWebAppSample-README.md | 60 ++++++++------- 4 files changed, 39 insertions(+), 106 deletions(-) delete mode 100644 sdk/monitor/azure-monitor-opentelemetry-autoconfigure/PR_CHANGES.md diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/PR_CHANGES.md b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/PR_CHANGES.md deleted file mode 100644 index a7d7c7bb33bc..000000000000 --- a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/PR_CHANGES.md +++ /dev/null @@ -1,76 +0,0 @@ -# PR: Add customer-facing SDKStats metrics (Item_Success_Count, Item_Dropped_Count, Item_Retry_Count) - -Implements customer-facing SDKStats per the [customer_facing_sdk_stats.md](customer_facing_sdk_stats.md) spec. The exporter now tracks per-telemetry-type success, drop, and retry counts and periodically exports them as `Metric` TelemetryItems through the existing pipeline to the customer's own Application Insights resource. - ---- - -## New files - -### Production - -| File | Purpose | -|------|---------| -| `CustomerSdkStats.java` | Thread-safe accumulator (`ConcurrentHashMap`) for three counter families: `successCounts`, `droppedCounts`, `retryCounts`. `collectAndReset()` atomically snapshots and clears all counters, returning a list of `TelemetryItem` metrics with the correct dimensions (`computeType`, `language`, `version`, `telemetry_type`, `telemetry_success`, `drop.code`/`drop.reason`, `retry.code`/`retry.reason`). Static factory `create(version)` auto-detects the resource provider. | -| `CustomerSdkStatsTelemetryType.java` | Maps `TelemetryItem.getName()` → spec dimension strings: `Request`→`REQUEST`, `RemoteDependency`→`DEPENDENCY`, `Message`→`TRACE`, `Exception`→`EXCEPTION`, `Metric`→`CUSTOM_METRIC`, `Event`→`CUSTOM_EVENT`, `PageView`→`PAGE_VIEW`, `Availability`→`AVAILABILITY`. Returns `null` for internal items (e.g. `Statsbeat`) to skip counting. | -| `CustomerSdkStatsExceptionCategory.java` | Classifies exceptions into low-cardinality reason strings for `drop.reason`/`retry.reason`: `"Timeout exception"`, `"Network exception"`, `"Storage exception"`, `"Client exception"`. Traverses the cause chain (depth ≤ 10). Also provides `isTimeout()` to choose between `CLIENT_TIMEOUT` and `CLIENT_EXCEPTION` retry codes. | -| `CustomerSdkStatsTelemetryPipelineListener.java` | `TelemetryPipelineListener` that routes pipeline responses to the accumulator: 200→success, retryable (401/403/408/429/500/502/503/504)→retry, redirect (307/308)→skip, all others→drop. `onException` categorizes the throwable and records a retry. Includes `getReasonPhraseForStatusCode()` for common HTTP status codes. | - -### Tests - -| File | # Tests | -|------|---------| -| `CustomerSdkStatsTest.java` | 10 (accumulation, reset, concurrent increments, telemetry_success split) | -| `CustomerSdkStatsTelemetryTypeTest.java` | 10 (all 8 mappings + Statsbeat→null + unknown→null) | -| `CustomerSdkStatsExceptionCategoryTest.java` | 9 (timeout, network, storage, client, null, wrapped exceptions) | -| `CustomerSdkStatsTelemetryPipelineListenerTest.java` | 9 (success, retry 429/500, drop 402, timeout/network exception, empty skip, redirect skip, reason phrases) | -| **Total** | **38 tests** | - -### Sample - -| File | Purpose | -|------|---------| -| `SimpleWebAppSample.java` | Long-running web app with 5 endpoints and 3 test modes (`success`/`drop`/`retry`). In drop/retry modes, a built-in mock ingestion server on port 9090 returns the configured error status and prints all gunzipped payloads to the console, tagged `[SDKStats]` when applicable. | -| `SimpleWebAppSample-README.md` | Step-by-step manual execution guide with env var setup, endpoint table, Kusto query, and expected console output. | - ---- - -## Modified files - -| File | Change | -|------|--------| -| `TelemetryItemExporter.java` | Added `computeItemCountMetadata()` to compute per-type item counts (with success/failure split for REQUEST/DEPENDENCY) before serialization. Added `sendWithoutTracking()` to send items without triggering recursive customer SDKStats counting. Updated `internalSendByBatch()` to pass item count maps to the pipeline. | -| `TelemetryPipelineRequest.java` | Added `itemCountsByType`, `successItemCountsByType`, `failureItemCountsByType` fields + overloaded public constructor + getters. The original constructor delegates with empty maps. | -| `TelemetryPipelineResponse.java` | Made constructor `public` (was package-private) for test access. | -| `TelemetryPipeline.java` | Added overloaded `send()` accepting item count maps; original `send()` delegates with empty maps. Passes maps into `TelemetryPipelineRequest`. | -| `AzureMonitorHelper.java` | `createTelemetryItemExporter()` now accepts `CustomerSdkStats`, creates a `CustomerSdkStatsTelemetryPipelineListener`, and wires it into the composite listener chain (alongside `DiagnosticTelemetryPipelineListener` and `LocalStorageTelemetryPipelineListener`). | -| `AzureMonitorExporterBuilder.java` | Added `SDKSTATS_DISABLED_ENV_VAR`, `SDKSTATS_EXPORT_INTERVAL_ENV_VAR` constants. Added `createCustomerSdkStats()` and `startCustomerSdkStats()` with a `ScheduledExecutorService` (daemon thread, default 900s interval) that calls `collectAndReset()` and `sendWithoutTracking()`. | - ---- - -## Configuration - -| Environment Variable | Default | Description | -|---------------------|---------|-------------| -| `APPLICATIONINSIGHTS_SDKSTATS_DISABLED` | `false` | Set to `true` to disable customer SDKStats entirely. | -| `APPLICATIONINSIGHTS_SDKSTATS_EXPORT_INTERVAL` | `900` (15 min) | Export interval in seconds. | - ---- - -## Metric details - -| Metric Name | Dimensions | When counted | -|-------------|------------|--------------| -| `Item_Success_Count` | `computeType`, `language`, `version`, `telemetry_type` | HTTP 200 from ingestion | -| `Item_Dropped_Count` | `computeType`, `language`, `version`, `telemetry_type`, `drop.code`, `drop.reason`, `telemetry_success` | Non-retryable HTTP status (e.g. 400, 402, 404) | -| `Item_Retry_Count` | `computeType`, `language`, `version`, `telemetry_type`, `retry.code`, `retry.reason` | Retryable HTTP status (401, 403, 408, 429, 500-504) or client exception/timeout | - ---- - -## Validation - -- **Unit tests**: 38/38 passing -- **Code formatting**: `spotlessApply` clean -- **Compilation**: Module compiles successfully -- **E2E success mode**: `Item_Success_Count` visible in Azure Monitor `customMetrics` table with correct dimensions -- **E2E drop mode**: `Item_Dropped_Count` visible in mock server console with `drop.code=400`, `drop.reason="Bad request"`, per-type/per-success breakdown -- **E2E retry mode**: Mock infrastructure ready (mock server returns 500) diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/checkstyle-suppressions.xml b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/checkstyle-suppressions.xml index d54e74099812..5a78a1260acb 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/checkstyle-suppressions.xml +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/checkstyle-suppressions.xml @@ -81,6 +81,7 @@ + @@ -130,6 +131,7 @@ + diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsTelemetryPipelineListener.java b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsTelemetryPipelineListener.java index ca2497a5722a..0bb5c3e034c2 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsTelemetryPipelineListener.java +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsTelemetryPipelineListener.java @@ -53,10 +53,9 @@ public void onResponse(TelemetryPipelineRequest request, TelemetryPipelineRespon String retryCode = String.valueOf(statusCode); String retryReason = getReasonPhraseForStatusCode(statusCode); customerSdkStats.incrementRetryCount(itemCountsByType, retryCode, retryReason); - } else if (StatusCode.isRedirect(statusCode)) { - // Redirects are handled transparently by the HTTP client; do not count - } else { - // Non-retryable status codes: items are dropped + } else if (!StatusCode.isRedirect(statusCode)) { + // Non-redirect, non-retryable status codes: items are dropped. + // Redirects are handled transparently by the HTTP client and are not counted. String dropCode = String.valueOf(statusCode); String dropReason = getReasonPhraseForStatusCode(statusCode); customerSdkStats.incrementDroppedCount(itemCountsByType, dropCode, dropReason, diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/samples/java/com/azure/monitor/opentelemetry/autoconfigure/SimpleWebAppSample-README.md b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/samples/java/com/azure/monitor/opentelemetry/autoconfigure/SimpleWebAppSample-README.md index 50919b28b5d1..d9f3aa25abfc 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/samples/java/com/azure/monitor/opentelemetry/autoconfigure/SimpleWebAppSample-README.md +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/samples/java/com/azure/monitor/opentelemetry/autoconfigure/SimpleWebAppSample-README.md @@ -6,8 +6,8 @@ A long-running web app that generates telemetry and validates customer-facing SD ## Prerequisites -- **Java 21**: `C:\Program Files\Microsoft\jdk-21.0.10.7-hotspot` -- **Maven 3.9.9**: `C:\apache-maven\apache-maven-3.9.9` +- **Java 8+** (JDK 8, 11, 17, or 21) +- **Maven 3.6+** - The `azure-monitor-opentelemetry-autoconfigure` module must be compiled (including test sources). --- @@ -16,22 +16,29 @@ A long-running web app that generates telemetry and validates customer-facing SD ### 1. Set up environment variables -Open a PowerShell terminal: +Ensure `JAVA_HOME` points to your JDK installation and Maven is on your `PATH`. +**Windows (PowerShell):** ```powershell -$env:JAVA_HOME = "C:\Program Files\Microsoft\jdk-21.0.10.7-hotspot" -$env:PATH = "C:\apache-maven\apache-maven-3.9.9\bin;$env:JAVA_HOME\bin;$env:PATH" +$env:JAVA_HOME = "" +$env:PATH = "/bin;$env:JAVA_HOME/bin;$env:PATH" +``` + +**Linux / macOS:** +```bash +export JAVA_HOME= +export PATH=/bin:$JAVA_HOME/bin:$PATH ``` ### 2. Navigate to the project -```powershell -cd c:\repo\azure-sdk-for-java\sdk\monitor\azure-monitor-opentelemetry-autoconfigure +```bash +cd sdk/monitor/azure-monitor-opentelemetry-autoconfigure ``` ### 3. Compile -```powershell +```bash mvn compile test-compile ``` @@ -39,17 +46,24 @@ mvn compile test-compile Set the SDKStats export interval to 60 seconds (instead of the default 900) so results appear sooner: +**Windows (PowerShell):** ```powershell $env:APPLICATIONINSIGHTS_SDKSTATS_EXPORT_INTERVAL = "60" ``` +**Linux / macOS:** +```bash +export APPLICATIONINSIGHTS_SDKSTATS_EXPORT_INTERVAL=60 +``` + Then pick **one** of the following modes: #### Success mode (default) Telemetry is sent to real Azure Monitor. You will see `Item_Success_Count` in the `customMetrics` table. +Set `APPLICATIONINSIGHTS_CONNECTION_STRING` to your Application Insights connection string. -```powershell +```bash # No TEST_MODE needed — defaults to "success" ``` @@ -57,27 +71,21 @@ Telemetry is sent to real Azure Monitor. You will see `Item_Success_Count` in th A local mock server on port 9090 returns **400** for all requests. You will see `Item_Dropped_Count` printed in the console. -```powershell -$env:TEST_MODE = "drop" -``` +**Windows:** `$env:TEST_MODE = "drop"`  |  **Linux/macOS:** `export TEST_MODE=drop` #### Retry mode A local mock server on port 9090 returns **500** for all requests. You will see `Item_Retry_Count` printed in the console. -```powershell -$env:TEST_MODE = "retry" -``` +**Windows:** `$env:TEST_MODE = "retry"`  |  **Linux/macOS:** `export TEST_MODE=retry` #### Optional: enable Azure SDK logging -```powershell -$env:AZURE_LOG_LEVEL = "informational" -``` +**Windows:** `$env:AZURE_LOG_LEVEL = "informational"`  |  **Linux/macOS:** `export AZURE_LOG_LEVEL=informational` ### 5. Run the sample -```powershell +```bash mvn exec:java "-Dexec.mainClass=com.azure.monitor.opentelemetry.autoconfigure.SimpleWebAppSample" "-Dexec.classpathScope=test" ``` @@ -92,14 +100,14 @@ You should see: ### 6. Generate telemetry -Open a **second** PowerShell terminal and hit the endpoints: +Open a **second** terminal and hit the endpoints: -```powershell -Invoke-WebRequest http://localhost:8080/ -Invoke-WebRequest http://localhost:8080/dependency -Invoke-WebRequest http://localhost:8080/error -Invoke-WebRequest http://localhost:8080/exception -Invoke-WebRequest http://localhost:8080/load +```bash +curl http://localhost:8080/ +curl http://localhost:8080/dependency +curl http://localhost:8080/error +curl http://localhost:8080/exception +curl http://localhost:8080/load ``` | Endpoint | What it does | From 1c733522d8ff31a4d60a9e2fa8026479c3aa709a Mon Sep 17 00:00:00 2001 From: Rajkumar Rangaraj Date: Mon, 23 Feb 2026 18:26:05 -0800 Subject: [PATCH 4/8] self-review --- .../AzureMonitorExporterBuilder.java | 2 + .../pipeline/TelemetryItemExporter.java | 20 ++++--- .../pipeline/TelemetryPipelineRequest.java | 2 + .../CustomerSdkStatsExceptionCategory.java | 52 ++++++++++--------- .../utils/AzureMonitorHelper.java | 4 ++ 5 files changed, 49 insertions(+), 31 deletions(-) diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/AzureMonitorExporterBuilder.java b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/AzureMonitorExporterBuilder.java index eeda121d79c5..61d5fce9a7ac 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/AzureMonitorExporterBuilder.java +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/AzureMonitorExporterBuilder.java @@ -265,6 +265,8 @@ private CustomerSdkStats createCustomerSdkStats() { return CustomerSdkStats.create(version); } + // TODO consider reading SDKStats env vars from ConfigProperties (like STATSBEAT_*_PROPERTY_NAME) + // instead of System.getenv() directly, for testability and consistency private boolean isCustomerSdkStatsEnabled() { String disabledEnvVar = System.getenv(SDKSTATS_DISABLED_ENV_VAR); if ("true".equalsIgnoreCase(disabledEnvVar)) { diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/pipeline/TelemetryItemExporter.java b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/pipeline/TelemetryItemExporter.java index 02af6099be94..125c79e3fcb2 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/pipeline/TelemetryItemExporter.java +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/pipeline/TelemetryItemExporter.java @@ -79,6 +79,10 @@ public CompletableResultCode send(List telemetryItems) { * Used for sending customer SDKStats metrics themselves to prevent recursive counting. * Items sent via this method will NOT trigger CustomerSdkStatsTelemetryPipelineListener * since the TelemetryPipelineRequest will have empty item count maps. + * + *

This intentionally bypasses {@link #internalSendByBatch} and its AKS + * {@code _OTELRESOURCE_} metric injection, since SDKStats metrics are not + * application telemetry and should not carry OTel resource attributes.

*/ public CompletableResultCode sendWithoutTracking(List telemetryItems) { Map> batches = splitIntoBatches(telemetryItems); @@ -136,6 +140,16 @@ public CompletableResultCode shutdown() { CompletableResultCode internalSendByBatch(TelemetryItemBatchKey telemetryItemBatchKey, List telemetryItems) { List byteBuffers; + + // Compute per-type item counts BEFORE injecting internal metrics (e.g. _OTELRESOURCE_) + // so that only customer telemetry is counted in SDKStats. + // TODO consider moving this computation into the statsbeat package to avoid a cross-layer + // dependency from TelemetryItemExporter (pipeline) → CustomerSdkStatsTelemetryType (statsbeat) + Map itemCountsByType = new HashMap<>(); + Map successItemCountsByType = new HashMap<>(); + Map failureItemCountsByType = new HashMap<>(); + computeItemCountMetadata(telemetryItems, itemCountsByType, successItemCountsByType, failureItemCountsByType); + // Don't send _OTELRESOURCE_ custom metric when OTEL_RESOURCE_ATTRIBUTES env var is empty // Don't send _OTELRESOURCE_ custom metric to Statsbeat yet // Don't Send _OTELRESOURCE_ when the app is running on other env other than AKS @@ -146,12 +160,6 @@ CompletableResultCode internalSendByBatch(TelemetryItemBatchKey telemetryItemBat telemetryItems.add(0, createOtelResourceMetric(telemetryItemBatchKey)); } - // Compute per-type item counts before serialization for customer-facing SDKStats - Map itemCountsByType = new HashMap<>(); - Map successItemCountsByType = new HashMap<>(); - Map failureItemCountsByType = new HashMap<>(); - computeItemCountMetadata(telemetryItems, itemCountsByType, successItemCountsByType, failureItemCountsByType); - try { byteBuffers = serialize(telemetryItems); encodeBatchOperationLogger.recordSuccess(); diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/pipeline/TelemetryPipelineRequest.java b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/pipeline/TelemetryPipelineRequest.java index 13f51c290a36..cb4ac9f4bcc0 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/pipeline/TelemetryPipelineRequest.java +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/pipeline/TelemetryPipelineRequest.java @@ -24,6 +24,8 @@ public class TelemetryPipelineRequest { private final int contentLength; // Customer-facing SDKStats metadata: item counts by telemetry type + // TODO consider extracting these three maps into a lightweight TelemetryBatchMetadata value + // object to reduce parameter proliferation and simplify TelemetryPipeline.send() signatures private final Map itemCountsByType; private final Map successItemCountsByType; private final Map failureItemCountsByType; diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsExceptionCategory.java b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsExceptionCategory.java index e780d080f1b5..c86523b7b53f 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsExceptionCategory.java +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsExceptionCategory.java @@ -31,22 +31,31 @@ static String categorize(Throwable throwable) { } /** - * Returns true if the exception represents a timeout scenario (use CLIENT_TIMEOUT retry code), - * false for other exceptions (use CLIENT_EXCEPTION retry code). + * Returns true if any exception in the cause chain represents a timeout scenario + * (use CLIENT_TIMEOUT retry code), false otherwise (use CLIENT_EXCEPTION retry code). */ static boolean isTimeout(Throwable throwable) { - if (throwable == null) { - return false; + Throwable current = throwable; + int depth = 0; + while (current != null && depth < 10) { + if (isSingleTimeoutType(current)) { + return true; + } + current = current.getCause(); + depth++; } - return isTimeoutType(throwable); + return false; } private static String categorizeByType(Throwable throwable) { - // Traverse the cause chain to find the most specific category + // Traverse the cause chain to find the most specific category. + // Each type-check method examines only the current node (no nested traversal) + // so that priority ordering (timeout > network > storage) is evaluated + // consistently at each level of the chain. Throwable current = throwable; int depth = 0; while (current != null && depth < 10) { - if (isTimeoutType(current)) { + if (isSingleTimeoutType(current)) { return TIMEOUT_EXCEPTION; } if (isNetworkType(current)) { @@ -61,25 +70,18 @@ private static String categorizeByType(Throwable throwable) { return CLIENT_EXCEPTION; } - private static boolean isTimeoutType(Throwable throwable) { - // Check the cause chain - Throwable current = throwable; - int depth = 0; - while (current != null && depth < 10) { - if (current instanceof SocketTimeoutException || current instanceof TimeoutException) { - return true; - } - // Check class name for netty timeout types without creating a hard dependency - String className = current.getClass().getName(); - if (className.contains("TimeoutException") - || className.contains("ReadTimeoutException") - || className.contains("WriteTimeoutException")) { - return true; - } - current = current.getCause(); - depth++; + /** + * Checks whether a single throwable (not its cause chain) is a timeout type. + */ + private static boolean isSingleTimeoutType(Throwable throwable) { + if (throwable instanceof SocketTimeoutException || throwable instanceof TimeoutException) { + return true; } - return false; + // Check class name for netty timeout types without creating a hard dependency + String className = throwable.getClass().getName(); + return className.contains("TimeoutException") + || className.contains("ReadTimeoutException") + || className.contains("WriteTimeoutException"); } private static boolean isNetworkType(Throwable throwable) { diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/utils/AzureMonitorHelper.java b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/utils/AzureMonitorHelper.java index 907ed2d04350..c0df064007cd 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/utils/AzureMonitorHelper.java +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/utils/AzureMonitorHelper.java @@ -24,6 +24,10 @@ public static TelemetryItemExporter createTelemetryItemExporter(HttpPipeline htt @Nullable CustomerSdkStatsTelemetryPipelineListener customerSdkStatsListener) { TelemetryPipeline telemetryPipeline = new TelemetryPipeline(httpPipeline, statsbeatModule::shutdown); + // Listener ordering matters: localStorageListener must come before customerSdkStatsListener + // so that telemetry is persisted to disk (for later retry) before the retry is recorded in + // SDKStats. If the order is reversed, SDKStats would record a retry before the items are + // actually persisted, and a persistence failure would not be reflected. TelemetryPipelineListener telemetryPipelineListener; if (tempDir == null) { DiagnosticTelemetryPipelineListener diagnosticListener = new DiagnosticTelemetryPipelineListener( From c8481a5a884149c9005dfbb612b23b78fb8f8fe2 Mon Sep 17 00:00:00 2001 From: Rajkumar Rangaraj Date: Mon, 23 Feb 2026 20:05:26 -0800 Subject: [PATCH 5/8] Remove TODOs --- .../AzureMonitorExporterBuilder.java | 33 ++--- .../pipeline/TelemetryItemExporter.java | 48 +------ .../pipeline/TelemetryPipeline.java | 9 +- .../pipeline/TelemetryPipelineRequest.java | 51 ++------ ...omerSdkStatsTelemetryPipelineListener.java | 23 ++-- .../CustomerSdkStatsTelemetryType.java | 21 +-- .../statsbeat/TelemetryBatchMetadata.java | 122 ++++++++++++++++++ ...SdkStatsTelemetryPipelineListenerTest.java | 7 +- 8 files changed, 177 insertions(+), 137 deletions(-) create mode 100644 sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/TelemetryBatchMetadata.java diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/AzureMonitorExporterBuilder.java b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/AzureMonitorExporterBuilder.java index 61d5fce9a7ac..98ca832e997c 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/AzureMonitorExporterBuilder.java +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/AzureMonitorExporterBuilder.java @@ -265,12 +265,10 @@ private CustomerSdkStats createCustomerSdkStats() { return CustomerSdkStats.create(version); } - // TODO consider reading SDKStats env vars from ConfigProperties (like STATSBEAT_*_PROPERTY_NAME) - // instead of System.getenv() directly, for testability and consistency private boolean isCustomerSdkStatsEnabled() { - String disabledEnvVar = System.getenv(SDKSTATS_DISABLED_ENV_VAR); - if ("true".equalsIgnoreCase(disabledEnvVar)) { - LOGGER.verbose("Customer SDKStats is disabled via environment variable {}.", SDKSTATS_DISABLED_ENV_VAR); + String disabledValue = configProperties.getString(SDKSTATS_DISABLED_ENV_VAR); + if ("true".equalsIgnoreCase(disabledValue)) { + LOGGER.verbose("Customer SDKStats is disabled via configuration property {}.", SDKSTATS_DISABLED_ENV_VAR); return false; } return true; @@ -278,22 +276,13 @@ private boolean isCustomerSdkStatsEnabled() { private void startCustomerSdkStats(CustomerSdkStats customerSdkStats, CustomerSdkStatsTelemetryPipelineListener customerSdkStatsListener, Resource resource) { - // Get export interval from env var or use default - long exportIntervalSeconds = SDKSTATS_DEFAULT_EXPORT_INTERVAL_SECONDS; - String intervalEnvVar = System.getenv(SDKSTATS_EXPORT_INTERVAL_ENV_VAR); - if (intervalEnvVar != null && !intervalEnvVar.isEmpty()) { - try { - long parsedInterval = Long.parseLong(intervalEnvVar); - if (parsedInterval > 0) { - exportIntervalSeconds = parsedInterval; - } else { - LOGGER.warning("Value for {} must be positive: {}. Using default {} seconds.", - SDKSTATS_EXPORT_INTERVAL_ENV_VAR, intervalEnvVar, SDKSTATS_DEFAULT_EXPORT_INTERVAL_SECONDS); - } - } catch (NumberFormatException e) { - LOGGER.warning("Invalid value for {}: {}. Using default {} seconds.", SDKSTATS_EXPORT_INTERVAL_ENV_VAR, - intervalEnvVar, SDKSTATS_DEFAULT_EXPORT_INTERVAL_SECONDS); - } + // Get export interval from configuration or use default + long exportIntervalSeconds + = configProperties.getLong(SDKSTATS_EXPORT_INTERVAL_ENV_VAR, SDKSTATS_DEFAULT_EXPORT_INTERVAL_SECONDS); + if (exportIntervalSeconds <= 0) { + LOGGER.warning("Value for {} must be positive: {}. Using default {} seconds.", + SDKSTATS_EXPORT_INTERVAL_ENV_VAR, exportIntervalSeconds, SDKSTATS_DEFAULT_EXPORT_INTERVAL_SECONDS); + exportIntervalSeconds = SDKSTATS_DEFAULT_EXPORT_INTERVAL_SECONDS; } ConnectionString connectionString = getConnectionString(); @@ -305,7 +294,7 @@ private void startCustomerSdkStats(CustomerSdkStats customerSdkStats, .newSingleThreadScheduledExecutor(ThreadPoolUtils.createDaemonThreadFactory(CustomerSdkStats.class)); customerSdkStatsListener.setScheduler(customerSdkStatsExecutor); - LOGGER.info("Customer SDKStats scheduler starting with interval {} seconds.", exportIntervalSeconds); + LOGGER.verbose("Customer SDKStats scheduler starting with interval {} seconds.", exportIntervalSeconds); customerSdkStatsExecutor.scheduleWithFixedDelay(() -> { try { List items diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/pipeline/TelemetryItemExporter.java b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/pipeline/TelemetryItemExporter.java index 125c79e3fcb2..dfb7c18514e7 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/pipeline/TelemetryItemExporter.java +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/pipeline/TelemetryItemExporter.java @@ -10,11 +10,8 @@ import com.azure.monitor.opentelemetry.autoconfigure.implementation.builders.MetricTelemetryBuilder; import com.azure.monitor.opentelemetry.autoconfigure.implementation.logging.OperationLogger; import com.azure.monitor.opentelemetry.autoconfigure.implementation.models.ContextTagKeys; -import com.azure.monitor.opentelemetry.autoconfigure.implementation.models.MonitorDomain; -import com.azure.monitor.opentelemetry.autoconfigure.implementation.models.RemoteDependencyData; -import com.azure.monitor.opentelemetry.autoconfigure.implementation.models.RequestData; import com.azure.monitor.opentelemetry.autoconfigure.implementation.models.TelemetryItem; -import com.azure.monitor.opentelemetry.autoconfigure.implementation.statsbeat.CustomerSdkStatsTelemetryType; +import com.azure.monitor.opentelemetry.autoconfigure.implementation.statsbeat.TelemetryBatchMetadata; import com.azure.monitor.opentelemetry.autoconfigure.implementation.utils.AksResourceAttributes; import com.azure.monitor.opentelemetry.autoconfigure.implementation.utils.IKeyMasker; import io.opentelemetry.api.common.AttributeKey; @@ -143,12 +140,7 @@ CompletableResultCode internalSendByBatch(TelemetryItemBatchKey telemetryItemBat // Compute per-type item counts BEFORE injecting internal metrics (e.g. _OTELRESOURCE_) // so that only customer telemetry is counted in SDKStats. - // TODO consider moving this computation into the statsbeat package to avoid a cross-layer - // dependency from TelemetryItemExporter (pipeline) → CustomerSdkStatsTelemetryType (statsbeat) - Map itemCountsByType = new HashMap<>(); - Map successItemCountsByType = new HashMap<>(); - Map failureItemCountsByType = new HashMap<>(); - computeItemCountMetadata(telemetryItems, itemCountsByType, successItemCountsByType, failureItemCountsByType); + TelemetryBatchMetadata batchMetadata = TelemetryBatchMetadata.fromTelemetryItems(telemetryItems); // Don't send _OTELRESOURCE_ custom metric when OTEL_RESOURCE_ATTRIBUTES env var is empty // Don't send _OTELRESOURCE_ custom metric to Statsbeat yet @@ -167,41 +159,7 @@ CompletableResultCode internalSendByBatch(TelemetryItemBatchKey telemetryItemBat encodeBatchOperationLogger.recordFailure(t.getMessage(), t); return CompletableResultCode.ofFailure(); } - return telemetryPipeline.send(byteBuffers, telemetryItemBatchKey.connectionString, listener, itemCountsByType, - successItemCountsByType, failureItemCountsByType); - } - - static void computeItemCountMetadata(List telemetryItems, Map itemCountsByType, - Map successItemCountsByType, Map failureItemCountsByType) { - for (TelemetryItem item : telemetryItems) { - String telemetryType = CustomerSdkStatsTelemetryType.fromTelemetryItemName(item.getName()); - if (telemetryType == null) { - // Skip internal items (e.g. Statsbeat) - continue; - } - - itemCountsByType.merge(telemetryType, 1L, Long::sum); - - // Track success/failure for Request and Dependency types - if ("REQUEST".equals(telemetryType) || "DEPENDENCY".equals(telemetryType)) { - MonitorDomain baseData = item.getData() != null ? item.getData().getBaseData() : null; - if (baseData instanceof RequestData) { - if (((RequestData) baseData).isSuccess()) { - successItemCountsByType.merge(telemetryType, 1L, Long::sum); - } else { - failureItemCountsByType.merge(telemetryType, 1L, Long::sum); - } - } else if (baseData instanceof RemoteDependencyData) { - Boolean success = ((RemoteDependencyData) baseData).isSuccess(); - if (success != null && success) { - successItemCountsByType.merge(telemetryType, 1L, Long::sum); - } else if (success != null) { - // Only count explicit false as failure; null is treated as unknown - failureItemCountsByType.merge(telemetryType, 1L, Long::sum); - } - } - } - } + return telemetryPipeline.send(byteBuffers, telemetryItemBatchKey.connectionString, listener, batchMetadata); } // serialize an array of TelemetryItems to an array of byte buffers diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/pipeline/TelemetryPipeline.java b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/pipeline/TelemetryPipeline.java index 9f3f66b7d1c7..d3c514466b3d 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/pipeline/TelemetryPipeline.java +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/pipeline/TelemetryPipeline.java @@ -10,6 +10,7 @@ import com.azure.core.util.logging.ClientLogger; import com.azure.core.util.tracing.Tracer; import com.azure.monitor.opentelemetry.autoconfigure.implementation.configuration.ConnectionString; +import com.azure.monitor.opentelemetry.autoconfigure.implementation.statsbeat.TelemetryBatchMetadata; import com.azure.monitor.opentelemetry.autoconfigure.implementation.utils.StatusCode; import io.opentelemetry.sdk.common.CompletableResultCode; import reactor.core.publisher.Mono; @@ -45,13 +46,11 @@ public TelemetryPipeline(HttpPipeline pipeline, Runnable statsbeatShutdown) { public CompletableResultCode send(List telemetry, String connectionString, TelemetryPipelineListener listener) { - return send(telemetry, connectionString, listener, Collections.emptyMap(), Collections.emptyMap(), - Collections.emptyMap()); + return send(telemetry, connectionString, listener, TelemetryBatchMetadata.empty()); } public CompletableResultCode send(List telemetry, String connectionString, - TelemetryPipelineListener listener, Map itemCountsByType, - Map successItemCountsByType, Map failureItemCountsByType) { + TelemetryPipelineListener listener, TelemetryBatchMetadata batchMetadata) { ConnectionString connectionStringObj = ConnectionString.parse(connectionString); @@ -60,7 +59,7 @@ public CompletableResultCode send(List telemetry, String connectionS TelemetryPipelineRequest request = new TelemetryPipelineRequest(url, connectionString, connectionStringObj.getInstrumentationKey(), - telemetry, itemCountsByType, successItemCountsByType, failureItemCountsByType); + telemetry, batchMetadata); try { CompletableResultCode result = new CompletableResultCode(); diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/pipeline/TelemetryPipelineRequest.java b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/pipeline/TelemetryPipelineRequest.java index cb4ac9f4bcc0..d6f001bd7ab8 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/pipeline/TelemetryPipelineRequest.java +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/pipeline/TelemetryPipelineRequest.java @@ -6,14 +6,12 @@ import com.azure.core.http.HttpHeaderName; import com.azure.core.http.HttpMethod; import com.azure.core.http.HttpRequest; +import com.azure.monitor.opentelemetry.autoconfigure.implementation.statsbeat.TelemetryBatchMetadata; import reactor.core.publisher.Flux; import java.net.URL; import java.nio.ByteBuffer; -import java.util.Collections; -import java.util.HashMap; import java.util.List; -import java.util.Map; public class TelemetryPipelineRequest { @@ -23,36 +21,22 @@ public class TelemetryPipelineRequest { private final List byteBuffers; private final int contentLength; - // Customer-facing SDKStats metadata: item counts by telemetry type - // TODO consider extracting these three maps into a lightweight TelemetryBatchMetadata value - // object to reduce parameter proliferation and simplify TelemetryPipeline.send() signatures - private final Map itemCountsByType; - private final Map successItemCountsByType; - private final Map failureItemCountsByType; + // Customer-facing SDKStats metadata + private final TelemetryBatchMetadata batchMetadata; TelemetryPipelineRequest(URL url, String connectionString, String instrumentationKey, List byteBuffers) { - this(url, connectionString, instrumentationKey, byteBuffers, Collections.emptyMap(), Collections.emptyMap(), - Collections.emptyMap()); + this(url, connectionString, instrumentationKey, byteBuffers, TelemetryBatchMetadata.empty()); } public TelemetryPipelineRequest(URL url, String connectionString, String instrumentationKey, - List byteBuffers, Map itemCountsByType, Map successItemCountsByType, - Map failureItemCountsByType) { + List byteBuffers, TelemetryBatchMetadata batchMetadata) { this.url = url; this.connectionString = connectionString; this.instrumentationKey = instrumentationKey; this.byteBuffers = byteBuffers; contentLength = byteBuffers.stream().mapToInt(ByteBuffer::limit).sum(); - this.itemCountsByType = itemCountsByType != null - ? Collections.unmodifiableMap(new HashMap<>(itemCountsByType)) - : Collections.emptyMap(); - this.successItemCountsByType = successItemCountsByType != null - ? Collections.unmodifiableMap(new HashMap<>(successItemCountsByType)) - : Collections.emptyMap(); - this.failureItemCountsByType = failureItemCountsByType != null - ? Collections.unmodifiableMap(new HashMap<>(failureItemCountsByType)) - : Collections.emptyMap(); + this.batchMetadata = batchMetadata != null ? batchMetadata : TelemetryBatchMetadata.empty(); } public URL getUrl() { @@ -77,25 +61,12 @@ public String getInstrumentationKey() { } /** - * Returns item counts by telemetry type (e.g. "REQUEST" -> 200, "DEPENDENCY" -> 300). - * Empty map for batches where item counting is not applicable (e.g. statsbeat, disk retries). + * Returns metadata about the telemetry batch, including item counts by type + * and success/failure breakdowns. Returns empty metadata for batches where + * item counting is not applicable (e.g. statsbeat, disk retries). */ - public Map getItemCountsByType() { - return itemCountsByType; - } - - /** - * Returns counts of successful REQUEST/DEPENDENCY items (where isSuccess() == true). - */ - public Map getSuccessItemCountsByType() { - return successItemCountsByType; - } - - /** - * Returns counts of failed REQUEST/DEPENDENCY items (where isSuccess() == false). - */ - public Map getFailureItemCountsByType() { - return failureItemCountsByType; + public TelemetryBatchMetadata getTelemetryBatchMetadata() { + return batchMetadata; } HttpRequest createHttpRequest() { diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsTelemetryPipelineListener.java b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsTelemetryPipelineListener.java index 0bb5c3e034c2..5589992426eb 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsTelemetryPipelineListener.java +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsTelemetryPipelineListener.java @@ -8,9 +8,7 @@ import com.azure.monitor.opentelemetry.autoconfigure.implementation.pipeline.TelemetryPipelineResponse; import com.azure.monitor.opentelemetry.autoconfigure.implementation.utils.StatusCode; import io.opentelemetry.sdk.common.CompletableResultCode; -import reactor.util.annotation.Nullable; -import java.util.Map; import java.util.concurrent.ScheduledExecutorService; /** @@ -36,8 +34,8 @@ public void setScheduler(ScheduledExecutorService scheduler) { @Override public void onResponse(TelemetryPipelineRequest request, TelemetryPipelineResponse response) { - Map itemCountsByType = request.getItemCountsByType(); - if (itemCountsByType.isEmpty()) { + TelemetryBatchMetadata batchMetadata = request.getTelemetryBatchMetadata(); + if (batchMetadata.isEmpty()) { // No item metadata (e.g. from local storage resend) — skip tracking. // NOTE: This means telemetry retried from disk will not be counted in SDKStats. // To track those, item-count metadata would need to be persisted alongside the payload. @@ -47,26 +45,26 @@ public void onResponse(TelemetryPipelineRequest request, TelemetryPipelineRespon int statusCode = response.getStatusCode(); if (statusCode == 200) { - customerSdkStats.incrementSuccessCount(itemCountsByType); + customerSdkStats.incrementSuccessCount(batchMetadata.getItemCountsByType()); } else if (StatusCode.isRetryable(statusCode)) { // Retryable status codes: items will be retried via local storage String retryCode = String.valueOf(statusCode); String retryReason = getReasonPhraseForStatusCode(statusCode); - customerSdkStats.incrementRetryCount(itemCountsByType, retryCode, retryReason); + customerSdkStats.incrementRetryCount(batchMetadata.getItemCountsByType(), retryCode, retryReason); } else if (!StatusCode.isRedirect(statusCode)) { // Non-redirect, non-retryable status codes: items are dropped. // Redirects are handled transparently by the HTTP client and are not counted. String dropCode = String.valueOf(statusCode); String dropReason = getReasonPhraseForStatusCode(statusCode); - customerSdkStats.incrementDroppedCount(itemCountsByType, dropCode, dropReason, - request.getSuccessItemCountsByType(), request.getFailureItemCountsByType()); + customerSdkStats.incrementDroppedCount(batchMetadata.getItemCountsByType(), dropCode, dropReason, + batchMetadata.getSuccessItemCountsByType(), batchMetadata.getFailureItemCountsByType()); } } @Override public void onException(TelemetryPipelineRequest request, String errorMessage, Throwable throwable) { - Map itemCountsByType = request.getItemCountsByType(); - if (itemCountsByType.isEmpty()) { + TelemetryBatchMetadata batchMetadata = request.getTelemetryBatchMetadata(); + if (batchMetadata.isEmpty()) { return; } @@ -75,7 +73,7 @@ public void onException(TelemetryPipelineRequest request, String errorMessage, T String retryCode = isTimeout ? CustomerSdkStats.RETRY_CODE_CLIENT_TIMEOUT : CustomerSdkStats.RETRY_CODE_CLIENT_EXCEPTION; String retryReason = CustomerSdkStatsExceptionCategory.categorize(throwable); - customerSdkStats.incrementRetryCount(itemCountsByType, retryCode, retryReason); + customerSdkStats.incrementRetryCount(batchMetadata.getItemCountsByType(), retryCode, retryReason); } @Override @@ -87,7 +85,6 @@ public CompletableResultCode shutdown() { return CompletableResultCode.ofSuccess(); } - @Nullable static String getReasonPhraseForStatusCode(int statusCode) { switch (statusCode) { case 400: @@ -127,7 +124,7 @@ static String getReasonPhraseForStatusCode(int statusCode) { return "Gateway timeout"; default: - return null; + return "Unknown"; } } } diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsTelemetryType.java b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsTelemetryType.java index 8f42e144f080..359547766619 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsTelemetryType.java +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsTelemetryType.java @@ -5,6 +5,7 @@ import reactor.util.annotation.Nullable; +import java.util.Collections; import java.util.HashMap; import java.util.Map; @@ -14,17 +15,19 @@ */ public final class CustomerSdkStatsTelemetryType { - private static final Map MAPPING = new HashMap<>(); + private static final Map MAPPING; static { - MAPPING.put("Request", "REQUEST"); - MAPPING.put("RemoteDependency", "DEPENDENCY"); - MAPPING.put("Message", "TRACE"); - MAPPING.put("Exception", "EXCEPTION"); - MAPPING.put("Metric", "CUSTOM_METRIC"); - MAPPING.put("Event", "CUSTOM_EVENT"); - MAPPING.put("PageView", "PAGE_VIEW"); - MAPPING.put("Availability", "AVAILABILITY"); + Map m = new HashMap<>(); + m.put("Request", "REQUEST"); + m.put("RemoteDependency", "DEPENDENCY"); + m.put("Message", "TRACE"); + m.put("Exception", "EXCEPTION"); + m.put("Metric", "CUSTOM_METRIC"); + m.put("Event", "CUSTOM_EVENT"); + m.put("PageView", "PAGE_VIEW"); + m.put("Availability", "AVAILABILITY"); + MAPPING = Collections.unmodifiableMap(m); } /** diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/TelemetryBatchMetadata.java b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/TelemetryBatchMetadata.java new file mode 100644 index 000000000000..2f5fe89f20b1 --- /dev/null +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/TelemetryBatchMetadata.java @@ -0,0 +1,122 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.monitor.opentelemetry.autoconfigure.implementation.statsbeat; + +import com.azure.monitor.opentelemetry.autoconfigure.implementation.models.MonitorDomain; +import com.azure.monitor.opentelemetry.autoconfigure.implementation.models.RemoteDependencyData; +import com.azure.monitor.opentelemetry.autoconfigure.implementation.models.RequestData; +import com.azure.monitor.opentelemetry.autoconfigure.implementation.models.TelemetryItem; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Immutable snapshot of per-telemetry-type item counts for a batch of TelemetryItems. + * Used to propagate customer-facing SDKStats metadata through the telemetry pipeline + * without exposing three separate map parameters. + */ +public final class TelemetryBatchMetadata { + + private static final TelemetryBatchMetadata EMPTY + = new TelemetryBatchMetadata(Collections.emptyMap(), Collections.emptyMap(), Collections.emptyMap()); + + private final Map itemCountsByType; + private final Map successItemCountsByType; + private final Map failureItemCountsByType; + + TelemetryBatchMetadata(Map itemCountsByType, Map successItemCountsByType, + Map failureItemCountsByType) { + this.itemCountsByType = itemCountsByType; + this.successItemCountsByType = successItemCountsByType; + this.failureItemCountsByType = failureItemCountsByType; + } + + /** + * Returns a shared empty instance (no item counts). Used for batches where + * item counting is not applicable (e.g. statsbeat, disk retries, SDKStats metrics). + */ + public static TelemetryBatchMetadata empty() { + return EMPTY; + } + + /** + * Computes per-type item counts from a list of TelemetryItems. + * Internal items (e.g. Statsbeat) are excluded from the counts. + * For REQUEST and DEPENDENCY types, success/failure is split based on isSuccess(). + */ + public static TelemetryBatchMetadata fromTelemetryItems(List telemetryItems) { + Map itemCountsByType = new HashMap<>(); + Map successItemCountsByType = new HashMap<>(); + Map failureItemCountsByType = new HashMap<>(); + + for (TelemetryItem item : telemetryItems) { + String telemetryType = CustomerSdkStatsTelemetryType.fromTelemetryItemName(item.getName()); + if (telemetryType == null) { + // Skip internal items (e.g. Statsbeat) + continue; + } + + itemCountsByType.merge(telemetryType, 1L, Long::sum); + + // Track success/failure for Request and Dependency types + if ("REQUEST".equals(telemetryType) || "DEPENDENCY".equals(telemetryType)) { + MonitorDomain baseData = item.getData() != null ? item.getData().getBaseData() : null; + if (baseData instanceof RequestData) { + if (((RequestData) baseData).isSuccess()) { + successItemCountsByType.merge(telemetryType, 1L, Long::sum); + } else { + failureItemCountsByType.merge(telemetryType, 1L, Long::sum); + } + } else if (baseData instanceof RemoteDependencyData) { + Boolean success = ((RemoteDependencyData) baseData).isSuccess(); + if (success != null && success) { + successItemCountsByType.merge(telemetryType, 1L, Long::sum); + } else if (success != null) { + // Only count explicit false as failure; null is treated as unknown + failureItemCountsByType.merge(telemetryType, 1L, Long::sum); + } + } + } + } + + if (itemCountsByType.isEmpty()) { + return EMPTY; + } + + return new TelemetryBatchMetadata(Collections.unmodifiableMap(itemCountsByType), + Collections.unmodifiableMap(successItemCountsByType), + Collections.unmodifiableMap(failureItemCountsByType)); + } + + /** + * Returns item counts by telemetry type (e.g. "REQUEST" -> 200, "DEPENDENCY" -> 300). + * Empty map for batches where item counting is not applicable. + */ + public Map getItemCountsByType() { + return itemCountsByType; + } + + /** + * Returns counts of successful REQUEST/DEPENDENCY items (where isSuccess() == true). + */ + public Map getSuccessItemCountsByType() { + return successItemCountsByType; + } + + /** + * Returns counts of failed REQUEST/DEPENDENCY items (where isSuccess() == false). + */ + public Map getFailureItemCountsByType() { + return failureItemCountsByType; + } + + /** + * Returns true if this metadata has no item counts. + */ + public boolean isEmpty() { + return itemCountsByType.isEmpty(); + } +} diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/test/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsTelemetryPipelineListenerTest.java b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/test/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsTelemetryPipelineListenerTest.java index f57eab078b77..383c98649232 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/test/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsTelemetryPipelineListenerTest.java +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/test/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsTelemetryPipelineListenerTest.java @@ -138,16 +138,17 @@ public void testReasonPhraseForStatusCode() { .isEqualTo("Too many requests"); assertThat(CustomerSdkStatsTelemetryPipelineListener.getReasonPhraseForStatusCode(500)) .isEqualTo("Internal server error"); - assertThat(CustomerSdkStatsTelemetryPipelineListener.getReasonPhraseForStatusCode(999)).isNull(); + assertThat(CustomerSdkStatsTelemetryPipelineListener.getReasonPhraseForStatusCode(999)).isEqualTo("Unknown"); } private static TelemetryPipelineRequest createRequest(Map itemCountsByType) { List byteBuffers = Collections.singletonList(ByteBuffer.allocate(0)); try { URL url = new URL("https://dc.services.visualstudio.com/v2.1/track"); + TelemetryBatchMetadata batchMetadata + = new TelemetryBatchMetadata(itemCountsByType, Collections.emptyMap(), Collections.emptyMap()); return new TelemetryPipelineRequest(url, "InstrumentationKey=00000000-0000-0000-0000-0FEEDDADBEEF", - "00000000-0000-0000-0000-0FEEDDADBEEF", byteBuffers, itemCountsByType, Collections.emptyMap(), - Collections.emptyMap()); + "00000000-0000-0000-0000-0FEEDDADBEEF", byteBuffers, batchMetadata); } catch (MalformedURLException e) { throw new RuntimeException(e); } From c2871b3c0763caed68e9c7438234ec1c9599810c Mon Sep 17 00:00:00 2001 From: Rajkumar Rangaraj Date: Mon, 23 Feb 2026 20:33:03 -0800 Subject: [PATCH 6/8] fix 206 double count issue --- .../pipeline/TelemetryPipeline.java | 5 ++--- ...tomerSdkStatsTelemetryPipelineListener.java | 9 ++++++++- .../statsbeat/TelemetryBatchMetadata.java | 3 +-- ...rSdkStatsTelemetryPipelineListenerTest.java | 18 ++++++++++++++++++ 4 files changed, 29 insertions(+), 6 deletions(-) diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/pipeline/TelemetryPipeline.java b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/pipeline/TelemetryPipeline.java index d3c514466b3d..8d5b4b7f969f 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/pipeline/TelemetryPipeline.java +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/pipeline/TelemetryPipeline.java @@ -57,9 +57,8 @@ public CompletableResultCode send(List telemetry, String connectionS URL url = redirectCache.computeIfAbsent(connectionString, k -> getFullIngestionUrl(connectionStringObj.getIngestionEndpoint())); - TelemetryPipelineRequest request - = new TelemetryPipelineRequest(url, connectionString, connectionStringObj.getInstrumentationKey(), - telemetry, batchMetadata); + TelemetryPipelineRequest request = new TelemetryPipelineRequest(url, connectionString, + connectionStringObj.getInstrumentationKey(), telemetry, batchMetadata); try { CompletableResultCode result = new CompletableResultCode(); diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsTelemetryPipelineListener.java b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsTelemetryPipelineListener.java index 5589992426eb..ed3fa4d36dd3 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsTelemetryPipelineListener.java +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsTelemetryPipelineListener.java @@ -44,7 +44,14 @@ public void onResponse(TelemetryPipelineRequest request, TelemetryPipelineRespon int statusCode = response.getStatusCode(); - if (statusCode == 200) { + if (statusCode == 200 || statusCode == 206) { + // 200 = full success; 206 = partial success (some items accepted, some rejected). + // For 206, we count the entire batch as success because: + // - The majority of items were accepted by the ingestion service. + // - The failed items are retried from disk by LocalStorageTelemetryPipelineListener. + // - Disk retries carry empty metadata, so there is no double-counting risk. + // - Splitting counts proportionally per-type would require complex response parsing + // for a rare edge case. customerSdkStats.incrementSuccessCount(batchMetadata.getItemCountsByType()); } else if (StatusCode.isRetryable(statusCode)) { // Retryable status codes: items will be retried via local storage diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/TelemetryBatchMetadata.java b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/TelemetryBatchMetadata.java index 2f5fe89f20b1..8df6b39d11e8 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/TelemetryBatchMetadata.java +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/TelemetryBatchMetadata.java @@ -87,8 +87,7 @@ public static TelemetryBatchMetadata fromTelemetryItems(List tele } return new TelemetryBatchMetadata(Collections.unmodifiableMap(itemCountsByType), - Collections.unmodifiableMap(successItemCountsByType), - Collections.unmodifiableMap(failureItemCountsByType)); + Collections.unmodifiableMap(successItemCountsByType), Collections.unmodifiableMap(failureItemCountsByType)); } /** diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/test/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsTelemetryPipelineListenerTest.java b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/test/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsTelemetryPipelineListenerTest.java index 383c98649232..a4a753de5783 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/test/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsTelemetryPipelineListenerTest.java +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/test/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsTelemetryPipelineListenerTest.java @@ -46,6 +46,24 @@ public void testSuccessResponse() { assertThat(customerSdkStats.getSuccessCount("DEPENDENCY")).isEqualTo(3); } + @Test + public void testPartialSuccessResponse206() { + Map itemCounts = new HashMap<>(); + itemCounts.put("REQUEST", 8L); + itemCounts.put("TRACE", 2L); + + TelemetryPipelineRequest request = createRequest(itemCounts); + // 206 = partial success: some items accepted, some rejected + TelemetryPipelineResponse response = new TelemetryPipelineResponse(206, "{\"itemsReceived\":10,\"itemsAccepted\":7,\"errors\":[]}"); + + listener.onResponse(request, response); + + // Entire batch counted as success (failed items retried from disk with empty metadata) + assertThat(customerSdkStats.getSuccessCount("REQUEST")).isEqualTo(8); + assertThat(customerSdkStats.getSuccessCount("TRACE")).isEqualTo(2); + assertThat(customerSdkStats.getDroppedCount("REQUEST", "206")).isEqualTo(0); + } + @Test public void testRetryableStatusCode429() { Map itemCounts = Collections.singletonMap("TRACE", 10L); From de198f04adf84d36930670f38bcb9c39833ccc5c Mon Sep 17 00:00:00 2001 From: Rajkumar Rangaraj Date: Tue, 24 Feb 2026 13:21:22 -0800 Subject: [PATCH 7/8] format TelemetryPipelineResponse initialization for better readability --- .../CustomerSdkStatsTelemetryPipelineListenerTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/test/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsTelemetryPipelineListenerTest.java b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/test/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsTelemetryPipelineListenerTest.java index a4a753de5783..1c99704392ac 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/test/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsTelemetryPipelineListenerTest.java +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/test/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsTelemetryPipelineListenerTest.java @@ -54,7 +54,8 @@ public void testPartialSuccessResponse206() { TelemetryPipelineRequest request = createRequest(itemCounts); // 206 = partial success: some items accepted, some rejected - TelemetryPipelineResponse response = new TelemetryPipelineResponse(206, "{\"itemsReceived\":10,\"itemsAccepted\":7,\"errors\":[]}"); + TelemetryPipelineResponse response + = new TelemetryPipelineResponse(206, "{\"itemsReceived\":10,\"itemsAccepted\":7,\"errors\":[]}"); listener.onResponse(request, response); From bba3205ede6d57f3fe473c69da7e17c9bbdcae63 Mon Sep 17 00:00:00 2001 From: Rajkumar Rangaraj Date: Wed, 25 Mar 2026 11:20:51 -0700 Subject: [PATCH 8/8] Address Matthew's review feedback on CustomerSdkStats - Extract cause chain depth limit (10) into MAX_CAUSE_CHAIN_DEPTH constant - Rename isTimeout() to containsTimeout() to clarify it traverses the chain - Rename isSingleTimeoutType() to isTimeoutType() for consistency with isNetworkType/isStorageType - Unify RequestData/RemoteDependencyData null-safe success handling in TelemetryBatchMetadata Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CustomerSdkStatsExceptionCategory.java | 14 +++++++------ ...omerSdkStatsTelemetryPipelineListener.java | 2 +- .../statsbeat/TelemetryBatchMetadata.java | 21 ++++++++----------- ...CustomerSdkStatsExceptionCategoryTest.java | 15 +++++++------ 4 files changed, 27 insertions(+), 25 deletions(-) diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsExceptionCategory.java b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsExceptionCategory.java index c86523b7b53f..3199d95ba389 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsExceptionCategory.java +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsExceptionCategory.java @@ -20,6 +20,8 @@ final class CustomerSdkStatsExceptionCategory { static final String STORAGE_EXCEPTION = "Storage exception"; static final String CLIENT_EXCEPTION = "Client exception"; + private static final int MAX_CAUSE_CHAIN_DEPTH = 10; + /** * Returns a low-cardinality exception category string. */ @@ -34,11 +36,11 @@ static String categorize(Throwable throwable) { * Returns true if any exception in the cause chain represents a timeout scenario * (use CLIENT_TIMEOUT retry code), false otherwise (use CLIENT_EXCEPTION retry code). */ - static boolean isTimeout(Throwable throwable) { + static boolean containsTimeout(Throwable throwable) { Throwable current = throwable; int depth = 0; - while (current != null && depth < 10) { - if (isSingleTimeoutType(current)) { + while (current != null && depth < MAX_CAUSE_CHAIN_DEPTH) { + if (isTimeoutType(current)) { return true; } current = current.getCause(); @@ -54,8 +56,8 @@ private static String categorizeByType(Throwable throwable) { // consistently at each level of the chain. Throwable current = throwable; int depth = 0; - while (current != null && depth < 10) { - if (isSingleTimeoutType(current)) { + while (current != null && depth < MAX_CAUSE_CHAIN_DEPTH) { + if (isTimeoutType(current)) { return TIMEOUT_EXCEPTION; } if (isNetworkType(current)) { @@ -73,7 +75,7 @@ private static String categorizeByType(Throwable throwable) { /** * Checks whether a single throwable (not its cause chain) is a timeout type. */ - private static boolean isSingleTimeoutType(Throwable throwable) { + private static boolean isTimeoutType(Throwable throwable) { if (throwable instanceof SocketTimeoutException || throwable instanceof TimeoutException) { return true; } diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsTelemetryPipelineListener.java b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsTelemetryPipelineListener.java index ed3fa4d36dd3..ce517d6c253c 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsTelemetryPipelineListener.java +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsTelemetryPipelineListener.java @@ -76,7 +76,7 @@ public void onException(TelemetryPipelineRequest request, String errorMessage, T } // Exceptions result in retry via local storage persistence - boolean isTimeout = CustomerSdkStatsExceptionCategory.isTimeout(throwable); + boolean isTimeout = CustomerSdkStatsExceptionCategory.containsTimeout(throwable); String retryCode = isTimeout ? CustomerSdkStats.RETRY_CODE_CLIENT_TIMEOUT : CustomerSdkStats.RETRY_CODE_CLIENT_EXCEPTION; String retryReason = CustomerSdkStatsExceptionCategory.categorize(throwable); diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/TelemetryBatchMetadata.java b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/TelemetryBatchMetadata.java index 8df6b39d11e8..9ff0c377eec8 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/TelemetryBatchMetadata.java +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/main/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/TelemetryBatchMetadata.java @@ -64,20 +64,17 @@ public static TelemetryBatchMetadata fromTelemetryItems(List tele // Track success/failure for Request and Dependency types if ("REQUEST".equals(telemetryType) || "DEPENDENCY".equals(telemetryType)) { MonitorDomain baseData = item.getData() != null ? item.getData().getBaseData() : null; + Boolean success = null; if (baseData instanceof RequestData) { - if (((RequestData) baseData).isSuccess()) { - successItemCountsByType.merge(telemetryType, 1L, Long::sum); - } else { - failureItemCountsByType.merge(telemetryType, 1L, Long::sum); - } + success = ((RequestData) baseData).isSuccess(); } else if (baseData instanceof RemoteDependencyData) { - Boolean success = ((RemoteDependencyData) baseData).isSuccess(); - if (success != null && success) { - successItemCountsByType.merge(telemetryType, 1L, Long::sum); - } else if (success != null) { - // Only count explicit false as failure; null is treated as unknown - failureItemCountsByType.merge(telemetryType, 1L, Long::sum); - } + success = ((RemoteDependencyData) baseData).isSuccess(); + } + if (success != null && success) { + successItemCountsByType.merge(telemetryType, 1L, Long::sum); + } else if (success != null) { + // Only count explicit false as failure; null is treated as unknown + failureItemCountsByType.merge(telemetryType, 1L, Long::sum); } } } diff --git a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/test/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsExceptionCategoryTest.java b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/test/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsExceptionCategoryTest.java index 8201856acdd0..dfa48c585f87 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/test/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsExceptionCategoryTest.java +++ b/sdk/monitor/azure-monitor-opentelemetry-autoconfigure/src/test/java/com/azure/monitor/opentelemetry/autoconfigure/implementation/statsbeat/CustomerSdkStatsExceptionCategoryTest.java @@ -19,21 +19,23 @@ public class CustomerSdkStatsExceptionCategoryTest { public void testSocketTimeoutException() { assertThat(CustomerSdkStatsExceptionCategory.categorize(new SocketTimeoutException("Read timed out"))) .isEqualTo("Timeout exception"); - assertThat(CustomerSdkStatsExceptionCategory.isTimeout(new SocketTimeoutException("Read timed out"))).isTrue(); + assertThat(CustomerSdkStatsExceptionCategory.containsTimeout(new SocketTimeoutException("Read timed out"))) + .isTrue(); } @Test public void testTimeoutException() { assertThat(CustomerSdkStatsExceptionCategory.categorize(new TimeoutException("timeout"))) .isEqualTo("Timeout exception"); - assertThat(CustomerSdkStatsExceptionCategory.isTimeout(new TimeoutException("timeout"))).isTrue(); + assertThat(CustomerSdkStatsExceptionCategory.containsTimeout(new TimeoutException("timeout"))).isTrue(); } @Test public void testUnknownHostException() { assertThat(CustomerSdkStatsExceptionCategory.categorize(new UnknownHostException("host.example.com"))) .isEqualTo("Network exception"); - assertThat(CustomerSdkStatsExceptionCategory.isTimeout(new UnknownHostException("host.example.com"))).isFalse(); + assertThat(CustomerSdkStatsExceptionCategory.containsTimeout(new UnknownHostException("host.example.com"))) + .isFalse(); } @Test @@ -52,13 +54,14 @@ public void testIOException() { public void testGenericRuntimeException() { assertThat(CustomerSdkStatsExceptionCategory.categorize(new RuntimeException("Something went wrong"))) .isEqualTo("Client exception"); - assertThat(CustomerSdkStatsExceptionCategory.isTimeout(new RuntimeException("Something went wrong"))).isFalse(); + assertThat(CustomerSdkStatsExceptionCategory.containsTimeout(new RuntimeException("Something went wrong"))) + .isFalse(); } @Test public void testNullThrowable() { assertThat(CustomerSdkStatsExceptionCategory.categorize(null)).isEqualTo("Client exception"); - assertThat(CustomerSdkStatsExceptionCategory.isTimeout(null)).isFalse(); + assertThat(CustomerSdkStatsExceptionCategory.containsTimeout(null)).isFalse(); } @Test @@ -66,7 +69,7 @@ public void testWrappedSocketTimeoutException() { // A SocketTimeoutException wrapped in a RuntimeException should still be detected via cause RuntimeException wrapper = new RuntimeException("wrapper", new SocketTimeoutException("Read timed out")); assertThat(CustomerSdkStatsExceptionCategory.categorize(wrapper)).isEqualTo("Timeout exception"); - assertThat(CustomerSdkStatsExceptionCategory.isTimeout(wrapper)).isTrue(); + assertThat(CustomerSdkStatsExceptionCategory.containsTimeout(wrapper)).isTrue(); } @Test