diff --git a/common4j/src/main/com/microsoft/identity/common/java/AuthenticationConstants.java b/common4j/src/main/com/microsoft/identity/common/java/AuthenticationConstants.java index dec771b7c9..e4efd16f7f 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/AuthenticationConstants.java +++ b/common4j/src/main/com/microsoft/identity/common/java/AuthenticationConstants.java @@ -599,6 +599,16 @@ public static final class Broker { */ public static final String BROKER_SILENT_EXECUTOR_POOL_SIZE = "broker_silent_executor_pool_size"; + /** + * Bundle key for the serialized broker telemetry request. + */ + public static final String BROKER_TELEMETRY_REQUEST = "broker_telemetry_request"; + + /** + * Bundle key for the serialized broker telemetry schema. + */ + public static final String BROKER_TELEMETRY_SCHEMA = "broker_telemetry_schema"; + /** * Broker application version name. */ diff --git a/common4j/src/main/com/microsoft/identity/common/java/broker/telemetry/EventCollector.kt b/common4j/src/main/com/microsoft/identity/common/java/broker/telemetry/EventCollector.kt new file mode 100644 index 0000000000..b3d5b87c12 --- /dev/null +++ b/common4j/src/main/com/microsoft/identity/common/java/broker/telemetry/EventCollector.kt @@ -0,0 +1,91 @@ +// 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.broker.telemetry + +import java.time.Instant +import java.util.concurrent.CopyOnWriteArrayList + +/** + * Thread-safe accumulator for [ExecutionEvent]s during an authentication flow. + * + * Create one instance per authentication request and call [addEvent] at each phase boundary. + * When the flow completes, call [toTelemetrySchema] to obtain the structured result. + * + * @param correlationId The correlation ID of the authentication request being tracked. + */ +class EventCollector(private val correlationId: String) { + private val events = CopyOnWriteArrayList() + private val startTimeMs: Long = System.currentTimeMillis() + + companion object { + /** + * Offset applied to broker-side thread IDs to avoid collision with + * OneAuth client-side thread IDs when the two event streams are + * stitched in Kusto via correlation_id. + */ + const val BROKER_THREAD_ID_OFFSET = 10_000L + } + + /** + * Records a new [ExecutionEvent] with the current elapsed time. + * Thread ID is offset by [BROKER_THREAD_ID_OFFSET] to avoid collision + * with client-side (OneAuth) thread IDs during Kusto stitching. + * + * @param tag The [EventTag] identifying this phase. + * @param diagnosticCode Optional diagnostic code for additional context. + * @param errorCode Optional error code if this event represents a failure. + */ + fun addEvent(tag: EventTag, diagnosticCode: Int? = null, errorCode: Int? = null) { + events.add( + ExecutionEvent( + tag = tag, + timestampMs = System.currentTimeMillis() - startTimeMs, + threadId = Thread.currentThread().id + BROKER_THREAD_ID_OFFSET, + diagnosticCode = diagnosticCode, + errorCode = errorCode + ) + ) + } + + /** + * Builds a [TelemetrySchema] from all events collected so far. + * + * Snapshots the event list first, then captures the end time, ensuring + * that [PerformanceRecord.duration] is always >= the last event's timestamp. + * + * @return A [TelemetrySchema] containing a [PerformanceRecord] with all collected events. + */ + fun toTelemetrySchema(): TelemetrySchema { + // Snapshot events first, then capture end time — guarantees duration >= last event ts. + val eventSnapshot = events.toList() + val duration = System.currentTimeMillis() - startTimeMs + return TelemetrySchema( + correlationId = correlationId, + performanceRecord = PerformanceRecord( + startTime = Instant.ofEpochMilli(startTimeMs).toString(), + duration = duration, + executionFlow = eventSnapshot + ) + ) + } +} diff --git a/common4j/src/main/com/microsoft/identity/common/java/broker/telemetry/EventTag.kt b/common4j/src/main/com/microsoft/identity/common/java/broker/telemetry/EventTag.kt new file mode 100644 index 0000000000..761837819e --- /dev/null +++ b/common4j/src/main/com/microsoft/identity/common/java/broker/telemetry/EventTag.kt @@ -0,0 +1,61 @@ +// 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.broker.telemetry + +/** + * Enum of event tags representing phases of the broker authentication flow. + * Serialized as human-readable enum names (e.g., "BrokerCacheHit") on the IPC wire format. + * Compact JSON keys on [ExecutionEvent] fields (t, ts, tid, d, e) minimize payload size. + */ +enum class EventTag { + // BrokerEntry (5) + BrokerRequestReceived, + BrokerRequestDeserialized, + BrokerAccountLookupStart, + BrokerAccountLookupEnd, + BrokerRequestValidated, + // BrokerDispatch (3) + BrokerControllerSelected, + BrokerCommandQueued, + BrokerCommandExecutionStart, + // BrokerCache (6) + BrokerCacheCheckStart, + BrokerCacheCheckEnd, + BrokerCacheHit, + BrokerCacheMiss, + BrokerCacheWriteStart, + BrokerCacheWriteEnd, + // BrokerNetwork (5) + BrokerPrtLoadStart, + BrokerNetworkCallStart, + BrokerNetworkCallEnd, + BrokerTokenAcquired, + BrokerNetworkCallFailed, + // BrokerResponse (3) + BrokerResponseSerialized, + BrokerResponseSent, + BrokerRequestFailed, + // CommonStrategy (2) + CommonHttpRequestExecute, + CommonHttpResponseReceived +} diff --git a/common4j/src/main/com/microsoft/identity/common/java/broker/telemetry/ExecutionEvent.kt b/common4j/src/main/com/microsoft/identity/common/java/broker/telemetry/ExecutionEvent.kt new file mode 100644 index 0000000000..dda71e4b11 --- /dev/null +++ b/common4j/src/main/com/microsoft/identity/common/java/broker/telemetry/ExecutionEvent.kt @@ -0,0 +1,42 @@ +// 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.broker.telemetry + +import com.google.gson.annotations.SerializedName + +/** + * Represents a single timed event within the broker authentication flow. + * + * @param tag The [EventTag] identifying which phase this event captures. + * @param timestampMs Elapsed time in milliseconds from the start of the flow. + * @param threadId The ID of the thread that recorded this event. + * @param diagnosticCode Optional diagnostic code for additional context. + * @param errorCode Optional error code if the event represents a failure. + */ +data class ExecutionEvent( + @SerializedName("t") val tag: EventTag, + @SerializedName("ts") val timestampMs: Long, + @SerializedName("tid") val threadId: Long = Thread.currentThread().id, + @SerializedName("d") val diagnosticCode: Int? = null, + @SerializedName("e") val errorCode: Int? = null +) diff --git a/common4j/src/main/com/microsoft/identity/common/java/broker/telemetry/ITelemetrySchemaProvider.kt b/common4j/src/main/com/microsoft/identity/common/java/broker/telemetry/ITelemetrySchemaProvider.kt new file mode 100644 index 0000000000..fbfe65fa30 --- /dev/null +++ b/common4j/src/main/com/microsoft/identity/common/java/broker/telemetry/ITelemetrySchemaProvider.kt @@ -0,0 +1,35 @@ +// 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.broker.telemetry + +/** + * Implemented by objects that can carry a [TelemetrySchema]. + * Mainly used to pass the structured broker telemetry between broker and client. + * + * Thread-safety note: [telemetrySchema] must be set before the implementing object + * is shared across threads. The IPC boundary (Binder transaction) provides the + * memory barrier. This follows the same contract as [IBrokerPerformanceMetricsProvider]. + */ +interface ITelemetrySchemaProvider { + var telemetrySchema: TelemetrySchema? +} diff --git a/common4j/src/main/com/microsoft/identity/common/java/broker/telemetry/PerformanceRecord.kt b/common4j/src/main/com/microsoft/identity/common/java/broker/telemetry/PerformanceRecord.kt new file mode 100644 index 0000000000..64a0c75fdf --- /dev/null +++ b/common4j/src/main/com/microsoft/identity/common/java/broker/telemetry/PerformanceRecord.kt @@ -0,0 +1,49 @@ +// 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.broker.telemetry + +import com.google.gson.annotations.SerializedName + +/** + * Aggregation container for the performance data captured during an authentication flow. + * + * @param version Schema version for this perf section. Independent of [TelemetrySchema.CURRENT_VERSION]. + * @param startTime ISO 8601 UTC timestamp when the flow started. + * @param duration Total elapsed time in milliseconds for the authentication flow. + * @param executionFlow Ordered list of [ExecutionEvent]s captured during the flow. + */ +data class PerformanceRecord( + @SerializedName("version") val version: String = CURRENT_PERF_VERSION, + @SerializedName("start_time") val startTime: String, + @SerializedName("duration") val duration: Long, + @SerializedName("execution_flow") val executionFlow: List +) { + companion object { + /** + * Version of the perf section schema. Informational only — not used in + * version negotiation (which uses root-level [TelemetrySchema.CURRENT_VERSION]). + * Evolves independently from the top-level schema version. + */ + const val CURRENT_PERF_VERSION = "1.0.0" + } +} diff --git a/common4j/src/main/com/microsoft/identity/common/java/broker/telemetry/TelemetryHelper.kt b/common4j/src/main/com/microsoft/identity/common/java/broker/telemetry/TelemetryHelper.kt new file mode 100644 index 0000000000..15e5a1e059 --- /dev/null +++ b/common4j/src/main/com/microsoft/identity/common/java/broker/telemetry/TelemetryHelper.kt @@ -0,0 +1,47 @@ +// 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.broker.telemetry + +/** + * Null-safe utility for recording events into an [EventCollector]. + * Callers may hold a nullable collector reference and use this helper to avoid null checks. + */ +object TelemetryHelper { + /** + * Adds an event to the given [collector] if it is non-null; otherwise does nothing. + * + * @param collector The [EventCollector] to record the event into, or null. + * @param tag The [EventTag] identifying the phase. + * @param diagnosticCode Optional diagnostic code for additional context. + * @param errorCode Optional error code if this event represents a failure. + */ + @JvmStatic + fun addEventSafely( + collector: EventCollector?, + tag: EventTag, + diagnosticCode: Int? = null, + errorCode: Int? = null + ) { + collector?.addEvent(tag, diagnosticCode, errorCode) + } +} diff --git a/common4j/src/main/com/microsoft/identity/common/java/broker/telemetry/TelemetrySchema.kt b/common4j/src/main/com/microsoft/identity/common/java/broker/telemetry/TelemetrySchema.kt new file mode 100644 index 0000000000..b43b90e7b5 --- /dev/null +++ b/common4j/src/main/com/microsoft/identity/common/java/broker/telemetry/TelemetrySchema.kt @@ -0,0 +1,59 @@ +// 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.broker.telemetry + +import com.google.gson.annotations.SerializedName + +/** + * Top-level telemetry schema emitted by the broker for a single authentication request. + * Captures metadata, auth outcome, and a structured performance record. + * + * @param schemaVersion Version of this schema format. Defaults to [CURRENT_VERSION]. + * @param correlationId Correlation ID linking this schema to the authentication request. + * @param name Optional name of the authentication operation. + * @param version Optional version string of the caller SDK. + * @param authOutcome Optional outcome of the authentication (e.g., "success", "failure"). + * @param errorCode Optional error code if the authentication failed. + * @param responseStarvationDuration Optional duration in milliseconds the client waited + * without a response. + * @param powerPolicy Optional flag indicating whether power policy restrictions were active. + * @param deviceIdle Optional flag indicating whether the device was in idle mode. + * @param performanceRecord Optional [PerformanceRecord] containing the detailed event timeline. + */ +data class TelemetrySchema( + @SerializedName("schema_version") val schemaVersion: String = CURRENT_VERSION, + @SerializedName("correlation_id") val correlationId: String, + @SerializedName("name") val name: String? = null, + @SerializedName("version") val version: String? = null, + @SerializedName("auth_outcome") val authOutcome: String? = null, + @SerializedName("error_code") val errorCode: String? = null, + @SerializedName("response_starvation_duration") val responseStarvationDuration: Int? = null, + @SerializedName("power_policy") val powerPolicy: Boolean? = null, + @SerializedName("device_idle") val deviceIdle: Boolean? = null, + @SerializedName("perf") val performanceRecord: PerformanceRecord? = null +) { + companion object { + const val CURRENT_VERSION = "1.0.0" + const val MIN_SUPPORTED_MAJOR = 1 + } +} diff --git a/common4j/src/main/com/microsoft/identity/common/java/commands/parameters/CommandParameters.java b/common4j/src/main/com/microsoft/identity/common/java/commands/parameters/CommandParameters.java index 31680df323..c2919cd38a 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/commands/parameters/CommandParameters.java +++ b/common4j/src/main/com/microsoft/identity/common/java/commands/parameters/CommandParameters.java @@ -24,6 +24,7 @@ import com.google.gson.annotations.Expose; import com.microsoft.identity.common.java.WarningType; +import com.microsoft.identity.common.java.broker.telemetry.EventCollector; import com.microsoft.identity.common.java.interfaces.IPlatformComponents; import com.microsoft.identity.common.java.logging.Logger; import com.microsoft.identity.common.java.opentelemetry.SerializableSpanContext; @@ -61,6 +62,10 @@ public class CommandParameters { @EqualsAndHashCode.Exclude private transient boolean isSharedDevice; + @Setter + @EqualsAndHashCode.Exclude + private transient EventCollector eventCollector; + @Expose() private String applicationName; diff --git a/common4j/src/main/com/microsoft/identity/common/java/exception/BaseException.java b/common4j/src/main/com/microsoft/identity/common/java/exception/BaseException.java index be58d7e019..9b4a97d2f3 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/exception/BaseException.java +++ b/common4j/src/main/com/microsoft/identity/common/java/exception/BaseException.java @@ -29,6 +29,8 @@ import com.microsoft.identity.common.java.util.StringUtil; import com.microsoft.identity.common.java.broker.BrokerPerformanceMetrics; import com.microsoft.identity.common.java.broker.IBrokerPerformanceMetricsProvider; +import com.microsoft.identity.common.java.broker.telemetry.ITelemetrySchemaProvider; +import com.microsoft.identity.common.java.broker.telemetry.TelemetrySchema; import java.util.ArrayList; import java.util.Arrays; @@ -41,7 +43,7 @@ import lombok.NonNull; import lombok.experimental.Accessors; -public class BaseException extends Exception implements IErrorInformation, ITelemetryAccessor, IBrokerPerformanceMetricsProvider, IBrokerInfoProvider { +public class BaseException extends Exception implements IErrorInformation, ITelemetryAccessor, IBrokerPerformanceMetricsProvider, IBrokerInfoProvider, ITelemetrySchemaProvider { // This is needed for backward compatibility with older versions of MSAL (pre 3.0.0) // When MSAL converts the result bundle it looks for this value to know about exception type @@ -90,6 +92,9 @@ public class BaseException extends Exception implements IErrorInformation, ITele private BrokerPerformanceMetrics mBrokerPerformanceMetrics; + @Nullable + private TelemetrySchema mTelemetrySchema; + /** * {@link Exception#addSuppressed(Throwable)} requires API19 in Android, so we're creating our own. */ @@ -236,6 +241,17 @@ public BrokerPerformanceMetrics getBrokerPerformanceMetrics() { return this.mBrokerPerformanceMetrics; } + @Override + public void setTelemetrySchema(@Nullable final TelemetrySchema telemetrySchema) { + this.mTelemetrySchema = telemetrySchema; + } + + @Override + @Nullable + public TelemetrySchema getTelemetrySchema() { + return this.mTelemetrySchema; + } + public void setUsername(@Nullable final String username) { this.mUsername = username; } diff --git a/common4j/src/main/com/microsoft/identity/common/java/result/AcquireTokenResult.java b/common4j/src/main/com/microsoft/identity/common/java/result/AcquireTokenResult.java index 28fd66a2a3..de5d200fe8 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/result/AcquireTokenResult.java +++ b/common4j/src/main/com/microsoft/identity/common/java/result/AcquireTokenResult.java @@ -28,10 +28,12 @@ import com.microsoft.identity.common.java.providers.oauth2.AuthorizationResult; import com.microsoft.identity.common.java.broker.BrokerPerformanceMetrics; import com.microsoft.identity.common.java.broker.IBrokerPerformanceMetricsProvider; +import com.microsoft.identity.common.java.broker.telemetry.ITelemetrySchemaProvider; +import com.microsoft.identity.common.java.broker.telemetry.TelemetrySchema; import javax.annotation.Nullable; -public class AcquireTokenResult implements IBrokerPerformanceMetricsProvider, IBrokerInfoProvider { +public class AcquireTokenResult implements IBrokerPerformanceMetricsProvider, IBrokerInfoProvider, ITelemetrySchemaProvider { private ILocalAuthenticationResult mLocalAuthenticationResult; private TokenResult mTokenResult; @@ -49,6 +51,9 @@ public class AcquireTokenResult implements IBrokerPerformanceMetricsProvider, IB private BrokerPerformanceMetrics mBrokerPerformanceMetrics; + @Nullable + private TelemetrySchema mTelemetrySchema; + public void setLocalAuthenticationResult(ILocalAuthenticationResult result) { this.mLocalAuthenticationResult = result; this.mSucceeded = true; @@ -75,6 +80,17 @@ public BrokerPerformanceMetrics getBrokerPerformanceMetrics() { return this.mBrokerPerformanceMetrics; } + @Override + public void setTelemetrySchema(@Nullable final TelemetrySchema telemetrySchema) { + this.mTelemetrySchema = telemetrySchema; + } + + @Override + @Nullable + public TelemetrySchema getTelemetrySchema() { + return this.mTelemetrySchema; + } + // Suppressing rawtype warnings due to the generic type AuthorizationResult @SuppressWarnings(WarningType.rawtype_warning) public AuthorizationResult getAuthorizationResult() { diff --git a/common4j/src/test/com/microsoft/identity/common/java/broker/telemetry/EventCollectorTest.kt b/common4j/src/test/com/microsoft/identity/common/java/broker/telemetry/EventCollectorTest.kt new file mode 100644 index 0000000000..16bade96bc --- /dev/null +++ b/common4j/src/test/com/microsoft/identity/common/java/broker/telemetry/EventCollectorTest.kt @@ -0,0 +1,174 @@ +// 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.broker.telemetry + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for [EventCollector]. + */ +class EventCollectorTest { + + private val testCorrelationId = "test-correlation-id" + + @Test + fun addEvent_whenTagProvided_eventIsPresentInSchema() { + val collector = EventCollector(testCorrelationId) + + collector.addEvent(EventTag.BrokerRequestReceived) + + val schema = collector.toTelemetrySchema() + val events = schema.performanceRecord?.executionFlow + assertNotNull(events) + assertEquals(1, events!!.size) + assertEquals(EventTag.BrokerRequestReceived, events[0].tag) + } + + @Test + fun addEvent_withDiagnosticAndErrorCode_fieldsArePresentInEvent() { + val collector = EventCollector(testCorrelationId) + + collector.addEvent(EventTag.BrokerRequestFailed, diagnosticCode = 42, errorCode = 100) + + val events = collector.toTelemetrySchema().performanceRecord?.executionFlow + assertNotNull(events) + val event = events!![0] + assertEquals(42, event.diagnosticCode) + assertEquals(100, event.errorCode) + } + + @Test + fun addEvent_whenNoOptionalCodes_diagnosticAndErrorCodeAreNull() { + val collector = EventCollector(testCorrelationId) + + collector.addEvent(EventTag.BrokerCacheHit) + + val event = collector.toTelemetrySchema().performanceRecord?.executionFlow?.get(0) + assertNotNull(event) + assertNull(event!!.diagnosticCode) + assertNull(event.errorCode) + } + + @Test + fun addEvent_multipleEvents_allEventsRetainedInOrder() { + val collector = EventCollector(testCorrelationId) + val tags = listOf( + EventTag.BrokerRequestReceived, + EventTag.BrokerCacheCheckStart, + EventTag.BrokerCacheHit, + EventTag.BrokerResponseSent + ) + + tags.forEach { collector.addEvent(it) } + + val events = collector.toTelemetrySchema().performanceRecord?.executionFlow + assertNotNull(events) + assertEquals(tags.size, events!!.size) + tags.forEachIndexed { index, tag -> assertEquals(tag, events[index].tag) } + } + + @Test + fun toTelemetrySchema_correlationIdMatchesConstructorArg() { + val collector = EventCollector(testCorrelationId) + + val schema = collector.toTelemetrySchema() + + assertEquals(testCorrelationId, schema.correlationId) + } + + @Test + fun toTelemetrySchema_performanceRecordHasNonNegativeDuration() { + val collector = EventCollector(testCorrelationId) + collector.addEvent(EventTag.BrokerRequestReceived) + + val schema = collector.toTelemetrySchema() + + assertNotNull(schema.performanceRecord) + assertTrue(schema.performanceRecord!!.duration >= 0) + } + + @Test + fun toTelemetrySchema_startTimeIsIso8601Format() { + val collector = EventCollector(testCorrelationId) + + val schema = collector.toTelemetrySchema() + + val startTime = schema.performanceRecord?.startTime + assertNotNull(startTime) + // ISO 8601 instant strings end with 'Z' + assertTrue(startTime!!.endsWith("Z")) + } + + @Test + fun addEvent_isThreadSafe_allEventsRecordedUnderConcurrentAccess() { + val collector = EventCollector(testCorrelationId) + val threadCount = 10 + val eventsPerThread = 20 + val threads = (1..threadCount).map { + Thread { + repeat(eventsPerThread) { + collector.addEvent(EventTag.BrokerNetworkCallStart) + } + } + } + + threads.forEach { it.start() } + threads.forEach { it.join() } + + val schema = collector.toTelemetrySchema() + val events = schema.performanceRecord?.executionFlow + assertNotNull(events) + assertEquals(threadCount * eventsPerThread, events!!.size) + + // Verify duration invariant: duration >= max event timestamp (race condition regression test) + val maxEventTs = events.maxOf { it.timestampMs } + val duration = schema.performanceRecord!!.duration + assertTrue( + "Duration ($duration) must be >= max event timestamp ($maxEventTs)", + duration >= maxEventTs + ) + } + + @Test + fun addEventSafely_withNullCollector_doesNotThrow() { + // Should be a no-op and not throw NullPointerException + TelemetryHelper.addEventSafely(null, EventTag.BrokerRequestReceived) + } + + @Test + fun addEventSafely_withNonNullCollector_addsEvent() { + val collector = EventCollector(testCorrelationId) + + TelemetryHelper.addEventSafely(collector, EventTag.BrokerTokenAcquired, diagnosticCode = 1) + + val events = collector.toTelemetrySchema().performanceRecord?.executionFlow + assertNotNull(events) + assertEquals(1, events!!.size) + assertEquals(EventTag.BrokerTokenAcquired, events[0].tag) + assertEquals(1, events[0].diagnosticCode) + } +} diff --git a/common4j/src/test/com/microsoft/identity/common/java/broker/telemetry/TelemetrySchemaSerializationTest.kt b/common4j/src/test/com/microsoft/identity/common/java/broker/telemetry/TelemetrySchemaSerializationTest.kt new file mode 100644 index 0000000000..879617b7d0 --- /dev/null +++ b/common4j/src/test/com/microsoft/identity/common/java/broker/telemetry/TelemetrySchemaSerializationTest.kt @@ -0,0 +1,235 @@ +// 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.broker.telemetry + +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * Unit tests for Gson round-trip serialization/deserialization of [TelemetrySchema] and + * its nested types. + */ +class TelemetrySchemaSerializationTest { + + private val gson: Gson = GsonBuilder().create() + + // ------------------------------------------------------------------ + // TelemetrySchema round-trip + // ------------------------------------------------------------------ + + @Test + fun telemetrySchema_roundTrip_preservesCorrelationId() { + val original = buildFullSchema() + + val json = gson.toJson(original) + val restored = gson.fromJson(json, TelemetrySchema::class.java) + + assertEquals(original.correlationId, restored.correlationId) + } + + @Test + fun telemetrySchema_roundTrip_preservesSchemaVersion() { + val original = buildFullSchema() + + val json = gson.toJson(original) + val restored = gson.fromJson(json, TelemetrySchema::class.java) + + assertEquals(TelemetrySchema.CURRENT_VERSION, restored.schemaVersion) + } + + @Test + fun telemetrySchema_roundTrip_preservesOptionalFields() { + val original = buildFullSchema() + + val json = gson.toJson(original) + val restored = gson.fromJson(json, TelemetrySchema::class.java) + + assertEquals(original.authOutcome, restored.authOutcome) + assertEquals(original.errorCode, restored.errorCode) + assertEquals(original.responseStarvationDuration, restored.responseStarvationDuration) + assertEquals(original.powerPolicy, restored.powerPolicy) + assertEquals(original.deviceIdle, restored.deviceIdle) + } + + @Test + fun telemetrySchema_roundTrip_withNullOptionalFields_remainsNull() { + val original = TelemetrySchema(correlationId = "corr-null-opts") + + val json = gson.toJson(original) + val restored = gson.fromJson(json, TelemetrySchema::class.java) + + assertNull(restored.authOutcome) + assertNull(restored.errorCode) + assertNull(restored.performanceRecord) + } + + // ------------------------------------------------------------------ + // PerformanceRecord round-trip + // ------------------------------------------------------------------ + + @Test + fun performanceRecord_roundTrip_preservesDuration() { + val original = buildFullSchema() + + val json = gson.toJson(original) + val restored = gson.fromJson(json, TelemetrySchema::class.java) + + assertEquals(original.performanceRecord!!.duration, restored.performanceRecord!!.duration) + } + + @Test + fun performanceRecord_roundTrip_preservesStartTime() { + val original = buildFullSchema() + + val json = gson.toJson(original) + val restored = gson.fromJson(json, TelemetrySchema::class.java) + + assertEquals(original.performanceRecord!!.startTime, restored.performanceRecord!!.startTime) + } + + // ------------------------------------------------------------------ + // ExecutionEvent round-trip + // ------------------------------------------------------------------ + + @Test + fun executionEvent_roundTrip_preservesTag() { + val original = buildFullSchema() + + val json = gson.toJson(original) + val restored = gson.fromJson(json, TelemetrySchema::class.java) + + val originalEvents = original.performanceRecord!!.executionFlow + val restoredEvents = restored.performanceRecord!!.executionFlow + assertEquals(originalEvents.size, restoredEvents.size) + originalEvents.forEachIndexed { i, event -> + assertEquals(event.tag, restoredEvents[i].tag) + } + } + + @Test + fun executionEvent_roundTrip_preservesTimestamp() { + val event = ExecutionEvent(tag = EventTag.BrokerCacheHit, timestampMs = 12345L) + + val json = gson.toJson(event) + val restored = gson.fromJson(json, ExecutionEvent::class.java) + + assertEquals(12345L, restored.timestampMs) + } + + @Test + fun executionEvent_roundTrip_preservesOptionalDiagnosticAndErrorCode() { + val event = ExecutionEvent( + tag = EventTag.BrokerNetworkCallFailed, + timestampMs = 5000L, + diagnosticCode = 7, + errorCode = 503 + ) + + val json = gson.toJson(event) + val restored = gson.fromJson(json, ExecutionEvent::class.java) + + assertEquals(7, restored.diagnosticCode) + assertEquals(503, restored.errorCode) + } + + @Test + fun executionEvent_roundTrip_whenOptionalCodesAbsent_remainsNull() { + val event = ExecutionEvent(tag = EventTag.BrokerRequestReceived, timestampMs = 0L) + + val json = gson.toJson(event) + val restored = gson.fromJson(json, ExecutionEvent::class.java) + + assertNull(restored.diagnosticCode) + assertNull(restored.errorCode) + } + + // ------------------------------------------------------------------ + // EventTag serialized name verification + // ------------------------------------------------------------------ + + @Test + fun eventTag_serializedAsEnumName() { + val event = ExecutionEvent(tag = EventTag.BrokerRequestReceived, timestampMs = 0L) + + val json = gson.toJson(event) + + // The tag should be serialized as the human-readable enum name + assertNotNull(json) + val tagJson = gson.toJsonTree(event).asJsonObject.get("t") + assertNotNull(tagJson) + // EventTag is a plain enum — Gson serializes it as its .name() + assertEquals("BrokerRequestReceived", tagJson.asString) + } + + // ------------------------------------------------------------------ + // EventCollector → TelemetrySchema integration + // ------------------------------------------------------------------ + + @Test + fun eventCollector_toTelemetrySchema_roundTripsCorrectly() { + val collector = EventCollector("round-trip-corr-id") + collector.addEvent(EventTag.BrokerRequestReceived) + collector.addEvent(EventTag.BrokerCacheCheckStart) + collector.addEvent(EventTag.BrokerResponseSent) + + val original = collector.toTelemetrySchema() + val json = gson.toJson(original) + val restored = gson.fromJson(json, TelemetrySchema::class.java) + + assertEquals(original.correlationId, restored.correlationId) + assertEquals(3, restored.performanceRecord?.executionFlow?.size) + assertEquals(EventTag.BrokerResponseSent, restored.performanceRecord?.executionFlow?.last()?.tag) + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + private fun buildFullSchema(): TelemetrySchema { + val events = listOf( + ExecutionEvent(tag = EventTag.BrokerRequestReceived, timestampMs = 0L), + ExecutionEvent(tag = EventTag.BrokerCacheHit, timestampMs = 10L), + ExecutionEvent(tag = EventTag.BrokerResponseSent, timestampMs = 50L) + ) + val perfRecord = PerformanceRecord( + startTime = "2024-01-01T00:00:00Z", + duration = 50L, + executionFlow = events + ) + return TelemetrySchema( + correlationId = "test-corr-id", + name = "AcquireTokenSilent", + version = "test-sdk-1.0", + authOutcome = "success", + errorCode = null, + responseStarvationDuration = 5, + powerPolicy = false, + deviceIdle = true, + performanceRecord = perfRecord + ) + } +}