Phase 9e.A — Auth-pipeline `IMetricsSink` DI registration (consumer migration)
Phase 9e.A — Auth-pipeline IMetricsSink DI registration (consumer migration)
What changes. The OIDC and Entra External ID auth providers now bind their IMetricsSink via construction rather than via a process-wide setter. The prior OidcAuthProvider.setMetricsSink / EntraExternalIdAuthProvider.setMetricsSink setters (introduced under Cluster D1 alongside the AuthMetrics counter names) and their backing let mutable private metricsSink: IMetricsSink option = None cells are gone. Each provider instance carries its own sink in its closure; there is no module-level state to leak between deployments, tests, or hot-reload cycles.
Scope. Server-side only — ToolUp.AuthProviders.Oidc and ToolUp.AuthProviders.EntraExternalId. No client-side change. No on-the-wire change. No change to AuthMetrics counter names or provider= tag values.
Backward compatibility. Every existing public function signature is preserved:
OidcAuthProvider.fromConfig— unchanged.OidcAuthProvider.fromConfigWith— unchanged.EntraExternalIdAuthProvider.create— unchanged.EntraExternalIdAuthProvider.createWith— unchanged.EntraExternalIdAuthProvider.fromEnv— unchanged.AuthProvider.fromEnv(inToolUp.Platform.Server) — unchanged.OidcAuthBuildertype alias — unchanged.
Consumers that never wired metrics (the entire workspace at the time of writing — setMetricsSink had no live call sites) compile and run identically. No record literal grows a field, no callback signature shifts, no opt-in is needed.
What was added. Four new public functions on the providers plus one new function + type alias in the env-driven dispatcher:
| New surface | Module | Purpose |
|---|---|---|
OidcAuthProvider.fromConfigMetered |
ToolUp.AuthProviders.Oidc |
Production shorthand with IMetricsSink option parameter; uses the lazy default HttpClient. |
OidcAuthProvider.fromConfigWithMetrics |
ToolUp.AuthProviders.Oidc |
Custom-HttpClient variant with IMetricsSink option parameter. Tests use this with stub handlers. |
EntraExternalIdAuthProvider.createMetered |
ToolUp.AuthProviders.EntraExternalId |
Production shorthand with metrics. |
EntraExternalIdAuthProvider.createWithMetrics |
ToolUp.AuthProviders.EntraExternalId |
Custom-HttpClient variant with metrics. |
EntraExternalIdAuthProvider.fromEnvMetered |
ToolUp.AuthProviders.EntraExternalId |
Env-driven variant with metrics. |
OidcAuthBuilderMetered |
ToolUp.Platform.AuthProvider |
Type alias: ILogger option -> IMetricsSink option -> AuthConfig -> IAuthProvider. |
AuthProvider.fromEnvMetered |
ToolUp.Platform.AuthProvider |
Env-driven dispatcher that threads IMetricsSink option through to the OIDC builder. |
What was removed.
OidcAuthProvider.setMetricsSink: IMetricsSink -> unit.EntraExternalIdAuthProvider.setMetricsSink: IMetricsSink -> unit.- The backing
let mutable private metricsSink: IMetricsSink option = Nonecell in both modules.
Consumers calling setMetricsSink will see a compile error. The fix is to migrate to the *Metered / *WithMetrics constructors below; no workspace consumer was on this path at migration time.
When to opt in
If your deployment registers a real IMetricsSink (e.g. the Prometheus sink shipped by default when ServerConfig.MetricsEndpoint = EnabledMetricsEndpoint, or the OtelMetricsSink companion), wiring the auth provider through the metered constructors lights up the standard toolup.auth.validate.* counters on the same observability backend that already carries toolup.requests.* / toolup.errors.* / toolup.sse.*.
If your deployment leaves metrics disabled (NoMetricsEndpoint, the default), there is nothing to do — fromConfig / create / fromEnv continue to elide emission. The NoOpMetricsSink registered by the SDK is what they bind internally when no sink is supplied.
Diff to apply
Pattern 1 — direct construction with a resolved sink
If your composition root resolves the SDK's IMetricsSink from DI (via ServiceProvider.GetService<IMetricsSink>() or equivalent) and you want the OIDC / Entra provider to emit counters against it:
// Before (no metrics on the auth pipeline):
let auth =
OidcAuthProvider.fromConfig (Some logger) authConfig
ServerApp.empty
|> ServerApp.withAuth auth
|> ...
// After (auth-pipeline counters emit via the resolved sink):
let metrics: IMetricsSink option =
services.GetService<IMetricsSink>() |> Option.ofObj
let auth =
OidcAuthProvider.fromConfigMetered (Some logger) metrics authConfig
ServerApp.empty
|> ServerApp.withAuth auth
|> ...
fromConfigMetered with metrics = None behaves identically to fromConfig — useful for composition roots that conditionally resolve metrics and want a single construction path.
Pattern 2 — env-driven dispatch via AuthProvider.fromEnv
If your composition root uses the env-var-driven helper from Phase 11.G:
// Before (no metrics):
let auth =
AuthProvider.fromEnv logger ToolUp.AuthProviders.OidcAuthProvider.fromConfig
ServerApp.empty
|> ServerApp.withAuth auth
|> ...
// After (env-driven dispatch with metrics threaded):
let metrics: IMetricsSink option =
services.GetService<IMetricsSink>() |> Option.ofObj
let auth =
AuthProvider.fromEnvMetered
logger
metrics
ToolUp.AuthProviders.OidcAuthProvider.fromConfigMetered
ServerApp.empty
|> ServerApp.withAuth auth
|> ...
fromEnvMetered shares all dispatch behaviour with fromEnv — HeaderAuthProvider fallback, unrecognised-mode warning text, missing-issuer warning text. Metrics only flow when TOOLUP_AUTH_MODE=oidc resolves to the OIDC branch.
Pattern 3 — Entra External ID
Mirrors Pattern 1, on the Entra companion:
// Before:
let auth = EntraExternalIdAuthProvider.create (Some logger) config
// After:
let metrics = services.GetService<IMetricsSink>() |> Option.ofObj
let auth = EntraExternalIdAuthProvider.createMetered (Some logger) metrics config
For env-driven Entra wiring:
// Before:
let auth =
EntraExternalIdAuthProvider.fromEnv (Some logger)
|> Option.defaultWith (fun () -> failwith "TOOLUP_ENTRA_EXTERNAL_ID_TENANT not set")
// After:
let metrics = services.GetService<IMetricsSink>() |> Option.ofObj
let auth =
EntraExternalIdAuthProvider.fromEnvMetered (Some logger) metrics
|> Option.defaultWith (fun () -> failwith "TOOLUP_ENTRA_EXTERNAL_ID_TENANT not set")
Pattern 4 — retiring setMetricsSink
If any consumer code reaches into the provider modules via the now-removed setter, replace at the construction site:
// Before — compile error after upgrade:
let auth = OidcAuthProvider.fromConfig (Some logger) authConfig
OidcAuthProvider.setMetricsSink resolvedSink // ← gone
// After:
let auth =
OidcAuthProvider.fromConfigMetered (Some logger) (Some resolvedSink) authConfig
No deployment in the workspace was on this path — the setter pattern was newly introduced under Cluster D1 and never wired by a real compose path. The migration is mechanical for any out-of-tree consumer that anticipated it.
Verification
dotnet buildagainst your composition root project — record the newIMetricsSinkresolve in DI is reachable and the metered constructor receives it.- If you have a metrics scrape (
/metricsendpoint, OTel exporter), confirm a successful authenticated request incrementstoolup.auth.validate.success_total{provider="oidc"}(orprovider="entra-external-id"). - A token-less request increments
toolup.auth.validate.no_token_total. - An expired token increments
toolup.auth.validate.expired_total. - The provider's per-instance binding is verifiable in tests — see
AuthProviderTests.fs"OidcAuthProvider — metrics emission" which asserts two provider instances bind independent sinks (no cross-instance leakage).
Rollback
If a regression surfaces, the legacy constructors are still in place; reverting the call site from fromConfigMetered back to fromConfig (and dropping the metrics argument) restores the no-emission behaviour without any other change. No record migration, no env-var change, no DB shape change.