Skip to content

Commit 27abc31

Browse files
authored
Add onboarding telemetry recorder, constants, and session correlation store for mobile onboarding flow, Fixes AB#3462876 (#3088)
## Summary Adds shared Android infrastructure for tracking mobile onboarding telemetry — the E2E flow where users hit Conditional Access (CA) blocking errors (broker install, MDM enrollment, device registration) and must remediate before completing sign-in. Consumed by both OneAuth (non-brokered flows) and broker (brokered flows) to construct a JSON telemetry blob emitted through MATS. Linked Feature: [AB#3462876](https://identitydivision.visualstudio.com/fac9d424-53d2-45c0-91b5-ef6ba7a6bf26/_workitems/edit/3462876) Linked PBI: [AB#3568356](https://identitydivision.visualstudio.com/fac9d424-53d2-45c0-91b5-ef6ba7a6bf26/_workitems/edit/3568356) ## Changes ### `OnboardingTelemetryConstants.kt` (common4j) - JSON field key constants for the onboarding blob — all snake_case matching MATS convention (EntityStore prepends `mo_` to produce final MATS columns, e.g. `blocking_errors` → `mo_blocking_errors`) - Step ID constants (`AuthenticationStarted`, `BrokerInstallPrompted`, `DeviceRegistrationStarted`, etc.) - Blocking error value constants (`BROKER_INSTALLATION_TRIGGERED`, `MDM_FLOW`) — must match C++ hardcoded strings in OneAuth''s `InteractiveRequest.cpp` ### `OnboardingTelemetryRecorder.kt` (common/Android) - Records steps, blocking errors, and domain tracking during interactive auth flows - Constructs the blob from a seed JSON provided by C++ xplat core - `addStep(stepId)` captures the current ISO-8601 timestamp internally — callers no longer pass a timestamp - `finalizeBlob()` returns populated JSON only if blocking errors were recorded AND `sessionCorrelationId` is non-empty (empty string otherwise); logs at verbose/warn/error level for diagnostics - Persists `sessionCorrelationId` to SharedPreferences on block detection for app-kill resilience ### `OnboardingSessionCorrelationStore.kt` (common/Android) - SharedPreferences-backed persistence for session correlation IDs (per-app sandbox; same schema/file name across apps for consistency) - Used by OneAuth''s Djinni `SessionCachePersistence` adapter (JNI bridge to C++ `SessionCorrelationIdCache`) ## Design decisions - **Kotlin**: Implemented in Kotlin, consistent with recent additions to Common (e.g. `BrowserRedirectValidator`, `DeviceRegistrationClientApplication`). Public surface remains JVM-compatible for Java callers. - **Snake_case blob keys**: All internal JSON keys use snake_case (`session_correlation_id`, `blocking_errors`, etc.) so EntityStore can use simple `"mo_" + key` concatenation for MATS columns — no mapping table needed, follows established MATS convention - **Recorder, not builder**: The class records events incrementally during WebView navigation with mid-flow side effects (SharedPreferences persistence), so it''s named `OnboardingTelemetryRecorder` rather than following the builder pattern - **No `remediationNeeded` field**: Redundant — implied by the presence of `blocking_errors` - **`apply()` over `commit()` for persistence**: Telemetry tolerates rare loss; blocking errors leave the app alive for seconds-to-minutes of user remediation, so the async flush window is far longer than typical loss. Avoids main-thread disk I/O. ## Testing - 20 Common Android/Robolectric unit tests pass (recorder + store) - OneAuth C++ unit tests pass (794 tests, 0 failures) including 41 new onboarding-specific tests - E2E tested with OneAuthTestApp: CA-blocked user triggers `BROKER_INSTALLATION_TRIGGERED`, blob emitted with correct fields and `sessionCorrelationId` ## Review feedback addressed - Renamed `OnboardingBlobFieldKeys` → `OnboardingTelemetryConstants` - Renamed `OnboardingSessionCachePersistence` → `OnboardingSessionCorrelationStore` - `addStep()` now captures timestamp internally (single-arg signature) - Added Logger calls in `finalizeBlob()` (verbose/error), seed-parse `init` catch (warn), and `persistSessionCorrelation()` catch (warn) - `finalizeBlob()` now early-returns with a warn log if `sessionCorrelationId` is empty (avoids emitting uncorrelatable telemetry) - Replaced `java.time.Instant` with `SimpleDateFormat` (desugaring disabled, minSdk 24) - Extracted `FIELD_ID` constant for the cache entry key - Clarified `OnboardingSessionCorrelationStore` Javadoc (per-app SharedPreferences sandbox) - Updated `addBlockingError` Javadoc to reference correct constant values - Full MIT license headers - Converted to Kotlin ## Dependencies - **Upstream of**: OneAuth onboarding telemetry PR (Djinni + C++ core + Android platform layer) - **No breaking changes**: all new files, no modifications to existing code
1 parent 62b7c33 commit 27abc31

6 files changed

Lines changed: 714 additions & 0 deletions

File tree

changelog.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ vNext
22
----------
33
- [PATCH] Handle app_link Intent redirection by validating broker install links and rejecting unsupported redirect URIs with appropriate error responses (#3102)
44
- [PATCH] Extend filter-then-clone optimization to load() and getIdTokensForAccountRecord() in MsalOAuth2TokenCache: when ENABLE_FILTER_THEN_CLONE_IN_MEMORY_CACHE flight is enabled, skip clone-all preload and call direct flight-gated overloads that clone only matching credentials; add new getCredentialsFilteredBy overload with kid support (#3100)
5+
- [MINOR] Add onboarding telemetry recorder, field keys, and session persistence for mobile onboarding flow (#3088)
56
- [PATCH] Move Multiple Listening apps check to the authorization layer (#3070)
67
- [PATCH] Edge TB: Fix lookup mode (#3108)
78

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// All rights reserved.
3+
//
4+
// This code is licensed under the MIT License.
5+
//
6+
// Permission is hereby granted, free of charge, to any person obtaining a copy
7+
// of this software and associated documentation files(the "Software"), to deal
8+
// in the Software without restriction, including without limitation the rights
9+
// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
10+
// copies of the Software, and to permit persons to whom the Software is
11+
// furnished to do so, subject to the following conditions :
12+
//
13+
// The above copyright notice and this permission notice shall be included in
14+
// all copies or substantial portions of the Software.
15+
//
16+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22+
// THE SOFTWARE.
23+
package com.microsoft.identity.common.internal.telemetry
24+
25+
import android.content.Context
26+
27+
/**
28+
* SharedPreferences-backed persistence for session correlation IDs.
29+
* Used by OneAuth (via JNI/Djinni SessionCachePersistence adapter).
30+
* Each app (OneAuth host, broker) has its own sandboxed SharedPreferences file;
31+
* the same schema and file name are used across apps for consistency.
32+
* OnboardingTelemetryRecorder also writes to this file on block detection.
33+
*/
34+
class OnboardingSessionCorrelationStore(context: Context) {
35+
36+
private val appContext: Context = context.applicationContext
37+
38+
/**
39+
* Load the persisted session correlation cache JSON string.
40+
* @return JSON string, or empty string if nothing is persisted
41+
*/
42+
fun load(): String {
43+
val prefs = appContext.getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE)
44+
return prefs.getString(PREFS_FILE, "") ?: ""
45+
}
46+
47+
/**
48+
* Save the session correlation cache JSON string to SharedPreferences.
49+
* @param json The JSON string to persist
50+
*/
51+
fun save(json: String) {
52+
val prefs = appContext.getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE)
53+
prefs.edit().putString(PREFS_FILE, json).apply()
54+
}
55+
56+
companion object {
57+
private const val PREFS_FILE = "com.microsoft.oneauth.session_correlation_cache"
58+
}
59+
}
Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// All rights reserved.
3+
//
4+
// This code is licensed under the MIT License.
5+
//
6+
// Permission is hereby granted, free of charge, to any person obtaining a copy
7+
// of this software and associated documentation files(the "Software"), to deal
8+
// in the Software without restriction, including without limitation the rights
9+
// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
10+
// copies of the Software, and to permit persons to whom the Software is
11+
// furnished to do so, subject to the following conditions :
12+
//
13+
// The above copyright notice and this permission notice shall be included in
14+
// all copies or substantial portions of the Software.
15+
//
16+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22+
// THE SOFTWARE.
23+
24+
package com.microsoft.identity.common.internal.telemetry
25+
26+
import android.content.Context
27+
import com.microsoft.identity.common.java.telemetry.OnboardingTelemetryConstants
28+
import com.microsoft.identity.common.logging.Logger
29+
import org.json.JSONArray
30+
import org.json.JSONException
31+
import org.json.JSONObject
32+
import java.text.SimpleDateFormat
33+
import java.util.Date
34+
import java.util.Locale
35+
36+
/**
37+
* Records onboarding telemetry events during interactive auth flows.
38+
* Called by WebView navigation fragments to track steps, blocking errors,
39+
* and domain navigation. Operates on an in-memory model, converts
40+
* to JSON at flow end. On block detection, persists sessionCorrelationId
41+
* to SharedPreferences for app-kill resilience (best-effort, async).
42+
*
43+
* Lives in Android common core so both OneAuth (non-brokered) and broker apps
44+
* (brokered) use the same class.
45+
*
46+
* @param seedJson Seed blob JSON produced by the C++ xplat core (via Djinni
47+
* `OnboardingBlobConstants`) and passed in from `authParameters`.
48+
* Expected shape:
49+
* `{ "schema_version": "1.0.0",
50+
* "session_correlation_id": "<uuid>",
51+
* "onboarding_mode": "brokered" | "non-brokered" }`.
52+
* If null/blank/malformed, the recorder still functions but with empty
53+
* seed fields; a warning is logged and `finalizeBlob()` will refuse to
54+
* emit a blob with an empty `sessionCorrelationId`.
55+
* @param clientId Client (application) ID, used as part of the SharedPreferences
56+
* cache key for session correlation persistence.
57+
* @param target Target scopes (space-joined, sorted) for the same cache key.
58+
* @param context Any Android context; the application context is captured internally
59+
* so the recorder can outlive the originating Activity/Fragment without
60+
* leaking it.
61+
*/
62+
class OnboardingTelemetryRecorder(
63+
seedJson: String,
64+
private val clientId: String,
65+
private val target: String, // sorted, space-joined scopes
66+
context: Context
67+
) {
68+
69+
// Use applicationContext so this recorder, which may outlive the originating
70+
// Activity/Fragment, never holds a reference that would leak that context.
71+
private val appContext: Context = context.applicationContext
72+
73+
// Seed fields (from C++ common core)
74+
private val schemaVersion: String
75+
val sessionCorrelationId: String
76+
private val onboardingMode: String
77+
78+
// Populated fields
79+
private val stepsList: MutableList<StepEntry> = mutableListOf()
80+
private val blockingErrors: MutableList<String> = mutableListOf()
81+
private var lastLoadedDomain: String? = null
82+
private var profile: String? = null
83+
private val uxFlowUsed: MutableList<String> = mutableListOf()
84+
85+
init {
86+
val parsed = parseSeed(seedJson)
87+
schemaVersion = parsed?.first ?: ""
88+
sessionCorrelationId = parsed?.second ?: ""
89+
onboardingMode = parsed?.third ?: ""
90+
}
91+
92+
/**
93+
* Parse the seed JSON into [schemaVersion], [sessionCorrelationId], and [onboardingMode].
94+
* Returns null if the seed is null/blank or fails to parse — callers fall back to
95+
* empty-string defaults rather than receiving a fake-empty Triple.
96+
*/
97+
private fun parseSeed(json: String): Triple<String, String, String>? {
98+
if (json.isBlank()) return null
99+
return try {
100+
val seed = JSONObject(json)
101+
Triple(
102+
seed.optString(FIELD_SCHEMA_VERSION, ""),
103+
seed.optString(FIELD_SESSION_CORRELATION_ID, ""),
104+
seed.optString(FIELD_ONBOARDING_MODE, "")
105+
)
106+
} catch (e: JSONException) {
107+
Logger.warn(
108+
TAG,
109+
"Failed to parse onboarding seed JSON; recorder will operate with empty fields: " + e.message
110+
)
111+
null
112+
}
113+
}
114+
115+
private data class StepEntry(val stepId: String, val timestamp: String)
116+
117+
/**
118+
* Record a step in the onboarding flow. Captures the current time automatically.
119+
*
120+
* @param stepId Step ID constant (from OnboardingTelemetryConstants)
121+
*/
122+
fun addStep(stepId: String) {
123+
val isoTimestamp = SimpleDateFormat(ISO_TIMESTAMP_FORMAT, Locale.US).format(Date())
124+
stepsList.add(StepEntry(stepId, isoTimestamp))
125+
}
126+
127+
/**
128+
* Record a blocking error detected during the flow.
129+
* Also persists the session correlation entry to SharedPreferences
130+
* (best-effort, async) for app-kill resilience.
131+
*
132+
* @param errorCode The onboarding blocking-error identifier to record
133+
* (e.g., [OnboardingTelemetryConstants.BLOCKING_ERROR_BROKER_INSTALL]
134+
* or [OnboardingTelemetryConstants.BLOCKING_ERROR_MDM_FLOW]),
135+
* not a numeric service auth error code.
136+
*/
137+
fun addBlockingError(errorCode: String) {
138+
blockingErrors.add(errorCode)
139+
140+
// Persist session correlation to SharedPreferences immediately on block
141+
persistSessionCorrelation()
142+
}
143+
144+
/**
145+
* Set the last loaded domain during WebView navigation.
146+
*
147+
* @param domain The domain URL (e.g., "login.microsoftonline.com")
148+
*/
149+
fun setLastLoadedDomain(domain: String) {
150+
lastLoadedDomain = domain
151+
}
152+
153+
/**
154+
* Set the Android profile context.
155+
*
156+
* @param profile One of [OnboardingTelemetryConstants.PROFILE_USER] or
157+
* [OnboardingTelemetryConstants.PROFILE_WORK]
158+
*/
159+
fun setProfile(profile: String) {
160+
this.profile = profile
161+
}
162+
163+
/**
164+
* Add a UX flow variant tag to the onboarding blob.
165+
*
166+
* The tag identifies which experiment/feature variant the user was exposed to during
167+
* the onboarding journey (e.g. a phased rollout cohort like `"MobileOnboardingPhase1"`,
168+
* or a remediation experiment like `"MdmEnrollmentRedesign_v2"`). Multiple tags can be
169+
* added when several flights apply to the same flow. The values are emitted as the
170+
* `ux_flow_used` array in the populated blob and surface in MATS as `mo_ux_flow_used`,
171+
* enabling per-experiment slicing of the onboarding funnel.
172+
*
173+
* @param flowTag Caller-defined experiment/variant identifier.
174+
*/
175+
fun addUxFlowUsed(flowTag: String) {
176+
uxFlowUsed.add(flowTag)
177+
}
178+
179+
/**
180+
* Finalize the blob and return the JSON string.
181+
* If no blocking errors were recorded, returns empty string (clears seed blob).
182+
* Otherwise serializes the populated blob to JSON.
183+
*
184+
* @return Populated blob JSON string, or empty string if no blocking errors
185+
*/
186+
fun finalizeBlob(): String {
187+
if (blockingErrors.isEmpty()) {
188+
Logger.verbose(TAG, sessionCorrelationId, "finalizeBlob: no blocking errors recorded, returning empty")
189+
return EMPTY_BLOB
190+
}
191+
if (sessionCorrelationId.isEmpty()) {
192+
Logger.warn(
193+
TAG,
194+
"finalizeBlob: sessionCorrelationId is empty; returning empty blob (skipping MATS emission) " +
195+
"to avoid emitting telemetry that cannot be joined with the broker side or with retries"
196+
)
197+
return EMPTY_BLOB
198+
}
199+
200+
return try {
201+
val blob = JSONObject().apply {
202+
// Seed fields
203+
put(FIELD_SCHEMA_VERSION, schemaVersion)
204+
put(FIELD_SESSION_CORRELATION_ID, sessionCorrelationId)
205+
put(FIELD_ONBOARDING_MODE, onboardingMode)
206+
207+
// StepsList
208+
val steps = JSONArray()
209+
for (entry in stepsList) {
210+
steps.put(JSONObject().apply {
211+
put(FIELD_STEP_ID, entry.stepId)
212+
put(FIELD_TS, entry.timestamp)
213+
})
214+
}
215+
put(FIELD_STEPS_LIST, steps)
216+
217+
// Platform builder fields
218+
val errorsArray = JSONArray()
219+
for (error in blockingErrors) {
220+
errorsArray.put(error)
221+
}
222+
put(OnboardingTelemetryConstants.BLOCKING_ERRORS, errorsArray)
223+
put(
224+
OnboardingTelemetryConstants.LAST_BLOCKING_ERROR,
225+
blockingErrors.last()
226+
)
227+
228+
lastLoadedDomain?.let {
229+
put(OnboardingTelemetryConstants.LAST_LOADED_DOMAIN, it)
230+
}
231+
232+
if (stepsList.isNotEmpty()) {
233+
put(
234+
OnboardingTelemetryConstants.LAST_COMPLETED_STEP,
235+
stepsList.last().stepId
236+
)
237+
}
238+
239+
profile?.let {
240+
put(OnboardingTelemetryConstants.PROFILE, it)
241+
}
242+
243+
if (uxFlowUsed.isNotEmpty()) {
244+
val flows = JSONArray()
245+
for (flow in uxFlowUsed) {
246+
flows.put(flow)
247+
}
248+
put(OnboardingTelemetryConstants.UX_FLOW_USED, flows)
249+
}
250+
}
251+
252+
blob.toString()
253+
} catch (e: JSONException) {
254+
Logger.error(TAG, sessionCorrelationId, "Failed to serialize onboarding blob", e)
255+
EMPTY_BLOB
256+
}
257+
}
258+
259+
/**
260+
* Persist session correlation entry to SharedPreferences.
261+
* Uses async [SharedPreferences.Editor.apply] — the in-memory write is
262+
* effective immediately, and the disk flush happens shortly after. Acceptable
263+
* for this use case: blocking errors leave the app alive for seconds-to-minutes
264+
* of user remediation, so the flush window is far longer than typical loss.
265+
* Telemetry tolerates rare loss; we avoid main-thread disk I/O.
266+
* Called on block detection.
267+
*/
268+
private fun persistSessionCorrelation() {
269+
if (sessionCorrelationId.isEmpty()) {
270+
Logger.verbose(TAG, "persistSessionCorrelation: skipped — no sessionCorrelationId")
271+
return
272+
}
273+
274+
try {
275+
val prefs = appContext.getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE)
276+
val existing = prefs.getString(PREFS_FILE, "")
277+
val cache = if (!existing.isNullOrEmpty()) JSONObject(existing) else JSONObject()
278+
279+
val key = "$clientId|$target" // target = sorted, space-joined scopes
280+
val entry = JSONObject().apply {
281+
put(FIELD_ID, sessionCorrelationId)
282+
put(FIELD_TS, System.currentTimeMillis())
283+
}
284+
cache.put(key, entry)
285+
286+
prefs.edit().putString(PREFS_FILE, cache.toString()).apply()
287+
Logger.verbose(
288+
TAG,
289+
sessionCorrelationId,
290+
"Persisted session correlation entry for key=$key"
291+
)
292+
} catch (e: JSONException) {
293+
Logger.warn(TAG, sessionCorrelationId, "Failed to persist session correlation entry: " + e.message)
294+
}
295+
}
296+
297+
companion object {
298+
private val TAG = OnboardingTelemetryRecorder::class.java.simpleName
299+
private const val PREFS_FILE = "com.microsoft.oneauth.session_correlation_cache"
300+
private const val EMPTY_BLOB = ""
301+
private const val ISO_TIMESTAMP_FORMAT = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"
302+
303+
// Seed field key constants — must match OnboardingBlobConstants (Djinni-generated).
304+
// Duplicated here to avoid a dependency on the Djinni-generated Java class in Common.
305+
private const val FIELD_SCHEMA_VERSION = "schema_version"
306+
private const val FIELD_SESSION_CORRELATION_ID = "session_correlation_id"
307+
private const val FIELD_ONBOARDING_MODE = "onboarding_mode"
308+
private const val FIELD_STEPS_LIST = "steps_list"
309+
private const val FIELD_STEP_ID = "step_id"
310+
private const val FIELD_TS = "ts"
311+
private const val FIELD_ID = "id"
312+
}
313+
}

0 commit comments

Comments
 (0)