Skip to content

[common] Add non-mutating log_telemetry bridge action (errorCode), Fixes AB#3688631 - #3197

Merged
wzhipan merged 18 commits into
devfrom
copilot/pbi-3688631-log-telemetry
Aug 9, 2026
Merged

wzhipan merged 18 commits into
devfrom
copilot/pbi-3688631-log-telemetry

Conversation

@wzhipan

@wzhipan wzhipan commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements AB#3688631 (child of Feature AB#3688629Auth UX Telemetry Native Host: Broker / common-core (AndroidCommon)).

Adds a dedicated, non-mutating log_telemetry action to the existing AuthUxJavaScriptInterface JS bridge so Auth UX page JS can push an opaque server error code (e.g. an STS code such as 530003) to the native host over the already-shipped window.broker.postMessageToBroker(...)receiveAuthUxMessage path — without creating a new bridge, WebView interface, or telemetry island.

Wire format

Implemented against the Auth UX design-doc shape:

{
  "correlationID": "$correlationId",
  "action_name": "log_telemetry",
  "action_component": "host",
  "params": {
    "v": 1,
    "sessionID": "$sessionID",
    "errorCode": 530003,
    "pageId": "ConvergedTFA",
    "trackingId": "$trackingId"
  }
}

Adding new key/value pairs — at the top level and inside params — does not break parsing or dispatch: the custom deserializer reads only known top-level keys, and Gson ignores unknown params fields. Covered by explicit tests.

Changes

Dispatch

  • Telemetry is matched on the top-level action_name == "log_telemetry" first, before params.operation. This ordering is load-bearing: a log_telemetry message that smuggles params.operation = "number_matching" must never reach the number-match device store. Pinned by a test that fails if the branches are reordered.
  • number_matching continues to dispatch off params.operation, unchanged.

Sink seam

  • AuthUxTelemetrySink receives an AuthUxTelemetryEventcorrelationId, errorCode, sessionId, pageId, trackingId, version — rather than a bare error code. correlationId is the Kusto / DRI join key; passing the whole context now avoids a source-breaking change to a published SAM interface when a downstream consumer wants those fields.
  • The sink returns whether it consumed the event. A sink may legitimately accept a call and record nothing (the AB#3688632 implementation does exactly this while its recorder is not yet attached). Returning false leaves the code eligible for a later retry instead of it being suppressed as already-forwarded and lost.
  • Optional (@JvmOverloads) telemetrySink constructor param, so the existing no-arg construction stays source- and binary-compatible.

Validation and boundingerrorCode is page-controlled and (via AB#3688632) ends up in an uploaded blob, so it is:

  • shape/length checked against ^[A-Za-z0-9_-]{1,32}$ — permissive enough for both numeric STS codes and the symbolic constants addBlockingError accepts;
  • validated before it reaches any log line, so an embedded CR/LF cannot forge log entries;
  • de-duplicated and capped per bridge instance — 10 distinct codes handed to a sink, plus a separate bound of 25 total post-validation attempts so the paths that deliberately do not record a handled code (no sink wired, sink declines) are bounded too;
  • and every other page-supplied value (action_name, operation, correlationID) is sanitized before logging too.

Observability — distinct log lines for forwarded / malformed / duplicate / capped / not-consumed / no-sink-wired, each carrying the correlation ID; new authux_js_error_code span attribute, set only for codes a sink actually took (so the span cannot disagree with downstream telemetry). A throwing sink gets its own try/catch and a sink-specific message rather than being misreported as a payload parsing failure, and a throw suppresses retry only — it does not set the span attribute or claim the code was forwarded, because nothing reached downstream telemetry.

Modelparams is modelled with @SerializedName; all fields nullable. Note v is deliberately typed String?, not Int?: Gson throws on a type mismatch, so an Int? version meant a "1.0" or "v2" value would drop the entire message including errorCode. Please do not "clean this up" back to Int.

Scope of the dedupe/cap

The cap is per bridge instance, and the WebView host rebuilds the bridge on every navigation — so it is a per-page flood guard, not a request-wide guarantee. Request-wide de-duplication belongs to the consumer that owns the blob and is implemented in AB#3688632. This is stated plainly in the KDoc and pinned by a test.

Security (H3 / H6)

The new branch is on a separate code path from write_data / number_matching / storeNumberMatch — telemetry-only, append-oriented, and it never mutates device or credential state. The client takes no action on the code (H6); errorCode is an opaque telemetry value.

Threading

@JavascriptInterface methods are dispatched on the WebView's private JavaBridge thread, not the UI thread. The bridge serializes its own calls per instance and never invokes a sink while holding an internal lock. It cannot make host-owned state safe, so the recorder's collections are made thread-safe in AB#3688632 rather than relying on a documentation-only requirement.

Merge order — please read

The host wiring for this seam lives in the stacked child PR #3201. If this PR lands on dev alone, dev carries a log_telemetry path that no host wires: every message will parse, validate, and then log "no telemetry sink is wired; dropping". Land them together, or expect that silence.

Both flows also remain gated on Auth UX actually emitting log_telemetry server-side (AB#3696811).

Scope note — brokered flow only (by design)

Bridge exposure is gated by shouldExposeJavaScriptInterface(...)ProcessUtil.isRunningOnAuthService(...) (plus the origin allow-list and the ENABLE_JS_API_FOR_AUTHUX flight), so this action is exercised in the brokered flow only. Non-brokered (OneAuth brokerless WebView) + iOS enablement is tracked under Feature AB#3688630.

Out of scope (follow-up PBIs)

  • AB#3688632 — append the code to the onboarding blob's blocking-errors list + non-blocking exclusion (wires the concrete sink).
  • AB#3688633 — full page→native security controls (H1, H4–H6).
  • AB#3688634 — additional unit-test matrix.

Testing

./gradlew :common:testLocalDebugUnitTest \
  --tests "*AuthUxJavaScriptInterfaceTest" --tests "*AuthUxJsonPayloadTest"

BUILD SUCCESSFULAuthUxJavaScriptInterfaceTest 37/37, AuthUxJsonPayloadTest 10/10, 0 failures.

Two behaviours are pinned by revert-tests (they fail if the fix is undone):

  • dispatch order — "log_telemetry must never write to the number-match store";
  • record-only-when-consumed — "expected:<[530003]> but was:<[]>", i.e. the code would otherwise be permanently lost;
  • the attempt bound — "expected:<25> but was:<40>", i.e. an unconsumed code would otherwise re-invoke the sink without limit.

Known, accepted limitations

  • errorCode: true is coerced by Gson to the string "true", which is alphanumeric and so indistinguishable from a symbolic code by shape. Validation bounds charset and length, not semantics — judging what is a meaningful error code is the server contract's job. Recorded as a named test rather than left undiscovered.
  • A structured v ({major: 2} / [2]) still throws and drops the message. That shape is not part of the contract.
  • errorCode: 530003.0 is captured as "530003.0" and then rejected by validation — the recorded value depends on how the page serializes the number.

@github-actions

Copy link
Copy Markdown

✅ Work item link check complete. Description contains link AB#3688631 to an Azure Boards work item.

@github-actions github-actions Bot changed the title [common] Add non-mutating log_telemetry bridge operation (error_code), AB#3688631 [common] Add non-mutating log_telemetry bridge operation (error_code), AB#3688631, Fixes AB#3688631 Jul 30, 2026
…#3688631

Add a dedicated, non-mutating `log_telemetry` action to AuthUxJavaScriptInterface so
Auth UX page JS can push an opaque server error code to the native host over the existing
postMessageToBroker bridge, without touching the number-match / write_data device-store
path (H3).

Implemented against the Auth UX design-doc wire format:
  { "correlationID": ..., "action_name": "log_telemetry", "action_component": "host",
    "params": { "v": 1, "sessionID": ..., "errorCode": 530003, "pageId": ..., "trackingId": ... } }

- AuthUxJavaScriptInterface: dispatch by top-level action_name == "log_telemetry" (the action
  carries no params.operation) via a new ActionNames.LOG_TELEMETRY; add a minimal
  AuthUxTelemetrySink seam and an optional (@jvmoverloads) telemetrySink constructor param that
  keeps existing no-arg construction source-compatible. The branch reads errorCode and forwards
  it to the sink; absent/empty errorCode is a safe no-op. number_matching still dispatches off
  params.operation, unchanged.
- AuthUxJsonPayload: model the full params contract with @SerializedName — v (version), sessionID,
  errorCode (opaque; captured as a string even when sent as a JSON number), pageId, trackingId;
  all fields nullable.
- Forward-compat: new top-level and params key/value pairs do not break parsing or dispatch
  (custom deserializer reads only known top-level keys; Gson ignores unknown params fields).
- Tests: exact design-doc payload; errorCode present/absent; unknown top-level + params fields
  tolerated; sink invoked exactly once; no-op on missing errorCode; number_matching regression-safe
  and never routed to the sink.

The onboarding-blob append (blocking-errors list + non-blocking exclusion) and consumption of
pageId/trackingId/sessionID/v are handled downstream in AB#3688632.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Zhipan Wang added 3 commits August 3, 2026 09:26
… context, AB#3688631

Blocking fixes:
- Dispatch on action_name BEFORE params.operation. Previously a log_telemetry
  message that smuggled params.operation=number_matching took the number-match
  branch and mutated the device store, contradicting the H3 invariant the code
  comment claimed. Regression test fails without the reordering.
- params.v retyped Int? -> String?. Gson throws on a type mismatch, so a version
  of "1.0" or "v2" dropped the ENTIRE message including errorCode. The one field
  designed to change over time must never be a parse cliff.

Hardening:
- Validate errorCode against ^[A-Za-z0-9_-]{1,32}$ BEFORE it reaches any log
  line, so a crafted value cannot inject newlines into logs or push an unbounded
  string into the uploaded onboarding blob. Mirrors the NumberMatchHelper
  precedent on this same bridge.
- Dedupe and cap forwarded codes per bridge instance (max 10) so a looping page
  cannot bloat the blob.
- Bound page-supplied action/operation strings echoed into logs.

Observability:
- Log the forwarded / rejected / duplicate / capped / no-sink-wired outcomes
  distinguishably, each with the correlation id inline.
- Record the code on a new authux_js_error_code span attribute so the path is
  queryable in android_spans without waiting for the blob to land.
- Give the sink invocation its own try/catch so a throwing sink is no longer
  misreported as "Unknown error occurred while processing the payload".

API:
- Sink now takes an AuthUxTelemetryEvent (correlation id, error code, session /
  page / tracking ids, version) instead of a bare error code. correlationId is
  the Kusto/DRI join key and was being discarded at the seam; passing the
  context now avoids a source-breaking SAM change in a follow-up PBI.
- Document the JavaBridge calling thread and the thread-safety requirement on
  AuthUxTelemetrySink.

Also: document the log_telemetry wire format on receiveAuthUxMessage (the KDoc
described only number-match), use the unused SerializedNames.PARAMS constant,
and add the reviewer's missing test cases (28 bridge + 10 payload, all passing).
@wzhipan wzhipan changed the title [common] Add non-mutating log_telemetry bridge operation (error_code), AB#3688631, Fixes AB#3688631 [common] Add non-mutating log_telemetry bridge action (errorCode), Fixes AB#3688631 Aug 4, 2026
Zhipan Wang added 2 commits August 4, 2026 15:44
…tize correlationId, AB#3688631

A (blocking) record-before-forward: a code was added to forwardedErrorCodes
before the sink was null-checked or invoked, so a code that nothing consumed was
suppressed as a duplicate on retry and permanently lost -- while the bridge had
already logged "Forwarded ...". The sink now returns whether it consumed the
event; the code is recorded only on true, never on the no-sink path. A sink that
throws counts as consumed (a throwing sink is a host defect, retrying cannot fix
it, and treating it as retryable lets a looping page re-invoke it without bound).

B (blocking) dedupe/cap scope: the bridge is rebuilt on every onPageStarted, so
the per-instance state resets each navigation while the consumer lives for the
whole request. Confirmed by our own device E2E, whose blob contained
blocking_errors ["530003","530003"] from two page loads -- previously written off
as a simulation artifact. The KDoc now states the per-page scope and its
consequence plainly, a test pins the behaviour across two registrations, and
request-wide de-duplication moves to the consumer, which owns the blob.

C: the span attribute was set before the dedupe/cap checks, so it reported codes
that were never forwarded. Moved after the forward decision, and the
last-forwarded-wins overwrite semantics are documented.

D: correlationID is page-controlled and was logged raw, then passed as the
correlationID argument of five Logger calls -- common4j formats it verbatim, so
the log-forging vector this PR closes for errorCode was still open here.
Sanitized once immediately after parsing.

E: the sink is no longer invoked while holding the internal lock, and the
threading contract now states the guarantee the bridge actually provides
(serialized per instance, on the JavaBridge thread) instead of asserting a
requirement it cannot enforce. Host-owned state is made thread-safe in #3201.

F: the version KDoc no longer claims "no cliff" -- a structured value still
drops the message; documented as out of contract.

Tests: 28 -> 34. The A fix is pinned by two tests that fail without it
(expected:<[530003]> but was:<[]>).
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown

✅ Work item link check complete. Description contains link AB#3688631 to an Azure Boards work item.

…8631

N-B (regression from the last round): moving add() after consumption meant the
distinct-code cap only ever counted CONSUMED codes, so the not-consumed and
no-sink paths were unbounded -- a code that is never consumed is never recorded,
so neither contains() nor the size check can ever fire for it. A page looping the
same code while the recorder was unattached re-invoked the sink and re-logged
without limit, and today every production message takes one of those two paths.
The previous test hid this: it posted 15 codes against a cap of 10 and asserted
only that none were consumed, never that the sink stopped being called.

Added telemetryAttempts, counted before the outcome is known, so every path past
validation is bounded (MAX_TELEMETRY_ATTEMPTS = 25, above the distinct-code cap
so legitimate retries still get through). The test now asserts sink invocations
are capped and fails without the guard (expected:<25> but was:<40>).

N-A: a throw set consumed = true, which drove three decisions when it should
drive one. Suppressing retry is right; also setting authux_js_error_code and
logging "Forwarded ... to onboarding telemetry" was not -- nothing reached
downstream telemetry, and the comment above setAttribute asserted exactly that
could not happen. Replaced the boolean with a SinkOutcome tri-state
(CONSUMED / NOT_CONSUMED / THREW): THREW suppresses retry only.

N-C: the KDoc credited per-instance serialization to this class, but
receiveAuthUxMessage takes no lock -- that is a WebView platform property (one
JavaBridge thread per WebView). It matters because the dedupe/cap bookkeeping is
now check-then-act across two critical sections with the sink call between them.
Documented the gap, attributed the serialization correctly, and noted the state
stays guarded so an off-thread caller could at worst cause a bounded duplicate
forward rather than a corrupted set.

Also renamed forwardedErrorCodes -> handledErrorCodes: it now holds codes that
will not be offered again, which includes the throw case.
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown

✅ Work item link check complete. Description contains link AB#3688631 to an Azure Boards work item.

@wzhipan
wzhipan marked this pull request as ready for review August 5, 2026 06:37
@wzhipan
wzhipan requested a review from a team as a code owner August 5, 2026 06:37
Copilot AI lite review requested due to automatic review settings August 5, 2026 06:37
@wzhipan
wzhipan requested a review from a team as a code owner August 5, 2026 06:37

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new non-mutating log_telemetry action to the Auth UX WebView JS bridge so Auth UX page JS can forward an opaque server error code to a host-provided telemetry sink, while keeping number_matching behavior isolated and unchanged.

Changes:

  • Introduces AuthUxTelemetryEvent + AuthUxTelemetrySink and dispatches action_name == "log_telemetry" before params.operation, forwarding validated error codes with context.
  • Extends the Auth UX payload model (AuthUxParams) to support telemetry fields (v, errorCode, pageId, trackingId) with nullable fields and a string-typed schema version.
  • Adds authux_js_error_code OpenTelemetry attribute + expands unit tests and updates changelog.txt.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
common4j/src/main/com/microsoft/identity/common/java/opentelemetry/AttributeName.java Adds new span attribute authux_js_error_code for Auth UX error-code telemetry.
common/src/main/java/com/microsoft/identity/common/internal/broker/AuthUxJsonPayload.kt Extends payload params model + deserializer constants to support telemetry fields.
common/src/main/java/com/microsoft/identity/common/internal/broker/AuthUxJavaScriptInterface.kt Adds the log_telemetry dispatch path, sink seam, validation/dedupe/capping, and span attribute setting.
common/src/test/java/com/microsoft/identity/common/internal/broker/AuthUxJsonPayloadTest.kt Adds deserialization tests for the log_telemetry wire shape and forward-compat cases.
common/src/test/java/com/microsoft/identity/common/internal/broker/AuthUxJavaScriptInterfaceTest.kt Adds dispatch/behavioral tests for sink forwarding, retry eligibility, dedupe/caps, and safety invariants.
changelog.txt Documents the new log_telemetry bridge behavior in vNext.

Zhipan Wang added 2 commits August 5, 2026 09:29
…join key, AB#3688631

The attempt cap was checked AFTER the duplicate and distinct-code checks, and both
of those log and return -- so a page looping one already-handled code, or posting
new codes once the distinct-code cap was reached, never reached the counter and
could spam the log without bound. Same failure mode the counter was added to
prevent, one level up. Now checked and incremented first, so every path past
validation is bounded, and the cap message itself is logged once on the
transition rather than on every subsequent message.

correlationID was run through sanitizeForLog, which truncates at 64 characters.
It is also the telemetry join key forwarded to the sink, so truncating a longer
ID would silently break correlation. Split out sanitizeCorrelationId: strips
control characters (the log-forging concern) without changing length semantics,
cutting only past 128 characters, which no real correlation ID reaches. A GUID
now survives verbatim, pinned by a test.

Mirrored authux_js_error_code into broker4j's AttributeName enum, which already
carries authux_js_action_name / _action_component / _operation -- cross-repo
telemetry keys must stay aligned. Switched the JavaDoc to {@code} in both, since
Markdown backticks do not render in JavaDoc.

Made the log_telemetry test fixtures strict JSON (quoted keys). They are
described as matching the design-doc wire format, so they should be what a real
JSON.stringify sender emits rather than something only a lenient parser accepts.
Pre-existing number-match fixtures left alone -- not this PR's code.

Tests 37 -> 39. The ordering fix is pinned by a test that fails without it
(expected:<1> but was:<2>).
wzhipan pushed a commit that referenced this pull request Aug 5, 2026
Repeated merges of an edited changelog line left four duplicate #3197 entries and
split this PR across three separate lines. Collapsed to a single entry each.
wzhipan pushed a commit that referenced this pull request Aug 5, 2026
Repeated merges of edited changelog lines left duplicate entries for #3197 and
#3201 and split each PR across several lines. Collapsed to a single entry each.
@wzhipan
wzhipan requested a lite review from Copilot August 5, 2026 17:42

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.

Suppressed comments (2)

common/src/main/java/com/microsoft/identity/common/internal/broker/AuthUxJavaScriptInterface.kt:233

  • sanitizeCorrelationId() currently uses value.map { ... }.joinToString(""), which walks and allocates a full copy of the correlation ID even though the result is truncated to 128 chars. Since correlationID is page-controlled, this can cause unnecessary large allocations/CPU work for oversized inputs.

Recommendation: sanitize incrementally and stop once MAX_CORRELATION_ID_LENGTH is reached, so processing stays bounded.

        private fun sanitizeCorrelationId(value: String): String {
            val flattened = value.map { if (it.isISOControl()) ' ' else it }.joinToString("")
            return if (flattened.length <= MAX_CORRELATION_ID_LENGTH) {
                flattened
            } else {

common/src/main/java/com/microsoft/identity/common/internal/broker/AuthUxJavaScriptInterface.kt:216

  • sanitizeForLog() builds a full-size copy of the untrusted page-supplied string via value.replace(...) before truncating. A malicious/buggy page can send a very large value and trigger large allocations/CPU work on the JavaBridge thread (potential DoS) even though the log output is capped.

Recommendation: sanitize while iterating and stop once MAX_LOGGED_VALUE_LENGTH is reached (also replaces control chars), so work is bounded regardless of input size.

This issue also appears on line 229 of the same file.

            val flattened = value.replace('\n', ' ').replace('\r', ' ')
            return if (flattened.length <= MAX_LOGGED_VALUE_LENGTH) {
                flattened
            } else {
                flattened.substring(0, MAX_LOGGED_VALUE_LENGTH) + "...(truncated)"

Both sanitize helpers transformed the whole page-supplied string and only then
truncated it. sanitizeCorrelationId was the worse of the two: String.map
produces a List<Char>, so it boxed every character of the input before joining.

Replaced both with one sanitizeBounded(value, limit) that walks at most limit
characters into a pre-sized StringBuilder. Work is now proportional to the output
bound rather than to the size of the untrusted input.

Note this bounds OUR processing, not the attacker's allocation -- the value is
already fully materialized by the time the bridge is called (WebView marshals the
payload across JNI and Gson parses it before we see it), so the DoS framing is
weaker than it looks. It is still strictly better and trivially cheap.

Unifying on the shared helper also means sanitizeForLog now strips all control
characters instead of only CR/LF, so an ESC or NEL can no longer reach a log line
either.
@wzhipan

wzhipan commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Fixed in 3104c92 — picked up both suppressed comments about sanitizeForLog / sanitizeCorrelationId.

Both were valid. sanitizeCorrelationId was the worse of the two: Kotlin's String.map produces a List<Char>, so it boxed every character of the input before joining. Replaced both with a single sanitizeBounded(value, limit) that walks at most limit characters into a pre-sized StringBuilder, so the work is proportional to the output bound rather than to the size of the untrusted input.

One correction to the framing: this bounds our processing, not the attacker's allocation. By the time the bridge is invoked the value is already fully materialized — WebView marshals the whole payload across JNI and Gson parses it before receiveAuthUxMessage sees it — so the DoS characterization is weaker than stated. The change is still strictly better and trivially cheap, hence taking it.

Bonus from unifying on the shared helper: sanitizeForLog now strips all control characters rather than only CR/LF, so an ESC or NEL can no longer reach a log line either. Two tests added (oversized input bounded; control chars beyond CR/LF stripped), and the existing test asserting a 36-char GUID survives verbatim still passes — 41 bridge tests green.

@wzhipan
wzhipan requested a lite review from Copilot August 5, 2026 19:06
@Prvnkmr337

Copy link
Copy Markdown
Contributor

Overall, the change looks good. The new telemetry action is well isolated from the existing number-matching path, defensively validates page-controlled input, and has strong test coverage. I left a few minor comments around clarity, naming, and additional test coverage; none are blocking.

wzhipan pushed a commit that referenced this pull request Aug 7, 2026
Conflict in AzureActiveDirectoryWebViewClientTest: both sides appended tests at
the end of the class. Kept both - this PR's 11 Auth UX onboarding / shim tests
and dev's 5 new MAM install-referrer tests (#3193). 93 + 5 = 98 @test, all green.
wzhipan pushed a commit that referenced this pull request Aug 7, 2026
…ed), AB#3708195

The broker builds an onboarding telemetry recorder per request from the seed,
but the WebView that renders the interactive auth / remediation pages lives in a
separate AuthorizationActivity created by the OS from an Intent. A live recorder
cannot ride an Intent, so the WebView-side onboarding hooks were inert for
brokered flows: steps observed in the WebView (MDM enrollment, Company Portal
launch, broker install), the last loaded domain, and the Auth UX log_telemetry
error code never reached the blob the broker finalizes and returns.

Both components run in the broker :auth process, so this adds an in-process
handoff keyed by the request correlationId.

OnboardingRecorderRegistry (new)
- Rejects DiagnosticContext.UNSET_CORRELATION_ID as a key. Every thread whose
  request context was never set carries that sentinel, and the authorization
  Intent extra is populated by reading the request-context map directly rather
  than through getThreadCorrelationId(), so the raw sentinel can reach the
  registry. It is shared by definition: accepting it would let two unrelated
  requests resolve to the same recorder and merge one flow's blocking errors into
  the other's uploaded blob. Rejecting it makes the feature inert (and logs a
  warning) instead of silently mis-attributing telemetry, which for a component
  whose whole purpose is correct attribution is the only acceptable failure.
- Bounded, access-ordered map (cap 16). Entries must be unregistered on terminal
  outcome, and because this is process-static state in a process that lives as
  long as the device is up, a missed unregister would otherwise leak a recorder
  permanently - the recorder object and its collected steps / blocking errors,
  not an Activity, since OnboardingTelemetryRecorder holds only the application
  context. On overflow it evicts the least-recently-used entry and logs a
  warning, bounding the leak while surfacing the underlying bug.
- Stores the concrete OnboardingTelemetryRecorder because the WebView client's
  hooks (e.g. setLastLoadedDomain) use methods not on the common4j interface.

WebViewAuthorizationFragment
- Resolves the recorder in onCreateView and attaches it to the WebView client
  before initializeAuthUxJavaScriptApi(...). No-op when the request seeded no
  recorder, which is the MSAL-client path.
- Reads the correlation id from its own state bundle in extractState, alongside
  every other request field, rather than from activity.getIntent(). This fragment
  supports being hosted by an activity it does not own, whose Intent would not
  carry the extra. The id is also added to onSaveInstanceState: the Intent
  survives activity recreation but the bundle did not carry it, so reading only
  from the bundle would otherwise have silently dropped the recorder after a
  config change AuthorizationActivity does not declare (e.g. uiMode).

DiagnosticContext
- UNSET_CORRELATION_ID is now public, and the internal duplicate string literal
  in getThreadCorrelationId() references it. Callers that use the correlation id
  as a KEY rather than for logging have to reject the sentinel explicitly, and a
  copied "UNSET" literal in the registry would silently stop matching if this
  ever changed - reintroducing cross-request contamination with no signal.

Note this PR alone changes no behaviour: register/unregister have no caller in
this repo (both live in the paired broker PR), so get() always returns null and
the hooks stay inert. Merge order: #3197 -> #3201 -> #3204 -> broker PR.

Tests: OnboardingRecorderRegistryTest (15) covers correlation-id keying, the
UNSET sentinel (including the concrete two-request cross-wiring it prevents),
removal / lifecycle, absent-key behaviour, null and empty inputs, idempotent
unregister, and the leak bound. Revert-tested: removing the sentinel guard fails
with "the sentinel must never become a key expected:<0> but was:<1>"; disabling
the cap fails with "expected:<16> but was:<100>"; switching eviction to
insertion order evicts a live in-use recorder.

Verified end-to-end on device: built brokerHost against this change and drove a
brokered MAM/Conditional-Access flow; the finalized blob carried the WebView-
observed steps, last_loaded_domain, and blocking_errors ["530003"] from the
Auth UX bridge.

186 tests green in this area: WebViewClient 98, bridge 41, recorder 18,
registry 15, payload 10, correlation store 4.
shahzaibj:
- Sanitize the carried context fields (sessionID / pageId / trackingId / v) at
  the sink seam. errorCode was shape-validated and correlationId was control-char
  stripped, but these four crossed the same trust boundary raw, so a consumer
  that logged one would inherit the log-forging hole already closed for
  correlationId. Stripped and bounded; not shape-validated, since unlike
  errorCode there is no contract shape to check. Null is preserved as null.
- Correct the span-attribute comment. It claimed the span "cannot report codes
  that downstream telemetry never received", but CONSUMED explicitly covers a
  sink that deliberately drops a code by policy - so a sink with an exclusion
  list (as the onboarding sink has) legitimately produces a span carrying a code
  the blob does not. The real guarantee is narrower: the span never carries a
  code NO sink took. Documented as written rather than as wished.
- Document the sink contract for the not-ready window: signal with false, never
  by throwing, because THREW suppresses retry for the rest of the page load and
  would lose exactly the early-arrival events the false contract preserves.

Prvnkmr337:
- Rename AuthUxTelemetrySink.onAuthUxTelemetry -> tryConsumeAuthUxTelemetry. The
  on... name reads as a notification, but the Boolean return decides whether the
  event stays eligible for retry. No production caller names the method (the
  sink is wired by method reference), so this is interface + tests only.
- Clarify the authux_js_error_code KDoc using the suggested wording, plus the
  policy-drop caveat above so it is not read as a mirror of blocking_errors.
- Note that "v" is the wire name defined by the Auth UX telemetry contract.
- Add the missing span-attribute tests: set on CONSUMED, unset on NOT_CONSUMED
  and on THREW, and last-consumed-code-wins across several codes.

The span tests use a small Java RecordingSpan extending the production NoopSpan,
so no OpenTelemetry SDK/exporter test dependency is added. It is Java because
Span.setAttribute(String, String) is a Java interface default method that
NoopSpan overrides, and Kotlin refuses to override it from a subclass or via
interface delegation ("overrides nothing").

Revert-tested: stamping the span unconditionally fails both "unset when the sink
declines" and "unset when the sink throws".

162 tests green: bridge 45 (was 41), payload 10, WebViewClient 87, recorder 16,
correlation store 4.
wzhipan pushed a commit that referenced this pull request Aug 7, 2026
…ed), AB#3708195

The broker builds an onboarding telemetry recorder per request from the seed,
but the WebView that renders the interactive auth / remediation pages lives in a
separate AuthorizationActivity created by the OS from an Intent. A live recorder
cannot ride an Intent, so the WebView-side onboarding hooks were inert for
brokered flows: steps observed in the WebView (MDM enrollment, Company Portal
launch, broker install), the last loaded domain, and the Auth UX log_telemetry
error code never reached the blob the broker finalizes and returns.

Both components run in the broker :auth process, so this adds an in-process
handoff keyed by the request correlationId.

OnboardingRecorderRegistry (new)
- Rejects DiagnosticContext.UNSET_CORRELATION_ID as a key. Every thread whose
  request context was never set carries that sentinel, and the authorization
  Intent extra is populated by reading the request-context map directly rather
  than through getThreadCorrelationId(), so the raw sentinel can reach the
  registry. It is shared by definition: accepting it would let two unrelated
  requests resolve to the same recorder and merge one flow's blocking errors into
  the other's uploaded blob. Rejecting it makes the feature inert (and logs a
  warning) instead of silently mis-attributing telemetry, which for a component
  whose whole purpose is correct attribution is the only acceptable failure.
- Bounded, access-ordered map (cap 16). Entries must be unregistered on terminal
  outcome, and because this is process-static state in a process that lives as
  long as the device is up, a missed unregister would otherwise leak a recorder
  permanently - the recorder object and its collected steps / blocking errors,
  not an Activity, since OnboardingTelemetryRecorder holds only the application
  context. On overflow it evicts the least-recently-used entry and logs a
  warning, bounding the leak while surfacing the underlying bug.
- Stores the concrete OnboardingTelemetryRecorder because the WebView client's
  hooks (e.g. setLastLoadedDomain) use methods not on the common4j interface.

WebViewAuthorizationFragment
- Resolves the recorder in onCreateView and attaches it to the WebView client
  before initializeAuthUxJavaScriptApi(...). No-op when the request seeded no
  recorder, which is the MSAL-client path.
- Reads the correlation id from its own state bundle in extractState, alongside
  every other request field, rather than from activity.getIntent(). This fragment
  supports being hosted by an activity it does not own, whose Intent would not
  carry the extra. The id is also added to onSaveInstanceState: the Intent
  survives activity recreation but the bundle did not carry it, so reading only
  from the bundle would otherwise have silently dropped the recorder after a
  config change AuthorizationActivity does not declare (e.g. uiMode).

DiagnosticContext
- UNSET_CORRELATION_ID is now public, and the internal duplicate string literal
  in getThreadCorrelationId() references it. Callers that use the correlation id
  as a KEY rather than for logging have to reject the sentinel explicitly, and a
  copied "UNSET" literal in the registry would silently stop matching if this
  ever changed - reintroducing cross-request contamination with no signal.

Note this PR alone changes no behaviour: register/unregister have no caller in
this repo (both live in the paired broker PR), so get() always returns null and
the hooks stay inert. Merge order: #3197 -> #3201 -> #3204 -> broker PR.

Tests: OnboardingRecorderRegistryTest (15) covers correlation-id keying, the
UNSET sentinel (including the concrete two-request cross-wiring it prevents),
removal / lifecycle, absent-key behaviour, null and empty inputs, idempotent
unregister, and the leak bound. Revert-tested: removing the sentinel guard fails
with "the sentinel must never become a key expected:<0> but was:<1>"; disabling
the cap fails with "expected:<16> but was:<100>"; switching eviction to
insertion order evicts a live in-use recorder.

Verified end-to-end on device: built brokerHost against this change and drove a
brokered MAM/Conditional-Access flow; the finalized blob carried the WebView-
observed steps, last_loaded_domain, and blocking_errors ["530003"] from the
Auth UX bridge.

186 tests green in this area: WebViewClient 98, bridge 41, recorder 18,
registry 15, payload 10, correlation store 4.
@wzhipan
wzhipan requested a lite review from Copilot August 7, 2026 18:10

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.

Suppressed comments (1)

common/src/test/java/com/microsoft/identity/common/internal/broker/AuthUxJavaScriptInterfaceTest.kt:236

  • Use SpanExtension.makeCurrentSpan(span) instead of Span.makeCurrent() when installing the current span. SpanExtension wraps makeCurrent() to avoid known AbstractMethodError/NPE issues on some devices (and keeps test code aligned with the production pattern).
    /** Runs [block] with [span] installed as the current span. */
    private fun withCurrentSpan(span: Span, block: () -> Unit) {
        span.makeCurrent().use { block() }
    }

wzhipan pushed a commit that referenced this pull request Aug 7, 2026
…ed), AB#3708195

The broker builds an onboarding telemetry recorder per request from the seed,
but the WebView that renders the interactive auth / remediation pages lives in a
separate AuthorizationActivity created by the OS from an Intent. A live recorder
cannot ride an Intent, so the WebView-side onboarding hooks were inert for
brokered flows: steps observed in the WebView (MDM enrollment, Company Portal
launch, broker install), the last loaded domain, and the Auth UX log_telemetry
error code never reached the blob the broker finalizes and returns.

Both components run in the broker :auth process, so this adds an in-process
handoff keyed by the request correlationId.

OnboardingRecorderRegistry (new)
- Rejects DiagnosticContext.UNSET_CORRELATION_ID as a key. Every thread whose
  request context was never set carries that sentinel, and the authorization
  Intent extra is populated by reading the request-context map directly rather
  than through getThreadCorrelationId(), so the raw sentinel can reach the
  registry. It is shared by definition: accepting it would let two unrelated
  requests resolve to the same recorder and merge one flow's blocking errors into
  the other's uploaded blob. Rejecting it makes the feature inert (and logs a
  warning) instead of silently mis-attributing telemetry.
- Bounded, access-ordered map (cap 16). Entries must be unregistered on terminal
  outcome, and because this is process-static state in a process that lives as
  long as the device is up, a missed unregister would otherwise leak a recorder
  permanently - the recorder object and its collected steps / blocking errors,
  not an Activity, since OnboardingTelemetryRecorder holds only the application
  context. On overflow it evicts the least-recently-used entry and logs a
  warning, bounding the leak while surfacing the underlying bug.

AuthorizationFragment
- Captures the correlation id in extractState and round-trips it through
  onSaveInstanceState. The read was already a base-class responsibility, so the
  save belongs next to it: putting it in one subclass left the other two
  (BrowserAuthorizationFragment, CurrentTaskBrowserAuthorizationFragment) still
  blanking their diagnostic context after activity recreation, and invited the
  pair to drift apart again. All three subclasses already call super, so the
  parent implementation fixes all of them with no duplication.
- This also repairs a pre-existing bug on that path: extractState previously fed
  setDiagnosticContextForNewThread(null) after a recreation, so every subsequent
  log line for the request lost its correlation id.

WebViewAuthorizationFragment
- Resolves the recorder in onCreateView from the inherited mCorrelationId and
  attaches it to the WebView client before initializeAuthUxJavaScriptApi(...).
  No-op when the request seeded no recorder, which is the MSAL-client path.

DiagnosticContext
- UNSET_CORRELATION_ID is now public, and the internal duplicate string literal
  in getThreadCorrelationId() references it. Callers that use the correlation id
  as a KEY rather than for logging have to reject the sentinel explicitly, and a
  copied "UNSET" literal in the registry would silently stop matching if this
  ever changed - reintroducing cross-request contamination with no signal.

Logger
- Notes that its own UNSET literal is a display placeholder, deliberately
  independent of the DiagnosticContext sentinel: it also stands in for a missing
  thread id and is never used as a key, so it must not be collapsed into the
  new constant.

Note this PR alone changes no behaviour for the recorder handoff:
register/unregister have no caller in this repo (both live in the paired broker
PR). Merge order: #3197 -> #3201 -> #3204 -> broker PR.

Tests
- OnboardingRecorderRegistryTest (15): keying, the UNSET sentinel (including the
  concrete two-request cross-wiring it prevents), removal / lifecycle, absent-key
  behaviour, null and empty inputs, idempotent unregister, and the leak bound.
- AuthorizationFragmentCorrelationIdTest (6, new): the fragment half of the
  handoff - extractState captures the id, the captured id resolves the registered
  recorder, it survives save/restore, and the no-recorder / missing-id / sentinel
  paths resolve to null rather than throwing. Without these, a regression in the
  correlation-id plumbing would leave every hook inert while the registry tests
  still passed.

Revert-tested: removing the sentinel guard fails with "the sentinel must never
become a key expected:<0> but was:<1>"; disabling the cap fails with
"expected:<16> but was:<100>"; switching eviction to insertion order evicts a
live in-use recorder; dropping the onSaveInstanceState round-trip fails with
"the correlation id must round-trip through the saved bundle expected:<...> but
was:<null>".

Verified end-to-end on device: built brokerHost against this change and drove a
brokered MAM/Conditional-Access flow; the finalized blob carried the WebView-
observed steps, last_loaded_domain, and blocking_errors ["530003"] from the
Auth UX bridge.

246 tests green across the onboarding / Auth UX / authorization-fragment suites.
Review feedback: sanitizeCarriedValue bounded sessionID / pageId / trackingId / v
with MAX_CORRELATION_ID_LENGTH, a constant named for something else.

Two problems with the reuse. The name does not describe the use, so a reader at
the call site sees a correlation-ID bound applied to a pageId. And it coupled two
unrelated limits: raising the correlation-ID bound (a cloud with longer IDs, say)
would have silently moved the bound on values that have nothing to do with
correlation, with nothing to catch it.

Adds MAX_CARRIED_VALUE_LENGTH, deliberately equal at 128 today but independent.
No behaviour change. 55 bridge tests green.
wzhipan pushed a commit that referenced this pull request Aug 7, 2026
wzhipan pushed a commit that referenced this pull request Aug 7, 2026
…ed), AB#3708195

The broker builds an onboarding telemetry recorder per request from the seed,
but the WebView that renders the interactive auth / remediation pages lives in a
separate AuthorizationActivity created by the OS from an Intent. A live recorder
cannot ride an Intent, so the WebView-side onboarding hooks were inert for
brokered flows: steps observed in the WebView (MDM enrollment, Company Portal
launch, broker install), the last loaded domain, and the Auth UX log_telemetry
error code never reached the blob the broker finalizes and returns.

Both components run in the broker :auth process, so this adds an in-process
handoff keyed by the request correlationId.

OnboardingRecorderRegistry (new)
- Rejects DiagnosticContext.UNSET_CORRELATION_ID as a key. Every thread whose
  request context was never set carries that sentinel, and the authorization
  Intent extra is populated by reading the request-context map directly rather
  than through getThreadCorrelationId(), so the raw sentinel can reach the
  registry. It is shared by definition: accepting it would let two unrelated
  requests resolve to the same recorder and merge one flow's blocking errors into
  the other's uploaded blob. Rejecting it makes the feature inert (and logs a
  warning) instead of silently mis-attributing telemetry.
- Bounded, access-ordered map (cap 16). Entries must be unregistered on terminal
  outcome, and because this is process-static state in a process that lives as
  long as the device is up, a missed unregister would otherwise leak a recorder
  permanently - the recorder object and its collected steps / blocking errors,
  not an Activity, since OnboardingTelemetryRecorder holds only the application
  context. On overflow it evicts the least-recently-used entry and logs a
  warning, bounding the leak while surfacing the underlying bug.

AuthorizationFragment
- Captures the correlation id in extractState and round-trips it through
  onSaveInstanceState. The read was already a base-class responsibility, so the
  save belongs next to it: putting it in one subclass left the other two
  (BrowserAuthorizationFragment, CurrentTaskBrowserAuthorizationFragment) still
  blanking their diagnostic context after activity recreation, and invited the
  pair to drift apart again. All three subclasses already call super, so the
  parent implementation fixes all of them with no duplication.
- This also repairs a pre-existing bug on that path: extractState previously fed
  setDiagnosticContextForNewThread(null) after a recreation, so every subsequent
  log line for the request lost its correlation id.

WebViewAuthorizationFragment
- Resolves the recorder in onCreateView from the inherited mCorrelationId and
  attaches it to the WebView client before initializeAuthUxJavaScriptApi(...).
  No-op when the request seeded no recorder, which is the MSAL-client path.

DiagnosticContext
- UNSET_CORRELATION_ID is now public, and the internal duplicate string literal
  in getThreadCorrelationId() references it. Callers that use the correlation id
  as a KEY rather than for logging have to reject the sentinel explicitly, and a
  copied "UNSET" literal in the registry would silently stop matching if this
  ever changed - reintroducing cross-request contamination with no signal.

Logger
- Notes that its own UNSET literal is a display placeholder, deliberately
  independent of the DiagnosticContext sentinel: it also stands in for a missing
  thread id and is never used as a key, so it must not be collapsed into the
  new constant.

Note this PR alone changes no behaviour for the recorder handoff:
register/unregister have no caller in this repo (both live in the paired broker
PR). Merge order: #3197 -> #3201 -> #3204 -> broker PR.

Tests
- OnboardingRecorderRegistryTest (15): keying, the UNSET sentinel (including the
  concrete two-request cross-wiring it prevents), removal / lifecycle, absent-key
  behaviour, null and empty inputs, idempotent unregister, and the leak bound.
- AuthorizationFragmentCorrelationIdTest (6, new): the fragment half of the
  handoff - extractState captures the id, the captured id resolves the registered
  recorder, it survives save/restore, and the no-recorder / missing-id / sentinel
  paths resolve to null rather than throwing. Without these, a regression in the
  correlation-id plumbing would leave every hook inert while the registry tests
  still passed.

Revert-tested: removing the sentinel guard fails with "the sentinel must never
become a key expected:<0> but was:<1>"; disabling the cap fails with
"expected:<16> but was:<100>"; switching eviction to insertion order evicts a
live in-use recorder; dropping the onSaveInstanceState round-trip fails with
"the correlation id must round-trip through the saved bundle expected:<...> but
was:<null>".

Verified end-to-end on device: built brokerHost against this change and drove a
brokered MAM/Conditional-Access flow; the finalized blob carried the WebView-
observed steps, last_loaded_domain, and blocking_errors ["530003"] from the
Auth UX bridge.

246 tests green across the onboarding / Auth UX / authorization-fragment suites.
wzhipan pushed a commit that referenced this pull request Aug 8, 2026
…ed), AB#3708195

The broker builds an onboarding telemetry recorder per request from the seed,
but the WebView that renders the interactive auth / remediation pages lives in a
separate AuthorizationActivity created by the OS from an Intent. A live recorder
cannot ride an Intent, so the WebView-side onboarding hooks were inert for
brokered flows: steps observed in the WebView (MDM enrollment, Company Portal
launch, broker install), the last loaded domain, and the Auth UX log_telemetry
error code never reached the blob the broker finalizes and returns.

Both components run in the broker :auth process, so this adds an in-process
handoff keyed by the request correlationId.

OnboardingRecorderRegistry (new)
- Rejects DiagnosticContext.UNSET_CORRELATION_ID as a key. Every thread whose
  request context was never set carries that sentinel, and the authorization
  Intent extra is populated by reading the request-context map directly rather
  than through getThreadCorrelationId(), so the raw sentinel can reach the
  registry. It is shared by definition: accepting it would let two unrelated
  requests resolve to the same recorder and merge one flow's blocking errors into
  the other's uploaded blob. Rejecting it makes the feature inert (and logs a
  warning) instead of silently mis-attributing telemetry.
- Bounded, access-ordered map (cap 16). Entries must be unregistered on terminal
  outcome, and because this is process-static state in a process that lives as
  long as the device is up, a missed unregister would otherwise leak a recorder
  permanently - the recorder object and its collected steps / blocking errors,
  not an Activity, since OnboardingTelemetryRecorder holds only the application
  context. On overflow it evicts the least-recently-used entry and logs a
  warning, bounding the leak while surfacing the underlying bug.

AuthorizationFragment
- Captures the correlation id in extractState and round-trips it through
  onSaveInstanceState. The read was already a base-class responsibility, so the
  save belongs next to it: putting it in one subclass left the other two
  (BrowserAuthorizationFragment, CurrentTaskBrowserAuthorizationFragment) still
  blanking their diagnostic context after activity recreation, and invited the
  pair to drift apart again. All three subclasses already call super, so the
  parent implementation fixes all of them with no duplication.
- This also repairs a pre-existing bug on that path: extractState previously fed
  setDiagnosticContextForNewThread(null) after a recreation, so every subsequent
  log line for the request lost its correlation id.

WebViewAuthorizationFragment
- Resolves the recorder in onCreateView from the inherited mCorrelationId and
  attaches it to the WebView client before initializeAuthUxJavaScriptApi(...).
  No-op when the request seeded no recorder, which is the MSAL-client path.

DiagnosticContext
- UNSET_CORRELATION_ID is now public, and the internal duplicate string literal
  in getThreadCorrelationId() references it. Callers that use the correlation id
  as a KEY rather than for logging have to reject the sentinel explicitly, and a
  copied "UNSET" literal in the registry would silently stop matching if this
  ever changed - reintroducing cross-request contamination with no signal.

Logger
- Notes that its own UNSET literal is a display placeholder, deliberately
  independent of the DiagnosticContext sentinel: it also stands in for a missing
  thread id and is never used as a key, so it must not be collapsed into the
  new constant.

Note this PR alone changes no behaviour for the recorder handoff:
register/unregister have no caller in this repo (both live in the paired broker
PR). Merge order: #3197 -> #3201 -> #3204 -> broker PR.

Tests
- OnboardingRecorderRegistryTest (15): keying, the UNSET sentinel (including the
  concrete two-request cross-wiring it prevents), removal / lifecycle, absent-key
  behaviour, null and empty inputs, idempotent unregister, and the leak bound.
- AuthorizationFragmentCorrelationIdTest (6, new): the fragment half of the
  handoff - extractState captures the id, the captured id resolves the registered
  recorder, it survives save/restore, and the no-recorder / missing-id / sentinel
  paths resolve to null rather than throwing. Without these, a regression in the
  correlation-id plumbing would leave every hook inert while the registry tests
  still passed.

Revert-tested: removing the sentinel guard fails with "the sentinel must never
become a key expected:<0> but was:<1>"; disabling the cap fails with
"expected:<16> but was:<100>"; switching eviction to insertion order evicts a
live in-use recorder; dropping the onSaveInstanceState round-trip fails with
"the correlation id must round-trip through the saved bundle expected:<...> but
was:<null>".

Verified end-to-end on device: built brokerHost against this change and drove a
brokered MAM/Conditional-Access flow; the finalized blob carried the WebView-
observed steps, last_loaded_domain, and blocking_errors ["530003"] from the
Auth UX bridge.

246 tests green across the onboarding / Auth UX / authorization-fragment suites.
wzhipan pushed a commit that referenced this pull request Aug 8, 2026
Two review findings, both about the sink being the trust boundary for the only
untrusted caller of addBlockingError.

Symbolic-code collision. The bridge shape-checks [A-Za-z0-9_-]{1,32}, which every
one of broker4j's symbolic blocking-error constants also matches -
DEVICE_REGISTRATION_NEEDED and INSUFFICIENT_DEVICE_REGISTRATION are the exact two
it writes on a real device-registration block. A page could post either, we would
append it to the same blocking_errors list and set last_blocking_error to it, and
nothing downstream - the onboarding dashboards included - could tell it from a
block the broker detected itself.

Fixed at the sink rather than at the bridge. The bridge is a generic seam and
cannot know where a code ends up; this sink knows its destination is a list shared
with broker4j, so the narrowing is its policy to make. Server-reported codes are
numeric, so ^[0-9]{1,32}$ closes the collision without rejecting anything a page
can legitimately report. (#3197 carries a matching doc note so the permissive
shape there is not read as a licence.)

Unbounded per-request growth. The bridge's caps (10 forwarded, 25 attempts) are
per INSTANCE and onPageStarted rebuilds it on every navigation, so they reset each
page load - this client's de-duplication set was the only thing spanning the
request, and nothing bounded it. A page cycling DISTINCT codes across navigations
could grow the uploaded blob without limit.

Capped at 10 distinct codes per request. Duplicates never counted anyway, and a
real flow reports one or two, so reaching this needs a page deliberately cycling
values. Checked AFTER the atomic add() rather than before, so the check-and-claim
stays one operation and the earlier check-then-act gap is not reintroduced; the
code is removed again when it does not fit. Returns consumed, so a capped code
does not burn the bridge's retry budget on something that can never be recorded.
The cap warning is logged once, on the transition, so it cannot become the flood
it exists to prevent.

Revert-tested: disabling either guard fails its test -
testRecordAuthUxServerErrorCode_SymbolicCodeIsRejected and
testRecordAuthUxServerErrorCode_PerRequestCapBoundsTheBlob.

179 tests green: WebViewClient 101, bridge 45, recorder 19, payload 10, store 4.
wzhipan pushed a commit that referenced this pull request Aug 8, 2026
…ed), AB#3708195

The broker builds an onboarding telemetry recorder per request from the seed,
but the WebView that renders the interactive auth / remediation pages lives in a
separate AuthorizationActivity created by the OS from an Intent. A live recorder
cannot ride an Intent, so the WebView-side onboarding hooks were inert for
brokered flows: steps observed in the WebView (MDM enrollment, Company Portal
launch, broker install), the last loaded domain, and the Auth UX log_telemetry
error code never reached the blob the broker finalizes and returns.

Both components run in the broker :auth process, so this adds an in-process
handoff keyed by the request correlationId.

OnboardingRecorderRegistry (new)
- Rejects DiagnosticContext.UNSET_CORRELATION_ID as a key. Every thread whose
  request context was never set carries that sentinel, and the authorization
  Intent extra is populated by reading the request-context map directly rather
  than through getThreadCorrelationId(), so the raw sentinel can reach the
  registry. It is shared by definition: accepting it would let two unrelated
  requests resolve to the same recorder and merge one flow's blocking errors into
  the other's uploaded blob. Rejecting it makes the feature inert (and logs a
  warning) instead of silently mis-attributing telemetry.
- Bounded, access-ordered map (cap 16). Entries must be unregistered on terminal
  outcome, and because this is process-static state in a process that lives as
  long as the device is up, a missed unregister would otherwise leak a recorder
  permanently - the recorder object and its collected steps / blocking errors,
  not an Activity, since OnboardingTelemetryRecorder holds only the application
  context. On overflow it evicts the least-recently-used entry and logs a
  warning, bounding the leak while surfacing the underlying bug.

AuthorizationFragment
- Captures the correlation id in extractState and round-trips it through
  onSaveInstanceState. The read was already a base-class responsibility, so the
  save belongs next to it: putting it in one subclass left the other two
  (BrowserAuthorizationFragment, CurrentTaskBrowserAuthorizationFragment) still
  blanking their diagnostic context after activity recreation, and invited the
  pair to drift apart again. All three subclasses already call super, so the
  parent implementation fixes all of them with no duplication.
- This also repairs a pre-existing bug on that path: extractState previously fed
  setDiagnosticContextForNewThread(null) after a recreation, so every subsequent
  log line for the request lost its correlation id.

WebViewAuthorizationFragment
- Resolves the recorder in onCreateView from the inherited mCorrelationId and
  attaches it to the WebView client before initializeAuthUxJavaScriptApi(...).
  No-op when the request seeded no recorder, which is the MSAL-client path.

DiagnosticContext
- UNSET_CORRELATION_ID is now public, and the internal duplicate string literal
  in getThreadCorrelationId() references it. Callers that use the correlation id
  as a KEY rather than for logging have to reject the sentinel explicitly, and a
  copied "UNSET" literal in the registry would silently stop matching if this
  ever changed - reintroducing cross-request contamination with no signal.

Logger
- Notes that its own UNSET literal is a display placeholder, deliberately
  independent of the DiagnosticContext sentinel: it also stands in for a missing
  thread id and is never used as a key, so it must not be collapsed into the
  new constant.

Note this PR alone changes no behaviour for the recorder handoff:
register/unregister have no caller in this repo (both live in the paired broker
PR). Merge order: #3197 -> #3201 -> #3204 -> broker PR.

Tests
- OnboardingRecorderRegistryTest (15): keying, the UNSET sentinel (including the
  concrete two-request cross-wiring it prevents), removal / lifecycle, absent-key
  behaviour, null and empty inputs, idempotent unregister, and the leak bound.
- AuthorizationFragmentCorrelationIdTest (6, new): the fragment half of the
  handoff - extractState captures the id, the captured id resolves the registered
  recorder, it survives save/restore, and the no-recorder / missing-id / sentinel
  paths resolve to null rather than throwing. Without these, a regression in the
  correlation-id plumbing would leave every hook inert while the registry tests
  still passed.

Revert-tested: removing the sentinel guard fails with "the sentinel must never
become a key expected:<0> but was:<1>"; disabling the cap fails with
"expected:<16> but was:<100>"; switching eviction to insertion order evicts a
live in-use recorder; dropping the onSaveInstanceState round-trip fails with
"the correlation id must round-trip through the saved bundle expected:<...> but
was:<null>".

Verified end-to-end on device: built brokerHost against this change and drove a
brokered MAM/Conditional-Access flow; the finalized blob carried the WebView-
observed steps, last_loaded_domain, and blocking_errors ["530003"] from the
Auth UX bridge.

246 tests green across the onboarding / Auth UX / authorization-fragment suites.
@wzhipan
wzhipan force-pushed the copilot/pbi-3688631-log-telemetry branch from 3e0a810 to cb19149 Compare August 8, 2026 01:57
@wzhipan
wzhipan merged commit 52360dc into dev Aug 9, 2026
39 checks passed
wzhipan pushed a commit that referenced this pull request Aug 9, 2026
…3197) was squash-merged

# Conflicts:
#	common/src/main/java/com/microsoft/identity/common/internal/broker/AuthUxJavaScriptInterface.kt
#	common/src/test/java/com/microsoft/identity/common/internal/broker/AuthUxJavaScriptInterfaceTest.kt
wzhipan pushed a commit that referenced this pull request Aug 9, 2026
wzhipan pushed a commit that referenced this pull request Aug 9, 2026
…#3197 squash-merge) into the telemetry-only bridge branch
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants