Skip to content

fix(mobile): HttpClient instrumentation via DelegatingHandler for mobile↔API correlation#172

Merged
davidortinau merged 2 commits into
mainfrom
squad/mobile-activity-handler
Apr 22, 2026
Merged

fix(mobile): HttpClient instrumentation via DelegatingHandler for mobile↔API correlation#172
davidortinau merged 2 commits into
mainfrom
squad/mobile-activity-handler

Conversation

@davidortinau

Copy link
Copy Markdown
Owner

What

Restores mobile↔API correlation in Application Insights by wrapping every API-bound mobile HttpClient with a manual ApiActivityHandler DelegatingHandler that starts a Client Activity per outbound request. With Activity.Current non-null, HttpClient auto-injects the W3C traceparent header, which propagates operation_Id from mobile through the server request.

Why

PR #165 (mobile App Insights bootstrap) + PR #166 (server companion) shipped the end-to-end telemetry plumbing, but the requests | join traces on operation_Id correlation query returned zero rows. Every server request showed operation_Id == operation_ParentId, meaning no traceparent was arriving from the device.

Two incorrect diagnoses during this investigation (see session log), finally landing on:

  • AddHttpClientInstrumentation() was already wired (since commit 216a2da, March 4) — my first "fix" would have been a no-op.
  • Trimming is not the cause — a Release build with <MtouchLink>None</MtouchLink> (136 MB, 333 DLLs) on DX24 produced the same 0-dependency result.
  • Root cause (tracked in MAUI OTel: force TracerProvider materialization since IHostedService doesn't run #171): MAUI's MauiApp doesn't run IHostedService instances, so TelemetryHostedService — which normally materializes the TracerProvider and attaches its listeners — never runs. Logs still work (they hook ILoggerFactory synchronously), but auto HTTP instrumentation does not.

How

  • New: ApiActivityHandler — dedicated ActivitySource (SentenceStudio.Mobile.HttpClient), Client kind, OTel-conformant tags (http.request.method, url.full, server.address, http.response.status_code), error status on exception + >=400, proper Activity disposal.
  • MauiServiceDefaults.Extensions:
    • .AddSource("SentenceStudio.Mobile.HttpClient") on the TracerProvider so the new spans actually export.
    • GetRequiredService<T>() instead of nullable GetService<T>() for all three providers so silent misregistration becomes a loud startup failure.
  • ServiceCollectionExtentions + SentenceStudioAppBuilder — handler placed FIRST in each chain (ahead of AuthenticatedHttpMessageHandler) so the span covers the full operation including auth token attachment. Registered via TryAddTransient<>() so the several entry points don't step on each other.

Verification

  • Mac Catalyst Debug: 0 Error(s) / 571 warnings ✅
  • Mac Catalyst Release: 0 Error(s) / 689 warnings ✅
  • Post-merge: iOS Release publish to DX24 + KQL query confirming non-empty operation_ParentId on server requests — Captain's call on timing.

Out of scope

Risk

  • Low: handler returns early if Source.StartActivity(...) yields null (no listener attached), so behavior is identical to the current code path on any surface where the source isn't registered.
  • Medium: GetRequiredService change converts a silent failure into a startup crash if providers aren't registered. This is intentional — today's silent failure is the reason we're here.

Follow-ups

…tion

PR #165 (mobile) + PR #166 (server) shipped the App Insights pipeline end
to end, but the mobile↔API correlation join in App Insights was returning
zero rows. Every server request had `operation_Id == operation_ParentId`
— i.e., no `traceparent` header was arriving from the device.

Diagnosis (see #171):

- `OpenTelemetry.Instrumentation.Http`'s `AddHttpClientInstrumentation()`
  was already on the MAUI TracerProvider since commit 216a2da and a
  trim-disabled Release build on DX24 produced the same zero-span result,
  so neither registration nor trimming was the problem.
- Mobile logs had empty `operation_Id` across the board, confirming no
  ambient `Activity` ever existed on the device.
- Root cause (tracked in #171): MAUI's `MauiApp` doesn't run
  `IHostedService` instances, so the `TelemetryHostedService` that would
  normally materialize the TracerProvider and attach its listeners never
  runs. Logs work because they hook `ILoggerFactory` synchronously; the
  tracer path needs the hosted-service startup.

This PR:

- Adds `ApiActivityHandler`, a `DelegatingHandler` that starts a
  `Client` Activity per outbound API call using a dedicated
  `ActivitySource` (`SentenceStudio.Mobile.HttpClient`). With an Activity
  current, HttpClient's built-in `DiagnosticsHandler` auto-injects the
  W3C `traceparent` header.
- Registers the new ActivitySource on the mobile TracerProvider via
  `.AddSource(...)` in `MauiServiceDefaults.Extensions` so the spans
  actually export.
- Wires the handler onto every API-bound HttpClient: CoreSync's
  `HttpClientToServer`, the auth client, the four typed API clients,
  and `VersionCheckService`. The handler is placed FIRST in the chain
  so the span wraps the full operation including auth token attachment.
- Hardens `OpenTelemetryInitializer` to call `GetRequiredService<T>()`
  instead of the nullable `GetService<T>()` for all three providers, so
  a misregistration fails loudly at startup instead of silently breaking
  telemetry at runtime.

Out of scope (explicitly):

- Root-cause fix for the IHostedService gap — tracked in #171.
- The raw `new HttpClient()` in `SentenceStudio.Shared/Services/AiService.cs:93`
  — bypasses `HttpClientFactory` entirely. Separate refactor.
- The KQL in `docs/deploy-runbook.md` is still wrong (joins requests to
  requests; should be dependencies to requests). Separate doc PR.

Verification: Mac Catalyst Debug + Release both build clean.
Post-merge verification will be an iOS publish to DX24 + KQL query for
non-empty `operation_ParentId` on server requests.
@davidortinau davidortinau force-pushed the squad/mobile-activity-handler branch from 4514a2f to 20b6b77 Compare April 22, 2026 03:14
…recording

Code review feedback on #172: exceptions should be recorded as Activity
events (via AddException/RecordException), not raw tags. Emits the
standard OTel 'exception' event with type/message/stacktrace, which
surfaces in App Insights' exception timeline rather than being tag-only.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@davidortinau davidortinau merged commit 8045e86 into main Apr 22, 2026
2 of 6 checks passed
@davidortinau davidortinau deleted the squad/mobile-activity-handler branch April 22, 2026 14:05
davidortinau added a commit that referenced this pull request Apr 22, 2026
…tion (#173)

PR #172 got mobile HttpClient dependency spans emitting with operation_Id,
but the correlation join against API requests still returned zero rows:
the API saw every incoming request without a traceparent header and started
a fresh operation_Id.

Root cause: HttpClient's built-in DiagnosticsHandler only injects traceparent
automatically when an OTel-style ActivityListener is attached to
"System.Net.Http". On MAUI the listener never attaches because OpenTelemetry's
TelemetryHostedService — which wires listeners to the TracerProvider — relies
on IHostedService, and MauiApp doesn't run hosted services (issue #171).

Fix: have ApiActivityHandler explicitly call
DistributedContextPropagator.Current.Inject(...) on the outbound request
headers after starting its Activity. Guards against double-injection if a
caller or a resilience retry already set traceparent.

This is the user-space workaround to #171. Framework fix is still desirable
but now lower priority.

Verification plan: re-run the App Insights correlation join; expect
requests | join dependencies on operation_Id to return > 0 rows for the
mobile role name.

Refs: #165 #166 #172 #171
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.

1 participant