Repository navigation
feat(metrics): record deployment, device class, mail provider, owner and attempt on every auth event - #306
Merged
Conversation
…and attempt on every auth event None of these can be backfilled: the mail provider is taken from an address the row does not store, the device class from a user agent that only means something once the adapter forwards the browser's, and the attempt id from a token claim that did not exist. Each is recorded at write time for that reason, before the Seamless Idea fleet starts producing sign-ins. deployment_id is APP_ID, the same value as sessions.infraId. device_class folds the user agent into a platform family, since platform is where passkeys differ. mail_provider folds the subject's address into a consumer provider or "other", never the domain, so the rows can be published. owner records whether the subject is in OWNER_EMAIL, which is what makes "did somebody other than the owner sign in" a query. attempt_id is the ephemeral token's new jti: /login and /registration/register mint it, write it on the row that starts the attempt, and every step taken on the token carries it. user_agent widens to text at the same time, since a real browser user agent could exceed the old column and fail the whole audit write. Every OTP send is now one otp_success row with metadata.channel, including the send registration makes, which used to write none. A send the provider refuses is a new otp_failed event, distinguished from a server fault by a typed DeliveryError. The adapter can forward the browser's user agent as x-seamless-client-user-agent, honoured under the same service-token rule as x-seamless-client-ip. Without it every row records the adapter's own user agent and the device class breakdown reads "unknown". GET /internal/metrics/sign-ins answers how many attempts succeeded and failed per method, device class, mail provider and owner flag, plus where attempts stop. It counts a method presented within an attempt rather than an event, so a completed OTP sign-in, which logs verify_otp_success twice, counts once. docs/telemetry.md records how each dimension is derived, the fleet-wide form of the query, and the publishability and retention decision. Closes #154
This was referenced Sep 12, 2026
Contributor
Author
|
Ripple, in merge order:
Until 3 is deployed in front of a deployment, every row it writes has |
Bccorb
added a commit
to fells-code/seamless-auth-types
that referenced
this pull request
Sep 12, 2026
…h event (#66) seamless-auth-api now writes deployment_id, device_class, mail_provider, owner and attempt_id on every auth_events row. AuthEventSchema is a plain object schema, so it stripped them from GET /admin/auth-events and a dashboard could not show which device or attempt an event came from. All five are optional and nullable, so an event written before they existed still parses. The two classifications stay plain strings rather than enums for the same reason type does: a newer server may add a class an older consumer has not heard of. Pairs with fells-code/seamless-auth-api#306. Co-authored-by: Brandon Corbett <Bccorb@users.noreply.github.com>
Bccorb
added a commit
to fells-code/seamless-auth-server
that referenced
this pull request
Sep 12, 2026
…ics route through The auth API now records a device class on every audit row, folded from the request user agent, and reports sign-in outcomes by it. The adapter is the only client it sees, so every row carried the adapter's own user agent and the breakdown read "unknown" for every sign-in. Both adapters now send the browser's User-Agent as x-seamless-client-user-agent beside x-seamless-client-ip on every proxied call, trimmed and capped at 512 characters. The API honours it under the same service-token rule as the address. GET /internal/metrics/sign-ins is proxied on an access session the way the funnel route is, with the window forwarded and pinned by the query forwarding tables and the parity cases. Depends on fells-code/seamless-auth-api#306.
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 #154. The write-path half: the dimensions that cannot be backfilled, recorded on every
auth_eventsrow before the Seamless Idea fleet starts producing sign-ins, and the one query the issue asks for. The read-side numbers already answerable from existing rows shipped as #304.What changed
Five nullable columns on
auth_events, stamped byAuthEventServiceon every row:deployment_idAPP_IDsessions.infraId.device_classios,android,macos,windows,linux,chromeos,bot,unknownmail_providerother, or nullownertrue,false, or nullOWNER_EMAIL. Null when the subject is unknown.attempt_idjti.user_agentwidens totextin the same migration. It wasvarchar(255), never hit while the adapter wrote its own short user agent, and would have failed the whole audit write for a long browser one.Attempt id.
/loginand/registration/registermint a UUID, write it on the row that starts the attempt, and sign the ephemeral token with it asjti.verifyBearerAuthreads it back ontoreq.attemptId,AuthEventService.logdefaults to it, and OTP resends and decoy re-mints pass the one they received so the attempt stays whole. This is the optional item from the scoping comment; it is what makes drop-off per step and time to login attributable exactly rather than by adjacency.Deliverability. Every OTP send is now one
otp_successrow withmetadata.channel(emailorsms), including the send registration makes, which used to write none. A send the provider refuses is a newotp_failedevent withreason: 'Delivery failed', told apart from a server fault by a typedDeliveryErrorthrown frommessagingService.otp_failedis not inLOCKOUT_FAILURE_TYPES, so lockout is unchanged.Forwarded user agent. The adapter is the only client this API sees, so every row records the adapter's user agent. A new
x-seamless-client-user-agentheader is honoured under the same service-token rule asx-seamless-client-ipand replaces the request header in place, so the audit row, the session record and the magic link device binding all see the browser's. The middleware is renamedapplyTrustedClientContext.The query.
GET /internal/metrics/sign-ins?from&to, behindadmin:readlike the funnel endpoint:{ "deploymentId": "a1b2c3", "attempts": { "started": 412, "delivered": 130, "presented": 388, "completed": 371 }, "signIns": { "success": 371, "failed": 21, "successRate": 0.946 }, "breakdown": [ { "method": "passkey", "deviceClass": "ios", "mailProvider": "gmail", "owner": false, "success": 202, "failed": 4 } ] }The unit is a method presented within an attempt, not an event, so a completed OTP sign-in (which logs
verify_otp_successtwice) counts once and a mistyped-then-correct code is one success. Rows with no attempt id stand as their own attempt, so older rows still count once each.breakdownis one flat row per combination; consumers pivot.Decisions worth knowing
botkeeps scanners out of a published rate. A server runtime such asnodeisunknown, notbot, because that is what an adapter that does not forward the user agent looks like and those are real people.other; telling them apart takes an MX lookup on the send path.subjectEmailis an input to two classifiers and nothing else. It defaults fromreq.useronly when the principal is the subject, never for a decoy or an administrator acting on another account, so those rows carry null.docs/telemetry.md: aggregates are what leave a deployment, never rows; cells of one are suppressed; the dimensions follow whateverauth_eventsretention becomes (Audit events have no retention policy and no bulk export #173). The fleet-wide form of the query, withCOUNT(DISTINCT deployment_id)andowner = false, is there too.Verification
getSignInMetricsand a realAuthEventService.logwrite (a 442 character user agent, owner derivation, every dimension) against it. A hand-seeded fixture covering a failed-then-successful OTP inside one attempt, a double-logged success, an owner passkey failure, an abandoned attempt and legacy rows with no attempt id produced the counts computed by hand.typecheck,lint,format:check,build,check:changeset-bumpcleantest:run: 118 files, 1464 passing (new unit tests for both classifiers and the metrics service, extended tests for the event service, token, bearer middleware, trusted-context middleware, OTP and registration controllers, login and the new route)/security-reviewfound nothing meeting its bar. It did catch that an address atconstructor.ioreachedObject.prototypethrough an object-literal lookup and would have dropped that subject's audit rows; the tables areMaps now, with a regression test.openapi.jsonandsrc/generated/api.tsregenerated;minorchangesetDownstream
Contract change: a new route, a new request header, a new event type, a new token claim (additive), and new columns on the auth event row.
AuthEventSchemain@seamless-auth/typesis a plainz.object, so the five columns are stripped fromGET /admin/auth-eventsuntil the shared schema carries them. The adapter does not forward the user agent yet, so every adapter-fronted deployment recordsdevice_class = 'unknown'until it does. Ripple issues are linked from the comments.