Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- Emits `user_creation` and `tenant_disassociation` lifecycle events atomically with the mutation from the interactive user-creation and tenant-removal paths.
- Bulk import now emits lifecycle events atomically with the import: one `user_import` per imported user (counted toward user totals like `user_creation`, but under its own type so the last-active rollup can exclude imports) plus a `tenant_association` for each remaining tenant the user lands in.
- The last-active rollup fold now skips activity for apps no longer present in `apps`, so a deleted app's retained `activity_log` rows can never resurrect a `user_last_active` projection row (which would violate its `apps` foreign key).
- Replaced the synthetic `user_last_active` event with semantic activity events (`sign_in`, `token_refresh`, `session_create`, `sign_out`, `oauth_token_exchange`, `oauth_authorize`); the last-active fold now reads these plus the `user_creation` and `account_linking` lifecycle events, and a new protected config `activity_log_throttle_enabled` (boolean, default `true`, per connection URI domain) toggles the per-`(app, user)` write throttle on the throttled events.

## [12.2.0]

Expand Down
7 changes: 7 additions & 0 deletions config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,13 @@ core_config_version: 0
# by a periodic cleanup. Must be the same for all apps/tenants under a connection URI domain.
# activity_log_retention_days:

# (OPTIONAL | Default: true) boolean value. If true, throttled activity events (token_refresh, session_create,
# oauth_token_exchange, oauth_authorize) are collapsed to at most one activity_log write per (app, user) every 5
# minutes, so a burst of refreshes does not turn into a per-request insert. sign_in and sign_out are never
# throttled. Set to false to record every activity event as its own row (a complete audit trail) at the cost of
# that write volume. Must be the same for all apps/tenants under a connection URI domain.
# activity_log_throttle_enabled:

# (DIFFERENT_ACROSS_APPS | OPTIONAL | Default: 3600000) long value. Time in milliseconds for how long a webauthn
# account recovery token is valid for.
# webauthn_recover_account_token_lifetime:
Expand Down
7 changes: 7 additions & 0 deletions devConfig.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,13 @@ bcrypt_log_rounds: 4
# by a periodic cleanup. Must be the same for all apps/tenants under a connection URI domain.
# activity_log_retention_days:

# (OPTIONAL | Default: true) boolean value. If true, throttled activity events (token_refresh, session_create,
# oauth_token_exchange, oauth_authorize) are collapsed to at most one activity_log write per (app, user) every 5
# minutes, so a burst of refreshes does not turn into a per-request insert. sign_in and sign_out are never
# throttled. Set to false to record every activity event as its own row (a complete audit trail) at the cost of
# that write volume. Must be the same for all apps/tenants under a connection URI domain.
# activity_log_throttle_enabled:

# (DIFFERENT_ACROSS_APPS | OPTIONAL | Default: 3600000) long value. Time in milliseconds for how long a webauthn
# account recovery token is valid for.
# webauthn_recover_account_token_lifetime:
Expand Down
94 changes: 63 additions & 31 deletions src/main/java/io/supertokens/ActiveUsers.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package io.supertokens;

import io.supertokens.auditlog.AuditLog;
import io.supertokens.auditlog.lifecycle.ActivityEventType;
import io.supertokens.config.Config;
import io.supertokens.cronjobs.rollupUserLastActive.RollupDirtySignal;
import io.supertokens.pluginInterface.ActiveUsersSQLStorage;
import io.supertokens.pluginInterface.Storage;
Expand All @@ -19,10 +21,10 @@

public class ActiveUsers {

// Skip the user_last_active upsert if we already wrote one for this (app, userId) within
// this window. The table feeds daily/monthly active-user counts, so a few minutes of
// staleness is invisible — but at refresh-token rates the unthrottled upsert dominates
// commit waits on the database.
// Skip appending a throttled activity event if we already wrote one for this (app, userId) within
// this window. The activity log feeds daily/monthly active-user counts (via the fold), so a few
// minutes of staleness is invisible — but at refresh-token rates an unthrottled insert dominates
// commit waits on the database. Unthrottled activity classes (sign_in, sign_out) bypass this.
private static final long THROTTLE_MS = 5 * 60 * 1000L;

// Hard cap on cache size. Beyond this we sweep expired entries; if still over we clear.
Expand Down Expand Up @@ -72,34 +74,70 @@ public static void markRecentlyActive(AppIdentifier appIdentifier, String userId
recordActiveAt(cacheKey(appIdentifier, userId), System.currentTimeMillis());
}

public static void updateLastActive(AppIdentifier appIdentifier, Main main, String userId)
/**
* Records a unit of user activity of the given {@code eventType}, emitting it into the request's tenant.
* The last-active rollup cron is the sole writer of the {@code user_last_active} projection (PLAN-011
* cutover); here we only append the activity-log event — the fold's source — and mark the storage dirty
* so the next rollup pass folds it. When the {@code activity_log_throttle_enabled} config is on (the
* default), throttled activity classes ({@link ActivityEventType#isThrottled()}) skip the append when
* this (app, user) was seen within the throttle window and unthrottled classes always append — either way
* the recency cache is refreshed. When the config is off, the throttle and its cache are bypassed and
* every activity is recorded as its own row (a complete audit trail). The projection updates
* asynchronously (within a rollup interval).
*
* <p>The append is best-effort: {@link AuditLog#emit} swallows its own write failures, so a failed
* activity write never fails the caller's request. That matters because these events are emitted after an
* already-committed (and, for OAuth, externally non-reversible) auth operation — a transient activity-log
* error must not turn a succeeded sign-in / refresh / session-create / sign-out / oauth call into a 500.
* Dropped rows self-heal for active-user counting: the next event for the user re-credits them, and the
* reliable recency anchors are the transactional {@code user_creation} / {@code account_linking} lifecycle
* events.
*/
public static void updateLastActive(TenantIdentifier tenantIdentifier, Main main, String userId,
ActivityEventType eventType)
throws TenantOrAppNotFoundException {
AppIdentifier appIdentifier = tenantIdentifier.toAppIdentifier();
long now = System.currentTimeMillis();
String key = cacheKey(appIdentifier, userId);
if (!Main.isTesting && isRecentlyActive(key, now)) {
return;
boolean throttleEnabled = Config.getConfig(appIdentifier.getAsPublicTenantIdentifier(), main)
.getActivityLogThrottleEnabled();
if (throttleEnabled && !Main.isTesting) {
Comment thread
tamassoltesz marked this conversation as resolved.
if (eventType.isThrottled() && isRecentlyActive(key, now)) {
return;
}
// Refresh the recency cache so a subsequent throttled event (and wasRecentlyActive) sees this
// activity. Only meaningful while throttling is on; when off we never touch the cache, so
// wasRecentlyActive stays false and every activity is recorded.
recordActiveAt(key, now);
}
// The activity log and its projection live on the app's public-tenant storage — as before, so the
// fold (which groups by app_id) and the count read see the same rows. The request's tenant is written
// into the tenant_id column for provenance only.
Storage storage = StorageLayer.getStorage(appIdentifier.getAsPublicTenantIdentifier(), main);
// The last-active rollup cron is the sole writer of user_last_active (PLAN-011 cutover). Here we only
// append the throttled user_last_active activity-log event — the fold's source — and mark the storage
// dirty so the next rollup pass folds it. The 5-minute throttle now caps activity-log insert volume
// instead of direct-upsert volume. The projection updates asynchronously (within a rollup interval).
recordActiveAt(key, now);
emitLastActiveAuditLog(main, storage, appIdentifier, userId, now);
emitActivityAuditLog(main, storage, tenantIdentifier, userId, eventType, now);
}

/**
* Overload for callers that only have the app on hand (no request tenant): the event is emitted into the
* app's public tenant — today's behavior for every activity emit before per-tenant provenance was added.
*/
public static void updateLastActive(AppIdentifier appIdentifier, Main main, String userId,
ActivityEventType eventType)
throws TenantOrAppNotFoundException {
updateLastActive(appIdentifier.getAsPublicTenantIdentifier(), main, userId, eventType);
}

/**
* Records a {@code user_last_active} entry in the activity_log table. Mirrors every successful
* user_last_active write so the audit log captures user activity. Best-effort: {@link AuditLog#emit}
* swallows its own failures, so a failed audit write never affects the active-users update.
* Appends an activity event to the activity_log so the last-active fold captures the user's activity.
* Best-effort: {@link AuditLog#emit} swallows its own failures, so a failed audit write never affects the
Comment thread
tamassoltesz marked this conversation as resolved.
* request. {@code tenant_id} carries the request's tenant; {@code event_type} is {@code eventType}'s value.
*/
private static void emitLastActiveAuditLog(Main main, Storage storage, AppIdentifier appIdentifier,
String userId, long now) {
TenantIdentifier tenantIdentifier = appIdentifier.getAsPublicTenantIdentifier();
private static void emitActivityAuditLog(Main main, Storage storage, TenantIdentifier tenantIdentifier,
String userId, ActivityEventType eventType, long now) {
AuditLog.emit(main, storage, tenantIdentifier, new AuditLogEvent(
appIdentifier.getAppId(), tenantIdentifier.getTenantId(),
tenantIdentifier.getAppId(), tenantIdentifier.getTenantId(),
userId, userId,
"user_last_active", "success", null, null,
eventType.getValue(), "success", null, null,
now, null));
// Signal the last-active rollup cron that this storage now has unfolded activity, so its next tick
// folds instead of skipping.
Expand All @@ -110,7 +148,7 @@ private static void emitLastActiveAuditLog(Main main, Storage storage, AppIdenti
public static void updateLastActive(Main main, String userId) {
try {
ActiveUsers.updateLastActive(ResourceDistributor.getAppForTesting().toAppIdentifier(),
main, userId);
main, userId, ActivityEventType.SIGN_IN);
} catch (TenantOrAppNotFoundException e) {
throw new IllegalStateException(e);
}
Expand Down Expand Up @@ -138,20 +176,14 @@ public static void updateLastActiveAfterLinking(Main main, AppIdentifier appIden
// Latency optimization only: the rollup's reconcile — driven by the account_linking event that
// AuthRecipe.linkAccounts emits atomically with the mapping change — is the source of truth for
// dropping the recipe user's now-stale projection row. Deleting it here just makes the merge visible
// before the next rollup pass instead of after it.
// before the next rollup pass instead of after it. The primary user's refreshed recency comes from
// the same account_linking event (the fold credits primary_or_recipe_user_id), so no activity ping is
// emitted here.
activeUsersStorage.startTransaction(con -> {
activeUsersStorage.deleteUserActive_Transaction(con, appIdentifier, recipeUserId);
return null;
});
recentlyActiveCache.remove(cacheKey(appIdentifier, recipeUserId));
Comment thread
tamassoltesz marked this conversation as resolved.

// Bypass throttle: linking merges two users into primaryUserId, so its timestamp must
// be refreshed to "now" regardless of cache state — it now represents the merged
// activity and an undercounted timestamp would lose the recipeUser's recency. Emitted into the
// activity log (not written directly) so the rollup — the sole user_last_active writer — folds it.
long now = System.currentTimeMillis();
recordActiveAt(cacheKey(appIdentifier, primaryUserId), now);
emitLastActiveAuditLog(main, activeUsersStorage, appIdentifier, primaryUserId, now);
}

@TestOnly
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/*
* Copyright (c) 2026, VRAI Labs and/or its affiliates. All rights reserved.
*
* This software is licensed under the Apache License, Version 2.0 (the
* "License") as published by the Apache Software Foundation.
*
* You may not use this file except in compliance with the License. You may
* obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/

package io.supertokens.auditlog.lifecycle;

/**
* The vocabulary of "activity" events written to the activity log — user-initiated interactions that mark a
* user as recently active. Unlike {@link LifecycleEventType lifecycle events} (count-affecting mutations
* recorded in their own transaction), activity events are best-effort pings emitted outside any mutation
* transaction, and feed the last-active rollup fold: {@code last_active(user) = MAX(created_at)} over these
* events (plus the activity-implying lifecycle events {@code user_creation} and {@code account_linking}).
Comment thread
tamassoltesz marked this conversation as resolved.
Outdated
*
* <p>The string {@link #getValue() value} is what lands in the {@code activity_log.event_type} column. These
* replaced the single synthetic {@code user_last_active} event, which is retired: the concrete interaction
* (a sign-in, a refresh, a session create, ...) is now recorded directly and the fold counts it.
*
* <p>{@link #isThrottled() Throttling}: {@code sign_in} and {@code sign_out} emit unthrottled (low-volume,
* user-initiated, audit-meaningful); the rest keep the shared 5-minute per-{@code (app, user)} throttle that
* caps high-volume activity-log inserts. Either way every emit refreshes the recency cache
* ({@code ActiveUsers.wasRecentlyActive} keeps its meaning).
*/
public enum ActivityEventType {
Comment thread
tamassoltesz marked this conversation as resolved.
Outdated

/** An interactive sign-in of an existing user (emailpassword / webauthn / thirdparty / passwordless). */
SIGN_IN("sign_in", false),

/** A session was explicitly revoked for a user. */
SIGN_OUT("sign_out", false),

/** A session's tokens were refreshed. */
TOKEN_REFRESH("token_refresh", true),

/** A new session was created for a user. */
SESSION_CREATE("session_create", true),

/** An OAuth token exchange resolved to a session user. */
OAUTH_TOKEN_EXCHANGE("oauth_token_exchange", true),

/** An OAuth authorization request resolved to a session user. */
OAUTH_AUTHORIZE("oauth_authorize", true);

private final String value;
private final boolean throttled;

ActivityEventType(String value, boolean throttled) {
this.value = value;
this.throttled = throttled;
}

/** The string stored in the {@code activity_log.event_type} column. */
public String getValue() {
return value;
}

/**
* @return whether emits of this type are subject to the shared 5-minute per-{@code (app, user)} throttle.
* {@code sign_in}/{@code sign_out} return {@code false} (always emitted); the rest return {@code true}.
*/
public boolean isThrottled() {
return throttled;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ private static void applyEvent(LifecycleEventPayload event, Map<String, Long> de
case USER_IMPORT:
// A bulk-imported user is counted toward totals exactly like an interactively created one:
// a +1 in the tenant it lands in. (The type distinction matters only to the last-active
// rollup, which reads user_last_active/account_linking rows and never enters this fold.)
// rollup, which folds user_creation but excludes user_import, and never enters this fold.)
add(deltas, event.tenantId, 1);
break;
case USER_GROUP_DELETION:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/*
* Copyright (c) 2026, VRAI Labs and/or its affiliates. All rights reserved.
*
* This software is licensed under the Apache License, Version 2.0 (the
* "License") as published by the Apache Software Foundation.
*
* You may not use this file except in compliance with the License. You may
* obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/

package io.supertokens.auditlog.lifecycle;

import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.Set;
import java.util.stream.Collectors;

/**
* Single source of truth for the {@code activity_log.event_type} values that feed the last-active rollup
* fold, so the fold query and the {@code hasUnfoldedActivitySince} existence check cannot drift apart. The
* PostgreSQL plugin mirrors the same set in its own SQL (supertokens-postgresql-plugin#398); both halves must
* ship together.
*
* <p>The set is the six {@link ActivityEventType activity events} plus the two lifecycle events that imply
* activity: {@code user_creation} (an interactive creation counts as activity — the fold reads it in place of
* a sign-up ping) and {@code account_linking} (credits the primary user via {@code primary_or_recipe_user_id};
* the reconcile separately drops the linked-away recipe user's row). Everything else is excluded — notably
* {@code user_import} (imported != active) and the retired {@code user_last_active} (no writer remains).
*/
public final class LastActiveFoldEvents {

private LastActiveFoldEvents() {
}

/** The {@code event_type} values the fold credits toward a user's recency. Insertion order is preserved. */
public static final Set<String> FOLD_EVENT_TYPES;

static {
Set<String> types = new LinkedHashSet<>();
for (ActivityEventType type : ActivityEventType.values()) {
types.add(type.getValue());
}
types.add(LifecycleEventType.USER_CREATION.getValue());
types.add(LifecycleEventType.ACCOUNT_LINKING.getValue());
Comment thread
tamassoltesz marked this conversation as resolved.
Outdated
FOLD_EVENT_TYPES = Collections.unmodifiableSet(types);
}

/**
* @return the fold set as a SQL {@code IN}-list body, e.g. {@code 'sign_in', 'token_refresh', ...}. Safe to
* inline into a query string: every value is a compile-time enum constant, never user input.
*/
public static String sqlInList() {
return FOLD_EVENT_TYPES.stream().map(v -> "'" + v + "'").collect(Collectors.joining(", "));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
*/
public final class LifecycleAuditEvent {

// Mirrors the status written for the user_last_active activity rows.
// Mirrors the status written for the semantic activity rows.
private static final String STATUS_SUCCESS = "success";

private LifecycleAuditEvent() {
Expand Down
Loading
Loading