diff --git a/changelog.txt b/changelog.txt index 0eff10ab14..f8cd4c19f1 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,6 @@ vNext ---------- +- [MINOR] Add OTel Benchmarker (#2786) - [MAJOR] Add KeyStoreBackedSecretKeyProvider (#2674) - [MINOR] Add Open Id configuration issuer validation reporting in OpenIdProviderConfigurationClient (#2751) - [MINOR] Add helper method to record elapsed time (#2768) diff --git a/common4j/build.gradle b/common4j/build.gradle index 232d2dfa58..4f4568f753 100644 --- a/common4j/build.gradle +++ b/common4j/build.gradle @@ -166,6 +166,7 @@ def nativeAuthConfigFilePathParameter = "" // will be blank unless specified by def nativeAuthConfigStringParameter = "" // will be blank unless specified by developer, used to run e2e tests in CI def disableAcquireTokenSilentTimeoutParameter = false // will be false unless specified by developer def allowOneboxAuthorities = false // will be false unless specified by developer +def shouldSkipSilentTokenCommandCacheForStressTest = false // will be false unless specified by developer, required for running concurrent stress test in MSAL/Broker. if (project.hasProperty("slice")) { sliceParameter = slice @@ -205,6 +206,10 @@ if (project.hasProperty("allowOneboxAuthorities")) { allowOneboxAuthorities = true } +if (project.hasProperty("shouldSkipSilentTokenCommandCacheForStressTest")) { + shouldSkipSilentTokenCommandCacheForStressTest = true +} + sourceSets { main { java.srcDirs = ['src/main', "$project.buildDir/generated/source/buildConfig/main"] @@ -216,6 +221,7 @@ sourceSets { buildConfigField("String", "NATIVE_AUTH_CONFIG_STRING", "\"$nativeAuthConfigStringParameter\"") buildConfigField("boolean", "DISABLE_ACQUIRE_TOKEN_SILENT_TIMEOUT", "${disableAcquireTokenSilentTimeoutParameter}") buildConfigField("boolean", "ALLOW_ONEBOX_AUTHORITIES", "${allowOneboxAuthorities}") + buildConfigField("boolean", "SHOULD_SKIP_SILENT_TOKEN_COMMAND_CACHE_FOR_STRESS_TEST", "${shouldSkipSilentTokenCommandCacheForStressTest}") } test { java.srcDirs = ['src/test'] diff --git a/common4j/src/main/com/microsoft/identity/common/java/commands/SilentTokenCommand.java b/common4j/src/main/com/microsoft/identity/common/java/commands/SilentTokenCommand.java index aed08ee935..08fe6210af 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/commands/SilentTokenCommand.java +++ b/common4j/src/main/com/microsoft/identity/common/java/commands/SilentTokenCommand.java @@ -22,6 +22,7 @@ // THE SOFTWARE. package com.microsoft.identity.common.java.commands; +import com.microsoft.identity.common.java.BuildConfig; import com.microsoft.identity.common.java.WarningType; import com.microsoft.identity.common.java.commands.parameters.SilentTokenCommandParameters; import com.microsoft.identity.common.java.constants.OAuth2ErrorCode; @@ -150,6 +151,11 @@ public AcquireTokenResult execute() throws Exception { @Override public boolean isEligibleForCaching() { + if (BuildConfig.SHOULD_SKIP_SILENT_TOKEN_COMMAND_CACHE_FOR_STRESS_TEST) { + // by disabling this, MSAL/Broker will allow similar request to be executed + // as opposed to being handled by cache. + return false; + } return true; } diff --git a/common4j/src/main/com/microsoft/identity/common/java/controllers/CommandDispatcher.java b/common4j/src/main/com/microsoft/identity/common/java/controllers/CommandDispatcher.java index 1654e98a5c..20920d6218 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/controllers/CommandDispatcher.java +++ b/common4j/src/main/com/microsoft/identity/common/java/controllers/CommandDispatcher.java @@ -89,6 +89,7 @@ import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; @@ -114,6 +115,14 @@ public class CommandDispatcher { @SuppressWarnings(WarningType.rawtype_warning) private static ConcurrentMap> sExecutingCommandMap = new ConcurrentHashMap<>(); + /** + * Returns the approximate number of threads that are actively + * executing tasks in the silent request thread pool. + */ + public static int getSilentRequestActiveCount(){ + return ((ThreadPoolExecutor)sSilentExecutor).getActiveCount(); + } + /** * Remove all keys that are the command reference from the executing command map. Since if they key has * been changed, remove will not work, construct a new map and add all keys that are not identically diff --git a/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/BenchmarkSpan.kt b/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/BenchmarkSpan.kt new file mode 100644 index 0000000000..60334bc5a3 --- /dev/null +++ b/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/BenchmarkSpan.kt @@ -0,0 +1,183 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +package com.microsoft.identity.common.java.opentelemetry + +import com.microsoft.identity.common.java.controllers.CommandDispatcher +import io.opentelemetry.api.common.AttributeKey +import io.opentelemetry.api.common.Attributes +import io.opentelemetry.api.trace.Span +import io.opentelemetry.api.trace.SpanContext +import io.opentelemetry.api.trace.StatusCode +import java.util.concurrent.TimeUnit + +interface IBenchmarkSpan { + /** + * Returns a list of status changes along with their timestamps in nanoseconds. + **/ + fun getStatuses(): List> + + /** + * Returns the span name. + **/ + fun getSpanName(): String + + /** + * The start time of the span in nanoseconds. + **/ + fun getStartTimeInNanoSeconds(): Long + + /** + * The end time of the span in nanoseconds. + **/ + fun getEndTimeInNanoSeconds(): Long + + /** + * # of concurrent active silent requests when this span is started. + **/ + fun getConcurrentSilentRequestSize(): Int + + /** + * The exception recorded on this span, if any. + **/ + fun getException(): Throwable? +} + +/** + * A span wrapper class for benchmarking purposes. + * + * @param originalSpan The original span to be wrapped. + * @param printer The printer to print the benchmark results. + * @param spanName The name of the span. + **/ +class BenchmarkSpan( + val originalSpan: Span, + val printer: IBenchmarkSpanPrinter, + private val spanName: String) : Span, IBenchmarkSpan { + + // Pair of (status name, timestamp in nano seconds) + val statuses : ArrayList> = arrayListOf() + private var exception: Throwable? = null + + private var startTimeInNanoSeconds: Long = System.nanoTime() + private var endTimeInNanoSeconds: Long = 0L + + // # of concurrent active silent requests when this span is started. + private var concurrentSize = 1 + + override fun getStatuses(): List> { + return statuses + } + + override fun getSpanName(): String { + return spanName + } + + override fun getStartTimeInNanoSeconds(): Long { + return startTimeInNanoSeconds + } + + override fun getEndTimeInNanoSeconds(): Long { + return endTimeInNanoSeconds + } + + override fun getConcurrentSilentRequestSize(): Int { + return concurrentSize + } + + override fun getException(): Throwable? { + return exception + } + + fun start(){ + startTimeInNanoSeconds = System.nanoTime() + concurrentSize = CommandDispatcher.getSilentRequestActiveCount() + } + + override fun end() { + endTimeInNanoSeconds = System.nanoTime() + printer.printAsync(this) + return originalSpan.end() + } + + override fun end(timestamp: Long, unit: TimeUnit) { + endTimeInNanoSeconds = System.nanoTime() + printer.printAsync(this) + return originalSpan.end(timestamp, unit) + } + + override fun setAttribute( + key: AttributeKey, + value: T + ): Span? { + statuses.add(Pair(key.toString(), System.nanoTime())) + return originalSpan.setAttribute(key, value) + } + + override fun addEvent( + name: String, + attributes: Attributes + ): Span? { + statuses.add(Pair(name, System.nanoTime())) + return originalSpan.addEvent(name, attributes) + } + + override fun addEvent( + name: String, + attributes: Attributes, + timestamp: Long, + unit: TimeUnit + ): Span? { + statuses.add(Pair(name, System.nanoTime())) + return originalSpan.addEvent(name, attributes, timestamp, unit) + } + + override fun setStatus( + statusCode: StatusCode, + description: String + ): Span? { + statuses.add(Pair("SetStatus:$statusCode", System.nanoTime())) + return originalSpan.setStatus(statusCode, description) + } + + override fun recordException( + exception: Throwable, + additionalAttributes: Attributes + ): Span? { + val timestamp = System.nanoTime() + statuses.add(Pair("recordException", timestamp)) + this.exception = exception + return originalSpan.recordException(exception, additionalAttributes) + } + + override fun updateName(name: String): Span? { + return originalSpan.updateName(name) + } + + override fun getSpanContext(): SpanContext? { + return originalSpan.spanContext + } + + override fun isRecording(): Boolean { + return originalSpan.isRecording + } +} diff --git a/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/DefaultBenchmarkSpanPrinter.kt b/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/DefaultBenchmarkSpanPrinter.kt new file mode 100644 index 0000000000..bac977d722 --- /dev/null +++ b/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/DefaultBenchmarkSpanPrinter.kt @@ -0,0 +1,423 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +package com.microsoft.identity.common.java.opentelemetry + +import com.microsoft.identity.common.java.logging.Logger +import java.io.File +import java.io.FileWriter +import java.io.IOException +import java.text.SimpleDateFormat +import java.util.* +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +/** + * Represents the different metric types that can be calculated and displayed. + */ +enum class MetricType(val displayName: String) { + AVERAGE("Avg"), + P50("P50"), + P75("P75"), + P90("P90"), + P95("P95"), + P99("P99") +} + +/** + * Default implementation of IBenchmarkSpanPrinter that asynchronously writes + * benchmark span status information to a file. + * + * @param outputDirectoryAbsolutePath Path to the directory where benchmark files will be written. + * @param batchSize Size of batches to accumulate before writing to file (default: 1, meaning write each span immediately). + * @param metricsToDisplay List of MetricType to display in the output (default: AVERAGE, P50, P75, P90). + */ +class DefaultBenchmarkSpanPrinter( + private val outputDirectoryAbsolutePath: String, + private val batchSize: Int = 1, + private val metricsToDisplay: List = listOf(MetricType.AVERAGE, MetricType.P50, MetricType.P75, MetricType.P90) +) : IBenchmarkSpanPrinter { + + companion object { + private val TAG = DefaultBenchmarkSpanPrinter::class.java.simpleName + private val DATE_FORMAT = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US) + } + + private val singleThreadExecutor = Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "BenchmarkSpanPrinter").apply { + isDaemon = true + } + } + + // Storage for batching spans - now per span name + private val batchedSpansByName = mutableMapOf>() + private val batchCounterByName = mutableMapOf() + + override fun printAsync(span: IBenchmarkSpan) { + singleThreadExecutor.submit { + try { + val spanName = span.getSpanName() + + // Get or create the batch list for this span name + val batchList = batchedSpansByName.getOrPut(spanName) { mutableListOf() } + batchList.add(span) + + // Increment the counter for this span name + val currentCount = batchCounterByName.getOrDefault(spanName, 0) + 1 + batchCounterByName[spanName] = currentCount + + if (currentCount >= batchSize) { + writeSpansToFile(batchList.toList()) + batchList.clear() + batchCounterByName[spanName] = 0 + } + } catch (e: Exception) { + Logger.error(TAG, "Failed to write span status to file", e) + } + } + } + + private fun writeSpansToFile(spans: List) { + if (spans.isEmpty()) return + + // All spans in the batch now have the same name due to per-span-name batching + val spanName = spans.first().getSpanName() + + try { + val file = getFile(spanName) + + FileWriter(file, true).use { writer -> + // Write statistics for the spans (averages, percentiles, etc.) + val statisticalData = calculateStatistics(spans) + + if (statisticalData.isEmpty()) { + val timestamp = DATE_FORMAT.format(Date()) + writer.appendLine("| $timestamp | N/A | No status entries recorded (batch size: ${spans.size})") + return@use + } + + // Write session header + val formattedTimestamp = DATE_FORMAT.format(Date(System.currentTimeMillis())) + + // Calculate total duration using dedicated end times (convert nanoseconds to milliseconds) + val totalDurationFormatted = spans.mapNotNull { span -> + val spanStartTime = span.getStartTimeInNanoSeconds() + val spanEndTime = span.getEndTimeInNanoSeconds() + if (spanEndTime > 0) { // Only include spans that have been ended + TimeUnit.NANOSECONDS.toMillis(spanEndTime - spanStartTime) + } else null + }.let { durations -> + if (durations.isNotEmpty()) { + val avgDuration = durations.average().toLong() + "${avgDuration}ms" + } else { + "N/A" + } + } + + // Calculate average concurrent size + val avgConcurrentSize = spans.map { it.getConcurrentSilentRequestSize() }.average() + val avgConcurrentSizeFormatted = String.format(Locale.US, "%.2f", avgConcurrentSize) + + writer.appendLine("") + writer.appendLine("=== Statistical Benchmark Session: $formattedTimestamp | Avg Total Duration: $totalDurationFormatted | Avg Concurrent Size: $avgConcurrentSizeFormatted | Batch Size: ${spans.size} ===") + writer.appendLine("") + + writer.appendLine("| Status Entry | Metric | Time Since Previous | Time Since Start |") + writer.appendLine("|--------------------------------------------------|--------|---------------------|------------------|") + + statisticalData.forEach { statsData -> + val paddedStatus = statsData.statusName.take(48).padEnd(48) + + // Print only the configured metrics + metricsToDisplay.forEach { metricType -> + val metricLabel = metricType.displayName.padEnd(6) + val sincePrevValue = getMetricValue(statsData.timeSincePreviousStats, metricType).padEnd(19) + val sinceStartValue = getMetricValue(statsData.timeSinceStartStats, metricType).padEnd(16) + + val statusColumn = if (metricType == metricsToDisplay.first()) { + paddedStatus + } else { + "".padEnd(48) + } + + writer.appendLine("| $statusColumn | $metricLabel | $sincePrevValue | $sinceStartValue |") + } + + // Separator line between status entries + writer.appendLine("|--------------------------------------------------|--------|---------------------|------------------|") + } + + writer.appendLine("") + + // Check if any status contains "recordException" and print slowest exceptions + val hasExceptions = statisticalData.any { it.statusName == "recordException" } + if (hasExceptions) { + writeSlowestExceptions(writer, spans) + } + + writer.flush() + } + } catch (e: IOException) { + Logger.error(TAG, "IOException while writing averaged batch to file: $outputDirectoryAbsolutePath", e) + } + } + + /** + * Get a file to write the benchmark result to. + * Separate file for each span name. + **/ + private fun getFile(spanName: String): File { + val outputDir = File(outputDirectoryAbsolutePath) + outputDir.mkdirs() + + val sanitizedSpanName = sanitizeFileName(spanName) + val filename = "${sanitizedSpanName}_benchmark.log" + val file = File(outputDir, filename) + return file + } + + /** + * Replace characters that are not safe for filenames + **/ + private fun sanitizeFileName(name: String): String { + return name.replace(Regex("[^a-zA-Z0-9_-]"), "_") + .replace(Regex("_+"), "_") + .trim('_') + .takeIf { it.isNotEmpty() } ?: "span" + } + + /** + * Print the 5 slowest exceptions (if any) in the batch. + **/ + private fun writeSlowestExceptions(writer: FileWriter, spans: List) { + // Collect all exception timings across all spans + data class ExceptionTiming(val spanIndex: Int, val timeSinceStartMs: Long, val exception: Throwable) + + val exceptionTimings = mutableListOf() + + spans.forEachIndexed { index, span -> + val exception = span.getException() + if (exception != null) { + val statuses = span.getStatuses() + val startTime = span.getStartTimeInNanoSeconds() + + // Find the recordException status to get the timestamp + val exceptionStatus = statuses.find { it.first == "recordException" } + if (exceptionStatus != null) { + val timeSinceStartMs = TimeUnit.NANOSECONDS.toMillis(exceptionStatus.second - startTime) + exceptionTimings.add(ExceptionTiming(index + 1, timeSinceStartMs, exception)) + } + } + } + + if (exceptionTimings.isNotEmpty()) { + // Sort by time since start (descending) and take top 5 + val slowestExceptions = exceptionTimings.sortedByDescending { it.timeSinceStartMs }.take(5) + + writer.appendLine("=== 5 Slowest Exceptions (Time Since Start) ===") + writer.appendLine("") + writer.appendLine("| Rank | Span # | Time Since Start | Exception Type | Message ") + writer.appendLine("|------|--------|------------------|------------------------------------------|------------------------------------------") + + slowestExceptions.forEachIndexed { rank, exceptionData -> + val rankStr = (rank + 1).toString().padEnd(4) + val spanNumStr = exceptionData.spanIndex.toString().padEnd(6) + val timeStr = "${exceptionData.timeSinceStartMs}ms".padEnd(16) + val exceptionType = exceptionData.exception.javaClass.simpleName.take(40).padEnd(40) + val exceptionMessage = (exceptionData.exception.message ?: "N/A") + writer.appendLine("| $rankStr | $spanNumStr | $timeStr | $exceptionType | $exceptionMessage ") + } + + writer.appendLine("") + } + } + + /** + * Calculate statistical metrics (average, percentiles) for all status entries across the batch of spans. + * + * For each unique status name found across all spans, this method: + * 1. Collects timing values (time since previous status, time since start) from all spans + * 2. Calculates configured statistical metrics (e.g., Avg, P50, P75, P90) + * 3. Returns the results sorted by the first configured metric's time since start value + * + * @param spans List of spans to analyze (all spans should have the same span name) + * + * @return List of statistical data for each unique status, sorted by the first configured metric's time since start + */ + private fun calculateStatistics(spans: List): List { + if (spans.isEmpty()) return emptyList() + + // Collect all unique status names across all spans + val allStatusNames = mutableSetOf() + for (span in spans) { + for ((statusName, _) in span.getStatuses()) { + allStatusNames.add(statusName) + } + } + + val result = mutableListOf() + + for (statusName in allStatusNames) { + val timeSincePreviousValues = mutableListOf() + val timeSinceStartValues = mutableListOf() + + for (span in spans) { + val statuses = span.getStatuses() + val startTime = span.getStartTimeInNanoSeconds() + + // Find this status in the span + val statusIndex = statuses.indexOfFirst { it.first == statusName } + if (statusIndex >= 0) { + val entry = statuses[statusIndex] + val timeSinceStartMs = TimeUnit.NANOSECONDS.toMillis(entry.second - startTime) + + val previousTime = if (statusIndex > 0) { + statuses[statusIndex - 1].second + } else { + startTime + } + val timeSincePreviousMs = TimeUnit.NANOSECONDS.toMillis(entry.second - previousTime) + + timeSincePreviousValues.add(timeSincePreviousMs) + timeSinceStartValues.add(timeSinceStartMs) + } + } + + if (timeSincePreviousValues.isNotEmpty()) { + result.add( + StatisticalStatusData( + statusName = statusName, + timeSinceStartStats = calculateMetrics(timeSinceStartValues), + timeSincePreviousStats = calculateMetrics(timeSincePreviousValues) + ) + ) + } + } + + // Sort by the first configured metric's Time Since Start value, or by status name if no metrics + return if (metricsToDisplay.isNotEmpty()) { + result.sortedBy { it.timeSinceStartStats[metricsToDisplay.first()] ?: 0L } + } else { + result.sortedBy { it.statusName } + } + } + + private fun calculateMetrics(values: List): Map { + if (values.isEmpty()) return emptyMap() + + val result = mutableMapOf() + + // Only calculate metrics that are actually displayed + metricsToDisplay.forEach { metricType -> + val value = when (metricType) { + MetricType.AVERAGE -> values.average().toLong() + MetricType.P50 -> percentile(values, 50.0) + MetricType.P75 -> percentile(values, 75.0) + MetricType.P90 -> percentile(values, 90.0) + MetricType.P95 -> percentile(values, 95.0) + MetricType.P99 -> percentile(values, 99.0) + } + result[metricType] = value + } + + return result + } + + private fun percentile(values: List, percentile: Double): Long { + if (values.isEmpty()) return 0L + if (values.size == 1) return values[0] + + val sortedValues = values.sorted() + + val index = (percentile / 100.0) * (sortedValues.size - 1) + val lower = kotlin.math.floor(index).toInt() + val upper = kotlin.math.ceil(index).toInt() + + if (lower == upper) { + return sortedValues[lower] + } + + val weight = index - lower + return (sortedValues[lower] * (1 - weight) + sortedValues[upper] * weight).toLong() + } + + /** + * Get the metric value from the metrics map based on MetricType + */ + private fun getMetricValue(metricsMap: Map, metricType: MetricType): String { + val value = metricsMap[metricType] ?: 0L + return "${value}ms" + } + + /** + * Holds statistical data for a single status entry across multiple spans. + * + * This data class aggregates timing metrics for a specific status name, + * containing both "time since start" and "time since previous" statistics + * calculated across all spans in the batch. + * + * @property statusName The name of the status entry (e.g., "acquireToken", "networkCall") + * @property timeSinceStartStats Map of metric type to value (in ms) for time elapsed from span start to this status + * @property timeSincePreviousStats Map of metric type to value (in ms) for time elapsed from previous status to this status + */ + data class StatisticalStatusData( + val statusName: String, + val timeSinceStartStats: Map, + val timeSincePreviousStats: Map + ) + + /** + * Flushes any remaining spans in the batch and writes them to file. + * This should be called when shutting down or when you want to force writing + * of incomplete batches. + */ + fun flushRemainingSpans() { + singleThreadExecutor.submit { + synchronized(batchedSpansByName) { + batchedSpansByName.forEach { (spanName, spans) -> + if (spans.isNotEmpty()) { + writeSpansToFile(spans.toList()) + spans.clear() + batchCounterByName[spanName] = 0 + } + } + } + } + } + + /** + * Shuts down the executor service. Should be called when the printer is no longer needed. + * This will also flush any remaining spans before shutting down. + */ + fun shutdown() { + // Flush any remaining spans before shutdown + flushRemainingSpans() + + // Wait a bit for the flush to complete, then shutdown + singleThreadExecutor.submit { + // This empty task ensures the flush completes before shutdown + } + + singleThreadExecutor.shutdown() + } +} diff --git a/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/DefaultOTelSpanFactory.kt b/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/DefaultOTelSpanFactory.kt new file mode 100644 index 0000000000..1875b6ea1c --- /dev/null +++ b/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/DefaultOTelSpanFactory.kt @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +package com.microsoft.identity.common.java.opentelemetry + +import com.microsoft.identity.common.java.logging.Logger +import io.opentelemetry.api.trace.Span +import io.opentelemetry.api.trace.SpanContext +import io.opentelemetry.api.trace.Tracer +import io.opentelemetry.context.Context + +/** + * Default factory that implements the original OTelUtility span creation logic. + */ +class DefaultOTelSpanFactory : IOTelSpanFactory { + + companion object { + private val TAG = DefaultOTelSpanFactory::class.java.simpleName + } + + override fun createSpan(name: String): Span { + val tracer: Tracer = OpenTelemetryHolder.getTracer(TAG) + return tracer.spanBuilder(name).startSpan() + } + + override fun createSpan(name: String, callingPackageName: String): Span { + val tracer: Tracer = OpenTelemetryHolder.getTracer(TAG) + return tracer.spanBuilder(name) + .setAttribute(AttributeName.calling_package_name.name, callingPackageName) + .startSpan() + } + + override fun createSpanFromParent(name: String, parentSpanContext: SpanContext?): Span { + val methodTag = "$TAG:createSpanFromParent" + + if (parentSpanContext == null) { + Logger.verbose(methodTag, "parentSpanContext is NULL. Creating span without parent.") + return createSpan(name) + } + + if (!parentSpanContext.isValid) { + Logger.warn(methodTag, "parentSpanContext is INVALID. Creating span without parent.") + return createSpan(name) + } + + val tracer: Tracer = OpenTelemetryHolder.getTracer(TAG) + + return tracer.spanBuilder(name) + .setParent(Context.current().with(Span.wrap(parentSpanContext))) + .startSpan() + } + + override fun createSpanFromParent( + name: String, + parentSpanContext: SpanContext?, + callingPackageName: String + ): Span { + val methodTag = "$TAG:createSpanFromParent" + + if (parentSpanContext == null) { + Logger.verbose(methodTag, "parentSpanContext is NULL. Creating span without parent.") + return createSpan(name, callingPackageName) + } + + if (!parentSpanContext.isValid) { + Logger.warn(methodTag, "parentSpanContext is INVALID. Creating span without parent.") + return createSpan(name, callingPackageName) + } + + val tracer: Tracer = OpenTelemetryHolder.getTracer(TAG) + + return tracer.spanBuilder(name) + .setParent(Context.current().with(Span.wrap(parentSpanContext))) + .setAttribute(AttributeName.calling_package_name.name, callingPackageName) + .startSpan() + } +} diff --git a/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/IBenchmarkSpanPrinter.kt b/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/IBenchmarkSpanPrinter.kt new file mode 100644 index 0000000000..72f18ab584 --- /dev/null +++ b/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/IBenchmarkSpanPrinter.kt @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +package com.microsoft.identity.common.java.opentelemetry + +// Interface for printing benchmark span data asynchronously. +// Implementations should define how to handle and output span information, potentially for logging or reporting purposes. +interface IBenchmarkSpanPrinter { + + /** + * Print the provided benchmark span asynchronously. + * Implementations should ensure non-blocking behavior and may use background threads or coroutines. + * + * @param span The benchmark span to print or log. + */ + fun printAsync(span: IBenchmarkSpan) +} \ No newline at end of file diff --git a/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/IOTelSpanFactory.kt b/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/IOTelSpanFactory.kt new file mode 100644 index 0000000000..809b2e973e --- /dev/null +++ b/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/IOTelSpanFactory.kt @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +package com.microsoft.identity.common.java.opentelemetry + +import io.opentelemetry.api.trace.Span +import io.opentelemetry.api.trace.SpanContext + +/** + * Interface for span factories that can be used by OTelUtility. + */ +interface IOTelSpanFactory { + + /** + * Creates a span (with shared basic attributes). + */ + fun createSpan(name: String): Span + + /** + * Creates a span with caller package name (with shared basic attributes). + */ + fun createSpan(name: String, callingPackageName: String): Span + + /** + * Creates a span from a parent Span Context (with shared basic attributes). + */ + fun createSpanFromParent(name: String, parentSpanContext: SpanContext?): Span + + /** + * Creates a span from a parent Span Context with caller package name. + */ + fun createSpanFromParent( + name: String, + parentSpanContext: SpanContext?, + callingPackageName: String + ): Span +} diff --git a/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/OTelBenchmarkSpanFactory.kt b/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/OTelBenchmarkSpanFactory.kt new file mode 100644 index 0000000000..d73f926985 --- /dev/null +++ b/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/OTelBenchmarkSpanFactory.kt @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +package com.microsoft.identity.common.java.opentelemetry + +import io.opentelemetry.api.trace.Span +import io.opentelemetry.api.trace.SpanContext + +/** + * Factory class that wraps OTelUtility and returns BenchmarkSpan instances + * when the span name matches a predefined list of benchmarkable span names. + */ +class OTelBenchmarkSpanFactory( + val benchmarkSpanNames: Set, + private val spanPrinter: IBenchmarkSpanPrinter +) : IOTelSpanFactory { + companion object { + private val TAG = OTelBenchmarkSpanFactory::class.java.simpleName + } + + private val defaultFactory = DefaultOTelSpanFactory() + + /** + * Checks if a span name is in the benchmark list. + * + * @param spanName The span name to check + * @return true if the span name should be benchmarked + */ + fun isBenchmarkSpan(spanName: String): Boolean { + return benchmarkSpanNames.contains(spanName) + } + + /** + * Creates a span (with shared basic attributes). + * Returns BenchmarkSpan if the name matches the benchmark list. + */ + override fun createSpan(name: String): Span { + val originalSpan = defaultFactory.createSpan(name) + + return if (isBenchmarkSpan(name)) { + val benchmarkSpan = BenchmarkSpan(originalSpan, spanPrinter, name) + benchmarkSpan.start() + benchmarkSpan + } else { + originalSpan + } + } + + /** + * Creates a span with caller package name (with shared basic attributes). + * Returns BenchmarkSpan if the name matches the benchmark list. + */ + override fun createSpan(name: String, callingPackageName: String): Span { + val originalSpan = defaultFactory.createSpan(name, callingPackageName) + + return if (isBenchmarkSpan(name)) { + val benchmarkSpan = BenchmarkSpan(originalSpan, spanPrinter, name) + benchmarkSpan.start() + benchmarkSpan + } else { + originalSpan + } + } + + /** + * Creates a span from a parent Span Context (with shared basic attributes). + * Returns BenchmarkSpan if the name matches the benchmark list. + */ + override fun createSpanFromParent(name: String, parentSpanContext: SpanContext?): Span { + val originalSpan = defaultFactory.createSpanFromParent(name, parentSpanContext) + + return if (isBenchmarkSpan(name)) { + val benchmarkSpan = BenchmarkSpan(originalSpan, spanPrinter, name) + benchmarkSpan.start() + benchmarkSpan + } else { + originalSpan + } + } + + /** + * Creates a span from a parent Span Context with caller package name. + * Returns BenchmarkSpan if the name matches the benchmark list. + */ + override fun createSpanFromParent( + name: String, + parentSpanContext: SpanContext?, + callingPackageName: String + ): Span { + val originalSpan = defaultFactory.createSpanFromParent(name, parentSpanContext, callingPackageName) + + return if (isBenchmarkSpan(name)) { + val benchmarkSpan = BenchmarkSpan(originalSpan, spanPrinter, name) + benchmarkSpan.start() + benchmarkSpan + } else { + originalSpan + } + } +} diff --git a/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/OTelUtility.java b/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/OTelUtility.java deleted file mode 100644 index 5922662c33..0000000000 --- a/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/OTelUtility.java +++ /dev/null @@ -1,143 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// All rights reserved. -// -// This code is licensed under the MIT License. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files(the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions : -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -// THE SOFTWARE. -package com.microsoft.identity.common.java.opentelemetry; - -import com.microsoft.identity.common.java.logging.Logger; - -import javax.annotation.Nullable; - -import io.opentelemetry.api.metrics.LongCounter; -import io.opentelemetry.api.metrics.Meter; -import io.opentelemetry.api.trace.Span; -import io.opentelemetry.api.trace.SpanContext; -import io.opentelemetry.api.trace.Tracer; -import io.opentelemetry.context.Context; -import lombok.NonNull; - -public class OTelUtility { - private static final String TAG = OTelUtility.class.getSimpleName(); - - /** - * Creates a span (with shared basic attributes). - **/ - @NonNull - public static Span createSpan(@NonNull final String name) { - final Tracer tracer = OpenTelemetryHolder.getTracer(TAG); - return tracer.spanBuilder(name).startSpan(); - } - - /** - * Creates a span from a parent Span Context (with shared basic attributes) and caller pkg name - * pre-populated on the span upon creation. - **/ - @NonNull - public static Span createSpan(@NonNull final String name, @NonNull final String callingPackageName) { - final Tracer tracer = OpenTelemetryHolder.getTracer(TAG); - return tracer.spanBuilder(name) - .setAttribute(AttributeName.calling_package_name.name(), callingPackageName) - .startSpan(); - } - - /** - * Creates a span from a parent Span Context (with shared basic attributes). - **/ - @NonNull - public static Span createSpanFromParent(@NonNull final String name, - @Nullable final SpanContext parentSpanContext) { - final String methodTag = TAG + ":createSpanFromParent"; - - if (parentSpanContext == null) { - Logger.verbose(methodTag, "parentSpanContext is NULL. Creating span without parent."); - return createSpan(name); - } - - if (!parentSpanContext.isValid()) { - Logger.warn(methodTag, "parentSpanContext is INVALID. Creating span without parent."); - return createSpan(name); - } - - final Tracer tracer = OpenTelemetryHolder.getTracer(TAG); - - return tracer.spanBuilder(name) - .setParent(Context.current().with(Span.wrap(parentSpanContext))) - .startSpan(); - } - - /** - * Creates a span from a parent Span Context (with shared basic attributes) and caller pkg name - * pre-populated on the span upon creation. - **/ - @NonNull - public static Span createSpanFromParent(@NonNull final String name, - @Nullable final SpanContext parentSpanContext, - @NonNull final String callingPackageName) { - final String methodTag = TAG + ":createSpanFromParent"; - - if (parentSpanContext == null) { - Logger.verbose(methodTag, "parentSpanContext is NULL. Creating span without parent."); - return createSpan(name, callingPackageName); - } - - if (!parentSpanContext.isValid()) { - Logger.warn(methodTag, "parentSpanContext is INVALID. Creating span without parent."); - return createSpan(name, callingPackageName); - } - - final Tracer tracer = OpenTelemetryHolder.getTracer(TAG); - - return tracer.spanBuilder(name) - .setParent(Context.current().with(Span.wrap(parentSpanContext))) - .setAttribute(AttributeName.calling_package_name.name(), callingPackageName) - .startSpan(); - } - - /** - * Creates a span (with shared basic attributes). - **/ - @NonNull - public static LongCounter createLongCounter(@NonNull final String name, @NonNull final String description) { - final Meter meter = OpenTelemetryHolder.getMeter(TAG); - - return meter - .counterBuilder(name) - .setDescription(description) - .setUnit("count") - .build(); - } - - /** - * Helper method to calculate and record the elapsed time since the provided start time. - * - * @param attributeName The name of the attribute to record in the telemetry. - * @param startTimeMillis The start time in milliseconds. - * The time unit recorded is milliseconds. - * If {@code startTimeMillis} is negative or in the future (greater than the current time), - * the method will record a negative or unexpected elapsed time value. - * No validation is performed on the input value. - */ - public static void recordElapsedTime(@NonNull final String attributeName, final long startTimeMillis) { - final long endTimeMillis = System.currentTimeMillis(); - final long elapsedTimeMillis = endTimeMillis - startTimeMillis; - SpanExtension.current().setAttribute(attributeName, elapsedTimeMillis); - } -} diff --git a/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/OTelUtility.kt b/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/OTelUtility.kt new file mode 100644 index 0000000000..c1e9f9ada0 --- /dev/null +++ b/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/OTelUtility.kt @@ -0,0 +1,118 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +package com.microsoft.identity.common.java.opentelemetry + +import io.opentelemetry.api.metrics.LongCounter +import io.opentelemetry.api.metrics.Meter +import io.opentelemetry.api.trace.Span +import io.opentelemetry.api.trace.SpanContext + +object OTelUtility { + private val TAG = OTelUtility::class.java.simpleName + + /** + * The currently selected span factory. Defaults to DefaultOTelSpanFactory. + */ + @Volatile + private var spanFactory: IOTelSpanFactory = DefaultOTelSpanFactory() + + /** + * Sets the span factory to use for all span creation operations. + * This allows switching between different factory implementations (e.g., benchmarking vs. default). + * + * @param factory The factory to use for span creation + */ + @JvmStatic + fun setSpanFactory(factory: IOTelSpanFactory) { + spanFactory = factory + } + + /** + * Creates a span (with shared basic attributes). + */ + @JvmStatic + fun createSpan(name: String): Span { + return spanFactory.createSpan(name) + } + + /** + * Creates a span from a parent Span Context (with shared basic attributes) and caller pkg name + * pre-populated on the span upon creation. + */ + @JvmStatic + fun createSpan(name: String, callingPackageName: String): Span { + return spanFactory.createSpan(name, callingPackageName) + } + + /** + * Creates a span from a parent Span Context (with shared basic attributes). + */ + @JvmStatic + fun createSpanFromParent(name: String, parentSpanContext: SpanContext?): Span { + return spanFactory.createSpanFromParent(name, parentSpanContext) + } + + /** + * Creates a span from a parent Span Context (with shared basic attributes) and caller pkg name + * pre-populated on the span upon creation. + */ + @JvmStatic + fun createSpanFromParent( + name: String, + parentSpanContext: SpanContext?, + callingPackageName: String + ): Span { + return spanFactory.createSpanFromParent(name, parentSpanContext, callingPackageName) + } + + /** + * Creates a span (with shared basic attributes). + */ + @JvmStatic + fun createLongCounter(name: String, description: String): LongCounter { + val meter: Meter = OpenTelemetryHolder.getMeter(TAG) + + return meter + .counterBuilder(name) + .setDescription(description) + .setUnit("count") + .build() + } + + /** + * Helper method to calculate and record the elapsed time since the provided start time. + * + * @param attributeName The name of the attribute to record in the telemetry. + * @param startTimeMillis The start time in milliseconds. + * The time unit recorded is milliseconds. + * If [startTimeMillis] is negative or in the future (greater than the current time), + * the method will record a negative or unexpected elapsed time value. + * No validation is performed on the input value. + */ + @JvmStatic + fun recordElapsedTime(attributeName: String, startTimeMillis: Long) { + val endTimeMillis = System.currentTimeMillis() + val elapsedTimeMillis = endTimeMillis - startTimeMillis + SpanExtension.current().setAttribute(attributeName, elapsedTimeMillis) + } +}