Skip to content

Commit 58cbcc8

Browse files
authored
Merge pull request #1403 from supertokens/agent/issue-1397-emit-creation-disassoc
feat: emit user_creation and tenant_disassociation lifecycle events
2 parents 9a4d87e + 0712835 commit 58cbcc8

47 files changed

Lines changed: 2280 additions & 231 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,13 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
1010
- `ActiveUsers.updateLastActive` no longer writes `user_last_active` directly; it only appends the throttled `user_last_active` activity-log event, and the `RollupUserLastActive` cron is now the sole writer of the projection (so counts reflect activity within a rollup interval).
1111
- Added Phase-1 parity tests proving the last-active rollup derives the same `countUsersActiveSince` answer (and per-user projection) as the direct write, including link/unlink cases (`ActivityLogRollupParityTest`).
1212
- Adds an observability-only shadow audit to the approximate-user-count background refresh; discrepancies are logged and emitted as telemetry, never served.
13+
- The `allowApproximate` user-count path now serves the exact anchor plus a fold of lifecycle events since the anchor, making the approximate count exact for creations, deletions and account (un)linking (no API change).
14+
- From CDI 5.6 the default single-tenant, unfiltered `/users/count` path serves `anchor + fold` from the lifecycle-event ledger instead of an exact recompute per request, always returning the `approximate`/`asOf` fields and making the `allowApproximate` parameter a no-op; older CDI versions are unchanged.
15+
- Emits `user_creation` and `tenant_disassociation` lifecycle events atomically with the mutation from the interactive user-creation and tenant-removal paths.
16+
- 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.
1317
- 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).
18+
- 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.
19+
- Sign-up and account-linking now wake the last-active rollup, so a user with no other activity is folded into `user_last_active` on the next tick instead of waiting for the periodic backstop pass.
1420

1521
## [12.2.0]
1622

config.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,13 @@ core_config_version: 0
215215
# by a periodic cleanup. Must be the same for all apps/tenants under a connection URI domain.
216216
# activity_log_retention_days:
217217

218+
# (OPTIONAL | Default: true) boolean value. If true, throttled activity events (token_refresh, session_create,
219+
# oauth_token_exchange, oauth_authorize) are collapsed to at most one activity_log write per (app, user) every 5
220+
# minutes, so a burst of refreshes does not turn into a per-request insert. sign_in and sign_out are never
221+
# throttled. Set to false to record every activity event as its own row (a complete audit trail) at the cost of
222+
# that write volume. Must be the same for all apps/tenants under a connection URI domain.
223+
# activity_log_throttle_enabled:
224+
218225
# (DIFFERENT_ACROSS_APPS | OPTIONAL | Default: 3600000) long value. Time in milliseconds for how long a webauthn
219226
# account recovery token is valid for.
220227
# webauthn_recover_account_token_lifetime:

devConfig.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,13 @@ bcrypt_log_rounds: 4
214214
# by a periodic cleanup. Must be the same for all apps/tenants under a connection URI domain.
215215
# activity_log_retention_days:
216216

217+
# (OPTIONAL | Default: true) boolean value. If true, throttled activity events (token_refresh, session_create,
218+
# oauth_token_exchange, oauth_authorize) are collapsed to at most one activity_log write per (app, user) every 5
219+
# minutes, so a burst of refreshes does not turn into a per-request insert. sign_in and sign_out are never
220+
# throttled. Set to false to record every activity event as its own row (a complete audit trail) at the cost of
221+
# that write volume. Must be the same for all apps/tenants under a connection URI domain.
222+
# activity_log_throttle_enabled:
223+
217224
# (DIFFERENT_ACROSS_APPS | OPTIONAL | Default: 3600000) long value. Time in milliseconds for how long a webauthn
218225
# account recovery token is valid for.
219226
# webauthn_recover_account_token_lifetime:

src/main/java/io/supertokens/ActiveUsers.java

Lines changed: 110 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
package io.supertokens;
22

33
import io.supertokens.auditlog.AuditLog;
4+
import io.supertokens.config.Config;
45
import io.supertokens.cronjobs.rollupUserLastActive.RollupDirtySignal;
56
import io.supertokens.pluginInterface.ActiveUsersSQLStorage;
67
import io.supertokens.pluginInterface.Storage;
78
import io.supertokens.pluginInterface.StorageUtils;
9+
import io.supertokens.pluginInterface.auditlog.ActivityEventType;
810
import io.supertokens.pluginInterface.auditlog.AuditLogEvent;
911
import io.supertokens.pluginInterface.exceptions.StorageQueryException;
1012
import io.supertokens.pluginInterface.exceptions.StorageTransactionLogicException;
@@ -14,17 +16,34 @@
1416
import io.supertokens.storageLayer.StorageLayer;
1517
import org.jetbrains.annotations.TestOnly;
1618

19+
import java.util.EnumSet;
1720
import java.util.concurrent.ConcurrentHashMap;
1821
import io.supertokens.auditlog.UnauditedTransaction;
1922

2023
public class ActiveUsers {
2124

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

31+
// Throttle policy for the shared plugin-interface {@link ActivityEventType} vocabulary. The vocabulary
32+
// deliberately carries no throttle flag — its javadoc keeps throttling core-side — so which classes are
33+
// throttled is decided here: sign_in / sign_out are low-volume, user-initiated and audit-meaningful, so
34+
// they always emit; every other activity class is high-volume and shares the throttle.
35+
private static final EnumSet<ActivityEventType> UNTHROTTLED_EVENTS =
36+
EnumSet.of(ActivityEventType.SIGN_IN, ActivityEventType.SIGN_OUT);
37+
38+
/**
39+
* @return whether emits of {@code eventType} are subject to the shared 5-minute per-{@code (app, user)}
40+
* throttle. {@code sign_in} / {@code sign_out} return {@code false} (always emitted); the rest return
41+
* {@code true}. Core-side policy over the plugin-interface {@link ActivityEventType} vocabulary.
42+
*/
43+
public static boolean isThrottled(ActivityEventType eventType) {
44+
return !UNTHROTTLED_EVENTS.contains(eventType);
45+
}
46+
2847
// Hard cap on cache size. Beyond this we sweep expired entries; if still over we clear.
2948
// Extra upserts for a window are acceptable; unbounded memory growth is not.
3049
private static final int MAX_CACHE_ENTRIES = 200_000;
@@ -72,45 +91,106 @@ public static void markRecentlyActive(AppIdentifier appIdentifier, String userId
7291
recordActiveAt(cacheKey(appIdentifier, userId), System.currentTimeMillis());
7392
}
7493

75-
public static void updateLastActive(AppIdentifier appIdentifier, Main main, String userId)
94+
/**
95+
* Records a unit of user activity of the given {@code eventType}, emitting it into the request's tenant.
96+
* The last-active rollup cron is the sole writer of the {@code user_last_active} projection (PLAN-011
97+
* cutover); here we only append the activity-log event — the fold's source — and mark the storage dirty
98+
* so the next rollup pass folds it. When the {@code activity_log_throttle_enabled} config is on (the
99+
* default), throttled activity classes ({@link #isThrottled(ActivityEventType)}) skip the append when
100+
* this (app, user) was seen within the throttle window and unthrottled classes always append — either way
101+
* the recency cache is refreshed. When the config is off, the throttle and its cache are bypassed and
102+
* every activity is recorded as its own row (a complete audit trail). The projection updates
103+
* asynchronously (within a rollup interval).
104+
*
105+
* <p>The append is best-effort: {@link AuditLog#emit} swallows its own write failures, so a failed
106+
* activity write never fails the caller's request. That matters because these events are emitted after an
107+
* already-committed (and, for OAuth, externally non-reversible) auth operation — a transient activity-log
108+
* error must not turn a succeeded sign-in / refresh / session-create / sign-out / oauth call into a 500.
109+
* Dropped rows self-heal for active-user counting: the next event for the user re-credits them, and the
110+
* reliable recency anchors are the transactional {@code user_creation} / {@code account_linking} lifecycle
111+
* events.
112+
*/
113+
public static void updateLastActive(TenantIdentifier tenantIdentifier, Main main, String userId,
114+
ActivityEventType eventType)
76115
throws TenantOrAppNotFoundException {
116+
AppIdentifier appIdentifier = tenantIdentifier.toAppIdentifier();
77117
long now = System.currentTimeMillis();
78118
String key = cacheKey(appIdentifier, userId);
79-
if (!Main.isTesting && isRecentlyActive(key, now)) {
80-
return;
119+
boolean throttleEnabled = Config.getConfig(appIdentifier.getAsPublicTenantIdentifier(), main)
120+
.getActivityLogThrottleEnabled();
121+
if (throttleEnabled && !Main.isTesting) {
122+
if (isThrottled(eventType) && isRecentlyActive(key, now)) {
123+
return;
124+
}
125+
// Refresh the recency cache so a subsequent throttled event (and wasRecentlyActive) sees this
126+
// activity. Only meaningful while throttling is on; when off we never touch the cache, so
127+
// wasRecentlyActive stays false and every activity is recorded.
128+
recordActiveAt(key, now);
81129
}
130+
// The activity log and its projection live on the app's public-tenant storage — as before, so the
131+
// fold (which groups by app_id) and the count read see the same rows. The request's tenant is written
132+
// into the tenant_id column for provenance only.
82133
Storage storage = StorageLayer.getStorage(appIdentifier.getAsPublicTenantIdentifier(), main);
83-
// The last-active rollup cron is the sole writer of user_last_active (PLAN-011 cutover). Here we only
84-
// append the throttled user_last_active activity-log event — the fold's source — and mark the storage
85-
// dirty so the next rollup pass folds it. The 5-minute throttle now caps activity-log insert volume
86-
// instead of direct-upsert volume. The projection updates asynchronously (within a rollup interval).
87-
recordActiveAt(key, now);
88-
emitLastActiveAuditLog(main, storage, appIdentifier, userId, now);
134+
emitActivityAuditLog(main, storage, tenantIdentifier, userId, eventType, now);
89135
}
90136

91137
/**
92-
* Records a {@code user_last_active} entry in the activity_log table. Mirrors every successful
93-
* user_last_active write so the audit log captures user activity. Best-effort: {@link AuditLog#emit}
94-
* swallows its own failures, so a failed audit write never affects the active-users update.
138+
* Overload for callers that only have the app on hand (no request tenant): the event is emitted into the
139+
* app's public tenant — today's behavior for every activity emit before per-tenant provenance was added.
95140
*/
96-
private static void emitLastActiveAuditLog(Main main, Storage storage, AppIdentifier appIdentifier,
97-
String userId, long now) {
98-
TenantIdentifier tenantIdentifier = appIdentifier.getAsPublicTenantIdentifier();
141+
public static void updateLastActive(AppIdentifier appIdentifier, Main main, String userId,
142+
ActivityEventType eventType)
143+
throws TenantOrAppNotFoundException {
144+
updateLastActive(appIdentifier.getAsPublicTenantIdentifier(), main, userId, eventType);
145+
}
146+
147+
/**
148+
* Appends an activity event to the activity_log so the last-active fold captures the user's activity.
149+
* Best-effort: {@link AuditLog#emit} swallows its own failures, so a failed audit write never affects the
150+
* request. {@code tenant_id} carries the request's tenant; {@code event_type} is {@code eventType}'s value.
151+
*/
152+
private static void emitActivityAuditLog(Main main, Storage storage, TenantIdentifier tenantIdentifier,
153+
String userId, ActivityEventType eventType, long now) {
99154
AuditLog.emit(main, storage, tenantIdentifier, new AuditLogEvent(
100-
appIdentifier.getAppId(), tenantIdentifier.getTenantId(),
155+
tenantIdentifier.getAppId(), tenantIdentifier.getTenantId(),
101156
userId, userId,
102-
"user_last_active", "success", null, null,
157+
eventType.getValue(), "success", null, null,
103158
now, null));
104159
// Signal the last-active rollup cron that this storage now has unfolded activity, so its next tick
105160
// folds instead of skipping.
106161
RollupDirtySignal.getInstance(main).markDirty(storage.getUserPoolId());
107162
}
108163

164+
/**
165+
* Wakes the last-active rollup for a user whose fold credit comes from a transactional lifecycle event —
166+
* {@code user_creation} on sign-up, {@code account_linking} on link (the two lifecycle members of the
167+
* fold set, see {@code RollupEventTypes#FOLD_SET}) — rather than from {@link #updateLastActive}. Those events
168+
* are written on the mutation's own connection via {@code startAuditedTransaction}, which — unlike
169+
* {@code updateLastActive} / {@link #emitActivityAuditLog} — does not touch the rollup dirty signal.
170+
*
171+
* <p>Without this nudge a user who only signs up (or is only linked) and produces no other activity would
172+
* not be folded into {@code user_last_active} until the periodic backstop pass — up to a backstop
173+
* interval — a promptness regression versus the pre-semantic-event behaviour where sign-up went through
174+
* {@code updateLastActive → markDirty} and folded on the next rollup tick.
175+
*
176+
* <p>Call after the lifecycle event's transaction has committed. Marking dirty only signals <em>that</em>
177+
* there is something to fold, never the fold window, so it is idempotent and safe to over-signal; a lost
178+
* signal is corrected by the cron's periodic backstop.
179+
*/
180+
public static void markLastActiveRollupDirty(Main main, AppIdentifier appIdentifier)
181+
throws TenantOrAppNotFoundException {
182+
// The projection and its dirty flag are keyed by the app's public-tenant storage pool — the same
183+
// storage updateLastActive marks dirty — so a fold-relevant lifecycle event written on any tenant in
184+
// the pool wakes the one rollup pass that folds it.
185+
Storage storage = StorageLayer.getStorage(appIdentifier.getAsPublicTenantIdentifier(), main);
186+
RollupDirtySignal.getInstance(main).markDirty(storage.getUserPoolId());
187+
}
188+
109189
@TestOnly
110190
public static void updateLastActive(Main main, String userId) {
111191
try {
112192
ActiveUsers.updateLastActive(ResourceDistributor.getAppForTesting().toAppIdentifier(),
113-
main, userId);
193+
main, userId, ActivityEventType.SIGN_IN);
114194
} catch (TenantOrAppNotFoundException e) {
115195
throw new IllegalStateException(e);
116196
}
@@ -138,20 +218,20 @@ public static void updateLastActiveAfterLinking(Main main, AppIdentifier appIden
138218
// Latency optimization only: the rollup's reconcile — driven by the account_linking event that
139219
// AuthRecipe.linkAccounts emits atomically with the mapping change — is the source of truth for
140220
// dropping the recipe user's now-stale projection row. Deleting it here just makes the merge visible
141-
// before the next rollup pass instead of after it.
221+
// before the next rollup pass instead of after it. The primary user's refreshed recency comes from
222+
// the same account_linking event (the fold credits primary_or_recipe_user_id), so no activity ping is
223+
// emitted here.
142224
activeUsersStorage.startTransaction(con -> {
143225
activeUsersStorage.deleteUserActive_Transaction(con, appIdentifier, recipeUserId);
144226
return null;
145227
});
146228
recentlyActiveCache.remove(cacheKey(appIdentifier, recipeUserId));
147229

148-
// Bypass throttle: linking merges two users into primaryUserId, so its timestamp must
149-
// be refreshed to "now" regardless of cache state — it now represents the merged
150-
// activity and an undercounted timestamp would lose the recipeUser's recency. Emitted into the
151-
// activity log (not written directly) so the rollup — the sole user_last_active writer — folds it.
152-
long now = System.currentTimeMillis();
153-
recordActiveAt(cacheKey(appIdentifier, primaryUserId), now);
154-
emitLastActiveAuditLog(main, activeUsersStorage, appIdentifier, primaryUserId, now);
230+
// The primary user's refreshed recency is credited by the account_linking lifecycle event
231+
// AuthRecipe.linkAccounts emitted transactionally — but that event, written via startAuditedTransaction,
232+
// does not mark the rollup dirty. Wake the rollup here so a link with no other activity folds on the
233+
// next tick rather than waiting for the periodic backstop.
234+
RollupDirtySignal.getInstance(main).markDirty(activeUsersStorage.getUserPoolId());
155235
}
156236

157237
@TestOnly

src/main/java/io/supertokens/auditlog/lifecycle/CountDeltaInterpreter.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,9 @@
3434
* derived here from those lists, per {@link LifecycleEventType}:
3535
*
3636
* <ul>
37-
* <li>{@code USER_CREATION} — {@code +1} for the created-in tenant (a new standalone group).</li>
37+
* <li>{@code USER_CREATION} / {@code USER_IMPORT} — {@code +1} for the tenant the (new or imported) user
38+
* lands in. The two are folded identically here; the distinction is for the last-active rollup, not the
39+
* user count.</li>
3840
* <li>{@code USER_GROUP_DELETION} — {@code -1} for every tenant the group was present in.</li>
3941
* <li>{@code USER_DELETION} — {@code after − before} per tenant: {@code -1} for each tenant the group left
4042
* (its last member there was deleted) and {@code +1} for any it newly appears in (not expected for a
@@ -147,6 +149,10 @@ public static long computeDeltaForTenant(List<LifecycleEventPayload> events, Str
147149
private static void applyEvent(LifecycleEventPayload event, Map<String, Long> deltas) {
148150
switch (event.type) {
149151
case USER_CREATION:
152+
case USER_IMPORT:
153+
// A bulk-imported user is counted toward totals exactly like an interactively created one:
154+
// a +1 in the tenant it lands in. (The type distinction matters only to the last-active
155+
// rollup, which folds user_creation but excludes user_import, and never enters this fold.)
150156
add(deltas, event.tenantId, 1);
151157
break;
152158
case USER_GROUP_DELETION:

0 commit comments

Comments
 (0)