FerrisKeyDocs

Querying and analytics

Compass keeps every traced authentication flow in PostgreSQL for 30 days and serves it through four read-only endpoints. Use them to debug a failed sign-in, watch authentication latency, and see how a realm’s traffic changes day by day.

All four need view_events, manage_events or manage_realm. Paths are written without a root path, the server default. If SERVER_ROOT_PATH is set, every path gains that prefix.

EndpointReturns
GET /realms/{realm_name}/compass/v1/statsCounts and average duration for the whole realm
GET /realms/{realm_name}/compass/v1/flowsOne page of flows with their steps
GET /realms/{realm_name}/compass/v1/flows/{flow_id}One flow with its steps
GET /realms/{realm_name}/compass/v1/activity/dailyPer-day counts for a date window

Flow statistics

The stats endpoint returns aggregate figures for the realm at a glance:

{
  "data": {
    "total": 15234,
    "success_count": 14102,
    "failure_count": 987,
    "pending_count": 145,
    "avg_duration_ms": 142.5
  }
}
FieldDescription
totalEvery recorded flow, including the expired ones
success_countFlows with status success
failure_countFlows with status failure
pending_countFlows still pending. Abandoned flows leave this count after 12 hours, when they become expired
avg_duration_msAverage duration_ms over the flows that have one. Null when none does

There is no expired count, so total can exceed the sum of the other three. Remember that duration_ms is the duration of a flow’s closing step, not the time the user spent signing in. A rising failure_count or avg_duration_ms still tells you something changed in the pipeline.

Listing flows

GET /realms/{realm_name}/compass/v1/flows

The listing follows the shared contract described in Paginated listings: page, limit, order_by, order, filters, and a {data, metadata} response. Each flow comes with its steps, sorted by started_at.

ParameterTypeDescription
pageinteger, 1 or morePage number. Defaults to 1
limitinteger, 1 to 100Page size. Defaults to 20
order_bystatus, started_at, duration_ms or created_atSort column. Defaults to created_at
orderasc or descSort direction. Defaults to desc
client_idstringThe OAuth client_id of the client, exact match
user_idUUIDFlows closed for this user, exact match
grant_typestringExact match, for example authorization_code or password
statuspending, success, failure or expiredExact match
ip_addressstringCase-insensitive match anywhere in the address. Flows without an address never match
searchstringSame match as ip_address
identifiedbooleantrue keeps flows tied to a user, false keeps the anonymous ones
completedbooleantrue keeps flows that have a completed_at, false the unfinished ones
fromRFC 3339 date-timeFlows that started at or after this instant, on started_at
toRFC 3339 date-timeFlows that started at or before this instant, on started_at

Filters combine with AND, and a filter sent with an empty value is ignored. Sorting on duration_ms puts unfinished flows last in ascending order and first in descending order.

Any other parameter is refused with 400 invalid_query naming it. That includes offset, which this endpoint does not have: use page. It also includes from_timestamp, to_timestamp, from_date and to_date: the window parameters of this endpoint are from and to.

A flow gets its user_id when it closes. A wrong password or code on the login page leaves the flow pending with no user, so these attempts do not appear under user_id and are not counted as failure. Find them with status=pending and look for a failed step, as in the queries below.

Example queries

Flows that started in a window and closed with a failure:

curl -G "http://localhost:3333/realms/my-app/compass/v1/flows" \
  --data-urlencode "status=failure" \
  --data-urlencode "from=2026-10-30T00:00:00Z" \
  --data-urlencode "limit=50" \
  -H "Authorization: Bearer $ADMIN_TOKEN"

Sign-ins that never closed, oldest first, to see where they stopped:

curl -G "http://localhost:3333/realms/my-app/compass/v1/flows" \
  --data-urlencode "status=pending" \
  --data-urlencode "completed=false" \
  --data-urlencode "order_by=started_at" \
  --data-urlencode "order=asc" \
  -H "Authorization: Bearer $ADMIN_TOKEN"

Password grant flows of one client:

curl -G "http://localhost:3333/realms/my-app/compass/v1/flows" \
  --data-urlencode "client_id=my-frontend" \
  --data-urlencode "grant_type=password" \
  --data-urlencode "limit=10" \
  -H "Authorization: Bearer $ADMIN_TOKEN"

The slowest flows first:

curl -G "http://localhost:3333/realms/my-app/compass/v1/flows" \
  --data-urlencode "order_by=duration_ms" \
  --data-urlencode "order=desc" \
  -H "Authorization: Bearer $ADMIN_TOKEN"

Flow detail

Fetch one flow by id to see all its steps:

{
  "data": {
    "id": "01914b3c-7e8a-7f5a-b456-789012345678",
    "realm_id": "01914b3c-1234-7f5a-b456-000000000001",
    "client_id": "my-frontend",
    "user_id": null,
    "grant_type": "authorization_code",
    "status": "pending",
    "ip_address": "203.0.113.42",
    "user_agent": "Mozilla/5.0 ...",
    "started_at": "2026-10-30T14:30:00.000000Z",
    "completed_at": null,
    "duration_ms": null,
    "steps": [
      {
        "id": "01914b3c-7e8a-7f5a-b456-789012345001",
        "flow_id": "01914b3c-7e8a-7f5a-b456-789012345678",
        "step_name": "authorize",
        "status": "success",
        "duration_ms": null,
        "error_code": null,
        "error_message": null,
        "details": null,
        "started_at": "2026-10-30T14:30:00.004000Z"
      },
      {
        "id": "01914b3c-7e8a-7f5a-b456-789012345002",
        "flow_id": "01914b3c-7e8a-7f5a-b456-789012345678",
        "step_name": "credential_validation",
        "status": "failure",
        "duration_ms": 85,
        "error_code": "InvalidPassword",
        "error_message": null,
        "details": null,
        "started_at": "2026-10-30T14:30:07.511000Z"
      }
    ]
  }
}

The story is complete: the authorization request was accepted, then the password was wrong after 85 ms of verification. The flow is still pending because the user is on the login page and can try again.

Daily activity

GET /realms/{realm_name}/compass/v1/activity/daily

This endpoint aggregates per day and is the one that takes a date window.

ParameterTypeDescription
fromdate, YYYY-MM-DDFirst day, inclusive. Defaults to 29 days before to, which makes a 30-day window
todate, YYYY-MM-DDLast day, inclusive. Defaults to the current UTC day
client_idstringRestrict the flow counts to one client
user_idUUIDRestrict the flow counts to one account
grant_typestringRestrict the flow counts to one grant type

A malformed date, a from after to, or a window longer than 366 days returns 400. Unlike the flow listing, this endpoint ignores parameters it does not know, so a misspelled from_date silently falls back to the default window.

{
  "data": [
    {
      "date": "2026-10-28",
      "signups": 12,
      "logins": 431,
      "login_failures": 7,
      "pending_logins": 3,
      "expired_logins": 9,
      "total_flows": 450,
      "unique_login_users": 212,
      "avg_login_duration_ms": 38.4
    },
    {
      "date": "2026-10-29",
      "signups": 9,
      "logins": 402,
      "login_failures": 5,
      "pending_logins": 2,
      "expired_logins": 11,
      "total_flows": 420,
      "unique_login_users": 198,
      "avg_login_duration_ms": 36.9
    },
    {
      "date": "2026-10-30",
      "signups": 0,
      "logins": 0,
      "login_failures": 0,
      "pending_logins": 0,
      "expired_logins": 0,
      "total_flows": 0,
      "unique_login_users": 0,
      "avg_login_duration_ms": null
    }
  ]
}

Every day of the window gets a row, with zeros where nothing happened. Days are UTC days, taken from started_at.

FieldDescription
signupsAccounts created that day in the realm. The filters do not apply to it
loginsFlows with status success
login_failuresFlows with status failure
pending_loginsFlows still pending
expired_loginsFlows with status expired
total_flowsAll flows that started that day
unique_login_usersDistinct users among the successful flows
avg_login_duration_msAverage duration_ms of the successful flows. Null when there are none

Queries people actually run

Authentication latency

Poll the stats endpoint on a schedule and graph avg_duration_ms:

curl -s "http://localhost:3333/realms/my-app/compass/v1/stats" \
  -H "Authorization: Bearer $ADMIN_TOKEN" | jq '.data | {total, failure_count, avg_duration_ms}'

Failed steps and their errors

Because a wrong password leaves its flow pending, count failed steps rather than failed flows. This groups the failed steps of recent pending flows by error code:

curl -s -G "http://localhost:3333/realms/my-app/compass/v1/flows" \
  --data-urlencode "status=pending" \
  --data-urlencode "limit=100" \
  -H "Authorization: Bearer $ADMIN_TOKEN" | \
  jq '[.data[].steps[] | select(.status == "failure") | .error_code]
      | group_by(.) | map({code: .[0], count: length}) | sort_by(-.count)'

The codes you will meet:

error_codeStepMeaning
InvalidPasswordcredential_validationWrong password
UserDisabledcredential_validationThe account is disabled
AccountLockedcredential_validationThe account is locked after repeated failures
invalid_otp_codemfa_challengeWrong one-time code

Failures at the token step carry the name of the internal error in the same way, and the SAML and token exchange steps carry their own error codes.

Finding slow sign-ins

Find successful flows where credential_validation, which covers the user lookup and the password hash check, took more than 500 ms:

curl -s -G "http://localhost:3333/realms/my-app/compass/v1/flows" \
  --data-urlencode "status=success" \
  --data-urlencode "limit=100" \
  -H "Authorization: Bearer $ADMIN_TOKEN" | \
  jq '.data[] | select(.steps[] | select(.step_name == "credential_validation" and .duration_ms > 500))'

Identity provider performance

When you sign in through an external provider with Abyss, the idp_callback step is the one that waits on the provider’s token endpoint, so its duration_ms is the provider’s response time. A failure carries the provider’s own error and error_description in error_message. Filter on the broker grant type to see only these flows, for example grant_type=broker_github for a provider with the alias github.

A user’s history

curl -G "http://localhost:3333/realms/my-app/compass/v1/flows" \
  --data-urlencode "user_id=USER_UUID" \
  --data-urlencode "limit=20" \
  -H "Authorization: Bearer $ADMIN_TOKEN"

This lists the user’s closed flows, which is useful for a support ticket (“when did I last sign in, and from where?”). It does not list their failed password attempts, because those flows are still anonymous. For those, match on ip_address or read the pending flows.

Keeping flows longer

Compass deletes flows 30 days after they started, and the window is not configurable. To keep a longer history, read flows out before they expire: page through GET .../flows with order_by=started_at, order=asc and a from that moves forward, and store them in your own system.

Compass and SeaWatch

The two modules complement each other.

AspectSeaWatchCompass
What it recordsSecurity events: sign-ins, user and role changes, secret readsAuthentication flows, step by step
GranularityOne event per actionSeveral steps per flow
TimingA timestampA duration per step and for the flow
Error detailA status, and a reason on failed sign-insAn error code and message per step
Network contextNot recordedIP address and user agent, when the request carries them
GuaranteesWritten with an awaited insert, chained, never deleted by FerrisKeyBest-effort: can drop events under load, deleted after 30 days, can be turned off per realm
Best forThe audit trail, “what happened”Debugging, “where and why it went wrong”

Use SeaWatch when you need to know what happened and be able to prove it. Use Compass when you need to know where a sign-in broke. To tie a SeaWatch event to its flow, see Querying events.