Repository navigation
feat(metrics): report time to registration, time to login and passkey adoption - #304
Merged
Merged
Conversation
… adoption Adds GET /internal/metrics/funnel behind admin:read. Each block carries the count its percentiles were computed over. Passkey adoption is counted from credentials rows because registration_success fires for more than enrollment, and time to login is bracketed by user and time within the ephemeral TTL since the token carries no flow id. Closes #302
This was referenced Sep 12, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #302. Read-side half of #154: the three funnel numbers that are answerable from rows the API already writes, so they do not wait on the write-path instrumentation that issue keeps.
What changed
GET /internal/metrics/funnel?from&to, onauth: 'access'withrequireAdmin('read'), answers four blocks:{ "timeToRegistration": { "count": 3, "medianSeconds": 90, "p90Seconds": 258 }, "timeToLogin": { "count": 2, "medianSeconds": 25, "p90Seconds": 29 }, "passkeyAdoption": { "users": 5, "withPasskey": 3, "rate": 0.6 }, "timeToFirstPasskey": { "count": 3, "medianSeconds": 86400, "p90Seconds": 86400 } }Every block carries the count its percentiles were computed over, so a median of three readings is not read as one of three thousand. Percentiles are
nullwhen the count is zero, neverNaN. The window applies to where each reading starts (theuser_createdevent, thelogin_success, the account row), not where it completes, so a registration begun on the last day of the window and finished the next morning still counts. With no window it covers all time.The queries live in
src/services/funnelMetrics.tsas raw SQL withpercentile_cont, bound through Sequelize named replacements. Nothing from the request reaches the SQL text:fromandtoarrive asDatereplacements and the sign-in type list is theSIGN_IN_SUCCESS_TYPESconstant.Three decisions worth knowing
credentialsrows, not events.registration_successfires on new-user registration, phone registration, magic link completion and passkey enrollment alike, and there has been nowebauthn_registration_successtype since webauthn_registration_success is logged when options are generated, before anything is registered #160. The cohort isusers.created_at, so administrator-created and OAuth accounts count in the denominator, which is the question an operator is asking.user_createdevent, notusers.created_at. Only self-serve registration emits it. Accounts an administrator or an OAuth sign-up creates never enter the registration funnel, so they would only drag the distribution.jtiandsession_idis null before a session exists. An attempt is the first completed sign-in after alogin_successfor the same user, within the five minute ephemeral TTL and before that user's nextlogin_success(LEAD()over the attempts). A retried attempt counts once, against thelogin_successit completed, and a sign-in after the token expired is not attributed at all. OAuth is excluded:oauth_login_startedcarries no user id, so it cannot be bracketed the same way. Funnel metrics: time to registration, passkey adoption, time to login #302 said otherwise; that was wrong and the issue text should be read with this correction.Two deviations from the issue as written: the route is
/internal/metrics/funnelrather than under/auth-events/, since adoption comes fromusersandcredentials, and OAuth attempts are out for the reason above.Verification
login_success(the decoy path), averify_otp_successlogged twice for one sign-in, an administrator-created account, and a 30 day old account. Every figure matched what I computed by hand, both all-time and with a 7 day window.typecheck,lint,format:check,buildcleantest:run: 115 files, 1382 passing (7 new service tests with a fakedsequelize.query, 8 new route tests)openapi.jsonandsrc/generated/api.tsregeneratedminorchangesetDownstream
This is a contract change (new route). The adapter passthrough is fells-code/seamless-auth-server#159 and the Overview section that reads it is fells-code/seamless-auth-admin-dashboard#256. Merge order is this, then the adapter release, then the dashboard.
Not in this PR:
auth_eventshas no index onuser_idor(type, created_at), which these queries and the per-user event list would both use. That is being handled separately.