Skip to content

feat(metrics): report time to registration, time to login and passkey adoption - #304

Merged
Bccorb merged 1 commit into
mainfrom
feat/funnel-metrics
Sep 12, 2026
Merged

Bccorb merged 1 commit into
mainfrom
feat/funnel-metrics

Conversation

@Bccorb

@Bccorb Bccorb commented Sep 12, 2026 •

Copy link
Copy Markdown
Contributor

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, on auth: 'access' with requireAdmin('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 null when the count is zero, never NaN. The window applies to where each reading starts (the user_created event, the login_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.ts as raw SQL with percentile_cont, bound through Sequelize named replacements. Nothing from the request reaches the SQL text: from and to arrive as Date replacements and the sign-in type list is the SIGN_IN_SUCCESS_TYPES constant.

Three decisions worth knowing

  • Passkey adoption is counted from credentials rows, not events. registration_success fires on new-user registration, phone registration, magic link completion and passkey enrollment alike, and there has been no webauthn_registration_success type since webauthn_registration_success is logged when options are generated, before anything is registered #160. The cohort is users.created_at, so administrator-created and OAuth accounts count in the denominator, which is the question an operator is asking.
  • Time to registration is anchored on the user_created event, not users.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.
  • Time to login is bracketed by user and time, because there is no flow id. The ephemeral token carries no jti and session_id is null before a session exists. An attempt is the first completed sign-in after a login_success for the same user, within the five minute ephemeral TTL and before that user's next login_success (LEAD() over the attempts). A retried attempt counts once, against the login_success it completed, and a sign-in after the token expired is not attributed at all. OAuth is excluded: oauth_login_started carries 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/funnel rather than under /auth-events/, since adoption comes from users and credentials, and OAuth attempts are out for the reason above.

Verification

  • The SQL was run against a real Postgres 16 with the migrations applied and a hand-seeded fixture covering a retried attempt, an abandoned attempt, a completion outside the TTL, a null-user login_success (the decoy path), a verify_otp_success logged 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, build clean
  • test:run: 115 files, 1382 passing (7 new service tests with a faked sequelize.query, 8 new route tests)
  • openapi.json and src/generated/api.ts regenerated
  • minor changeset

Downstream

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_events has no index on user_id or (type, created_at), which these queries and the per-user event list would both use. That is being handled separately.

… 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
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.

Funnel metrics: time to registration, passkey adoption, time to login

1 participant