Agora

Observability

Metrics, the OTel recorder, the JSON/dashboard routes, and a Grafana board.

AuthKit emits a canonical set of metrics from both the server (IdP) and the client kit, through a small MetricsRecorder seam that wires to OpenTelemetry when available — and always keeps an in-memory snapshot for the built-in JSON / dashboard routes.

Enabling metrics (server)

Metrics are off until you turn them on in observability:

config/authkit.ts
defineConfig({
  // ...
  observability: {
    metrics: true, // create the recorder and wire provider events
    jsonRoutes: true, // GET /authkit/metrics  → snapshot JSON
    dashboard: true, // GET /authkit/dashboard → tiny HTML table (auto-refresh)
  },
});
FieldTypeDefaultNotes
metricsboolean?falseBuilds the OtelRecorder and binds authkit.metrics; without it the recorder is a NoopRecorder.
jsonRoutesboolean?falseMounts GET /authkit/metrics returning the in-memory snapshot.
dashboardboolean?falseMounts GET /authkit/dashboard, a minimal auto-refreshing HTML table.

The metric names

The canonical names live in AUTHKIT_METRICS (from @adonis-agora/authkit-core).

Server (IdP)

Wired from real oidc-provider v9 events:

ConstantNameKindMeaning
loginSuccessauthkit.login.successcounterA grant succeeded (grant.success).
loginFailureauthkit.login.failurecounterA provider error occurred (server_error).
tokenIssuedauthkit.token.issuedcounterAn access token was issued/saved (access_token.issued / .saved).
refreshRotatedauthkit.refresh.rotatedcounterA refresh token was saved/rotated (refresh_token.saved).
grantRevokedauthkit.grant.revokedcounterA grant was revoked (grant.revoked).

Client (RP)

Recorded by the Authenticator while resolving an identity:

ConstantNameKindMeaning
resolveDurationauthkit.resolve.durationhistogramWall-clock (ms) to resolve an identity — includes opaque-resolver introspection latency.
resolveErrorsauthkit.resolve.errorscounterThe resolver threw while resolving.

Reserved names

These names are part of the contract for future use (no emit site yet): sessionsActive (authkit.sessions.active), passwordHashDuration (authkit.password.hash.duration), jwksRefresh (authkit.jwks.refresh), tokenRefresh (authkit.token.refresh).

The MetricsRecorder seam

A recorder is just two methods plus a snapshot:

interface MetricsRecorder {
  increment(
    name: AuthkitMetricName,
    attributes?: Record<string, string | number>,
  ): void;
  record(
    name: AuthkitMetricName,
    value: number,
    attributes?: Record<string, string | number>,
  ): void;
  snapshot(): MetricsSnapshot;
}
  • OtelRecorder (server and client) best-effort imports @opentelemetry/api. If it's installed, counters/histograms are emitted to your configured OTel meter; if not, it silently stays in-memory-only. It always aggregates into an InMemorySnapshot.
  • NoopRecorder is used when metrics is off.

InMemorySnapshot and NoopRecorder are exported from @adonis-agora/authkit-core, so you can build a custom recorder over the same contract. The snapshot shape:

interface MetricsSnapshot {
  counters: Record<string, number>;
  histograms: Record<
    string,
    { count: number; sum: number; min: number; max: number }
  >;
  updatedAt: number;
}

To actually export to a backend (Prometheus/OTLP), install @opentelemetry/api plus an OTel SDK and meter provider in your app. AuthKit only calls the API surface; the SDK wiring (readers, exporters) is yours.

Sample Grafana dashboard

The board below wires the key metrics to a Prometheus datasource (metric names are shown in their Prometheus form — dots become underscores and counters gain _total). Import it from /grafana-dashboard.json or paste the JSON:

{
  "title": "AuthKit — Auth Overview",
  "uid": "authkit-overview",
  "tags": ["authkit", "oidc", "auth"],
  "templating": {
    "list": [
      {
        "name": "DS_PROMETHEUS",
        "label": "Prometheus",
        "query": "prometheus",
        "type": "datasource"
      }
    ]
  },
  "panels": [
    {
      "title": "Login success vs failure rate",
      "type": "timeseries",
      "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
      "targets": [
        {
          "expr": "sum(rate(authkit_login_success_total[5m]))",
          "legendFormat": "login success/s"
        },
        {
          "expr": "sum(rate(authkit_login_failure_total[5m]))",
          "legendFormat": "login failure/s"
        }
      ]
    },
    {
      "title": "Login failure ratio",
      "type": "stat",
      "gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
      "targets": [
        {
          "expr": "sum(rate(authkit_login_failure_total[5m])) / clamp_min(sum(rate(authkit_login_success_total[5m])) + sum(rate(authkit_login_failure_total[5m])), 1)"
        }
      ]
    },
    {
      "title": "Identity resolve duration (incl. introspection latency)",
      "type": "timeseries",
      "gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
      "targets": [
        {
          "expr": "histogram_quantile(0.95, sum(rate(authkit_resolve_duration_bucket[5m])) by (le))",
          "legendFormat": "resolve p95"
        },
        {
          "expr": "histogram_quantile(0.50, sum(rate(authkit_resolve_duration_bucket[5m])) by (le))",
          "legendFormat": "resolve p50"
        }
      ]
    },
    {
      "title": "Tokens, rotation, revocation & resolve errors",
      "type": "timeseries",
      "gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 },
      "targets": [
        {
          "expr": "sum(rate(authkit_token_issued_total[5m]))",
          "legendFormat": "tokens issued/s"
        },
        {
          "expr": "sum(rate(authkit_refresh_rotated_total[5m]))",
          "legendFormat": "refresh rotated/s"
        },
        {
          "expr": "sum(rate(authkit_grant_revoked_total[5m]))",
          "legendFormat": "grants revoked/s"
        },
        {
          "expr": "sum(rate(authkit_resolve_errors_total[5m]))",
          "legendFormat": "resolve errors/s"
        }
      ]
    }
  ]
}

The exact Prometheus metric names depend on your OTel → Prometheus exporter's naming conventions (unit suffixes, _total). Adjust the PromQL expr to match what your exporter actually publishes for the authkit.* instruments.

Telescope & the Agora diagnostics bus

Independently of OTel metrics, AuthKit republishes every audit event onto the Agora diagnostics bus as agora:authkit:<AuditEventType> (e.g. agora:authkit:login.success, agora:authkit:mfa.enabled, agora:authkit:account.locked, agora:authkit:pat.issued). This is wired automatically by the audit sink — it is a best-effort, fire-and-forget republish that never touches the request path and is a no-op when nothing is subscribed.

The baseline works without any extra setup

If you have @adonis-agora/telescope installed, its generic diagnostics watcher already records each of these events as a diagnostic entry tagged lib:authkit — no AuthKit-specific configuration required. You can browse them in Telescope under the diagnostics entry type out of the box.

Adding the dedicated "Security" dashboard

For a purpose-built auth view, register AuthKit's Telescope extension. It contributes a navigable Auth entry type and a Security dashboard that aggregates the recorded agora:authkit:* entries: login success rate, successful/failed logins over time, MFA & passkey enrollments, account lockouts, and PAT / impersonation activity.

config/telescope.ts
import { defineConfig } from "@adonis-agora/telescope";
import { defineAuthkitTelescopeExtension } from "@adonis-agora/authkit-server/telescope";

export default defineConfig({
  extensions: [defineAuthkitTelescopeExtension()],
});

You can widen or narrow the rolling window the stat / gauge / breakdown panels aggregate over (default 24h):

defineAuthkitTelescopeExtension({ windowMs: 7 * 24 * 60 * 60 * 1000 });

@adonis-agora/telescope is an optional peer dependency of @adonis-agora/authkit-server. The extension lives on the isolated @adonis-agora/authkit-server/telescope subpath, so the main entrypoint never imports Telescope — and the baseline (events on the bus) keeps working whether or not you register the extension.

On this page