Usage analytics
Serves, daily play rollups, the rollup boundary between rolled-up days and live telemetry, perceived difficulty, and the usage stats and analytics read by the teacher app.
The usage module counts what the player did with a teacher's deals. A serve is one deal resolved by the public API; it counts once the player's first telemetry event lands it. Plays, results and graded difficulty are rolled up per owner, deal code and UTC day by a nightly job, and every usage read sums rollups up to the job's watermark then counts raw telemetry live after it. The API records serves, the jobs runtime runs the rollups, the standings and the perseverance snapshots, and the teacher app reads the usage tile and the detail view.
Import from @workspace/training/domain/usage and @workspace/training/application/usage. Reads reuse UsagePeriod from @workspace/training/domain/entitlements.
Domain
Perceived difficulty
The perceived difficulty of one play is a 0 to 100 index from the stars earned, the auction errors n and the minutes of card play t. k = 10 × (n + t − 2) grades the 2- and 3-star plays: 0 and 1 star score 100, 2 stars score 50 + k clamped to [50, 100], 3 stars score k clamped to [0, 50]. A play whose auction was never the learner's to bid counts no error. Plays longer than the outlier bound leave the aggregate.
Prop
Type
| Export | Definition |
|---|---|
DIFFICULTY_MAX_INDEX | 100, the top of the scale. |
DIFFICULTY_OUTLIER_SECONDS | 900: plays longer than 15 minutes are outliers. |
| Function | Signature | Description |
|---|---|---|
derivePerceivedDifficulty | (input: { stars: 0 | 1 | 2 | 3; auctionErrors: number; playSeconds: number }) => number | The index of one play. |
parseDifficultyStars | (value: number) => 0 | 1 | 2 | 3 | null | Null when the value is not a play verdict. |
isDifficultyOutlier | (playSeconds: number) => boolean | Strictly longer than DIFFICULTY_OUTLIER_SECONDS. |
aggregatePerceivedDifficulty | (plays: readonly PerceivedDifficultyPlay[]) => { difficultySum: number; gradedCount: number } | Sums the graded plays, dropping outliers and anything without a verdict. A null auctionErrors counts no error, a null playSeconds grades as instant. The mean index is difficultySum / gradedCount. |
derivePerceivedDifficulty({ stars: 1, auctionErrors: 0, playSeconds: 30 }); // => 100
derivePerceivedDifficulty({ stars: 2, auctionErrors: 1, playSeconds: 120 }); // => 60 (50 + 10 × (1 + 2 − 2))
derivePerceivedDifficulty({ stars: 3, auctionErrors: 0, playSeconds: 240 }); // => 20 (10 × (0 + 4 − 2))
derivePerceivedDifficulty({ stars: 3, auctionErrors: 0, playSeconds: 60 }); // => 0 (floored)
isDifficultyOutlier(DIFFICULTY_OUTLIER_SECONDS + 1); // => true: longer than 15 minutes
parseDifficultyStars(4); // => null: not a verdict
aggregatePerceivedDifficulty([
{ stars: 3, auctionErrors: null, playSeconds: 240 }, // auction never bid: counts no error => 20
{ stars: 2, auctionErrors: 1, playSeconds: null }, // never finished: graded as instant => 50
{ stars: 3, auctionErrors: 0, playSeconds: 20 * 60 }, // outlier, dropped
{ stars: 5, auctionErrors: 0, playSeconds: 10 }, // not a verdict, dropped
]); // => { difficultySum: 70, gradedCount: 2 }Analytics windows
The detail view reads over a rolling window that always ends with today, half-open so the current UTC day is included, unlike the calendar-month entitlement period.
| Export | Definition |
|---|---|
USAGE_ANALYTICS_PERIODS | ["last7", "last30", "season"] |
DEFAULT_USAGE_ANALYTICS_PERIOD | "last7" |
USAGE_ANALYTICS_SCOPE_KINDS | ["session", "collection"]: what the analytics can be narrowed to. |
UsageAnalyticsScope | { kind: UsageAnalyticsScopeKind; id: string } |
USAGE_ANALYTICS_TOP_DEALS | 5, deals kept in the top-deals ranking of a window. |
| Function | Signature | Description |
|---|---|---|
getSeasonStart | (now: Date) => Date | Bridge seasons open on 1 July: this year's from July on, last year's before. |
resolveUsageAnalyticsWindow | (period: UsageAnalyticsPeriod, now: Date) => UsagePeriod | Half-open UTC window of a preset, ending at the start of tomorrow. last7 and last30 count today as their last day. |
const now = new Date("2026-10-08T15:30:00Z");
resolveUsageAnalyticsWindow("last7", now);
// => { start: 2026-10-02T00:00:00Z, end: 2026-10-09T00:00:00Z } (today included, half-open)
resolveUsageAnalyticsWindow("season", now).start; // => 2026-07-01T00:00:00Z
getSeasonStart(new Date("2026-03-01T00:00:00Z")); // => 2025-07-01T00:00:00ZApplication
ServesService
createServesService(deps: ServesServiceDeps): ServesService, with deps = { serves: ServesRepository }
Records a serve for every deal resolved by the public API, the anchor for telemetry and billing. No actor context: the resolve path is public. The best-effort policy belongs to the caller; repository failures propagate.
| Method | Signature | Description |
|---|---|---|
recordServe | (input: PersistableServeWrite) => Promise<{ id: string }> | The id is carried to the client as serveId. |
Prop
Type
// Public resolve path: no ServiceContext, the deal may be anonymous.
export function mintServe(serves: ServesRepository, code: string) {
const service = createServesService({ serves });
return service.recordServe({
sessionId: null, // SSR bootstrap: no client session yet
code,
boardId: null,
ownerAccountId: null,
diffusionDecidedAt: null,
adsSponsorshipId: null,
viewerAccountId: null,
playerAccountId: null, // an ad_free player would pay the serve instead
}); // => { id }: carried to the player as `serveId`
}UsageStatsService
createUsageStatsService(deps: UsageStatsServiceDeps): UsageStatsService
Read-only view of the subject's current-period play activity and diffusions_per_period entitlement, for the usage tile. Not an enforcement path: quota debit happens at action time, never here. The read is about one grid of plans, the one the asking app sells: the period shown is the quota month of that grid's active subscription grant, else the UTC calendar month. quotaUsed and diffusionsLimit add up the counters and caps of every active grant whatever its grid, since the debit draws on all of them. The ad-served count uses the period's exact bounds; the play counts come from daily rollups, so a window opening mid-day counts that whole UTC day.
| Method | Signature | Description |
|---|---|---|
getForSubject | (subjectId: string, gridKey: string) => Promise<UsageStats> | Throws NotFoundError (PLAN_GRID_NOT_FOUND) when no grid has the key. Resolves the rollup boundary before the counts, so a run landing mid-read can only move the boundary back on the next read, never double-count. |
Prop
Type
Prop
Type
export async function usageTile(deps: UsageStatsServiceDeps, subjectId: string) {
const stats = createUsageStatsService(deps);
try {
const usage = await stats.getForSubject(subjectId, "teachers");
usage.servesCount; // rolled-up days + live count since the watermark
usage.diffusionsLimit; // Number.POSITIVE_INFINITY when unlimited
usage.ranking; // null until the daily job has placed the subject
return usage;
} catch (error) {
if (isAppError(error)) {
error.code; // => "PLAN_GRID_NOT_FOUND"
}
throw error;
}
}UsageAnalyticsService
createUsageAnalyticsService(deps: UsageAnalyticsServiceDeps): UsageAnalyticsService
Read-only detail view of the subject's authored deals over a rolling window: serves per UTC day and the most-served deals, each with its mean stars, its serves per student and its perceived difficulty. Same split as the usage stats: rollups up to the watermark, live telemetry after it, so the curve never shows a hole. The ranking is cut by the read over both spans at once, and the per-deal metrics are then read for its head alone, never for the whole catalogue. Perseverance is the exception: a distinct count of students cannot be summed across days, so it is read from the snapshot the nightly job precomputes at the three preset windows, fresh up to the last closed UTC day.
The view is a plan feature, usage_analytics: a subject without it is refused before anything of its activity is read. A session or a collection narrows the whole view to its deals and numbers them by their slot in it; a scope the subject does not own reads exactly like an unknown one.
| Method | Signature | Description |
|---|---|---|
getForSubject | (subjectId: string, query: UsageAnalyticsQuery) => Promise<UsageAnalytics> | Throws FeatureDisabledError (FEATURE_DISABLED) without the feature, NotFoundError (GROUP_SESSION_NOT_FOUND or DEAL_COLLECTION_NOT_FOUND) when the scope resolves to no deal. Skips the per-deal reads entirely when nothing was served. |
Prop
Type
Prop
Type
Prop
Type
Prop
Type
Prop
Type
export async function usageDetail(
deps: UsageAnalyticsServiceDeps,
subjectId: string,
sessionId: string
) {
const analytics = createUsageAnalyticsService(deps);
try {
const detail = await analytics.getForSubject(subjectId, {
period: "last30",
scope: { kind: "session", id: sessionId }, // only that session's deals, numbered by slot
});
detail.daily; // one zero-filled point per UTC day, today included
detail.topDeals; // at most USAGE_ANALYTICS_TOP_DEALS, with meanStars, servesPerStudent, scopePosition
detail.difficulty; // same deals, ungraded ones dropped
return detail;
} catch (error) {
if (error instanceof FeatureDisabledError) {
error.code; // => "FEATURE_DISABLED": the subject's plan lacks `usage_analytics`
} else if (isAppError(error)) {
error.code; // => "GROUP_SESSION_NOT_FOUND" (unknown, foreign or empty session)
}
throw error;
}
}Rollup boundary
Where a usage read stops summing daily rollups and starts counting raw telemetry. The cut is a watermark, not the calendar: the first UTC day of the window no execution ever covered. The last covered day would jump over a hole and a middle day the job missed would vanish from every figure; the first uncovered one hands that day and everything after it to the live count. Today is never covered, so the watermark is at most today, and a healthy job leaves the boundary exactly where the calendar cut used to be. Both sides slice time the same way (first_event_at for serves, received_at for events), so nothing is counted twice, and the live side has no upper bound: an outage widens it, it never drops a day.
Prop
Type
| Function | Signature | Description |
|---|---|---|
resolveRollupBoundary | (coverage: PlayRollupRunsReadRepository, fromDay: string, now: Date) => Promise<RollupBoundary> | Asks the coverage trace for [fromDay, today) and cuts at the first uncovered closed day, or today. A window opening today is read entirely live without a coverage read. Throws a plain Error for a fromDay that is not a UTC calendar day. |
export async function whereLiveStarts(coverage: PlayRollupRunsReadRepository) {
const boundary = await resolveRollupBoundary(coverage, "2026-10-01", now);
// Every closed day covered => rolledUp: 2026-10-01 → 2026-10-08, liveSince: 2026-10-08T00:00Z
// 2026-10-04 never computed => rolledUp: 2026-10-01 → 2026-10-04, liveSince: 2026-10-04T00:00Z
return boundary;
}PlayRollupsService
createPlayRollupsService(deps: PlayRollupsServiceDeps): PlayRollupsService
Computes one UTC day of the daily usage rollups from serves and telemetry events, and replaces that day's rows atomically. Idempotent: re-running a day over the same source rows yields identical output. The perceived difficulty of a play is graded here by the domain rule and stored as a sum plus a count. Every execution leaves a trace, so a day with no activity never reads like a day the job never reached.
| Method | Signature | Description |
|---|---|---|
rollupDay | (day: string, source: PlayRollupRunSource) => Promise<{ day: string; rows: number }> | Reads the four count sets of the day window in parallel, merges them, replaces the day with the run trace. On failure, traces the failed run through runs with the root cause first and re-throws. An invalid day is rejected before any repository call and traces nothing. |
findMissingDays | (range: UtcDayRange, now: Date) => Promise<string[]> | Closed days of the range no execution ever covered, oldest first. Today is still open, so it was never due. |
summarizeStoredDays | (range: UtcDayRange) => Promise<StoredPlayRollupRows> | The rows the range holds, anonymous deals included, and how many carry graded results: what a re-roll of those days would replace. Writes nothing. The dry run of pnpm cli backfill-play-rollups. |
The nightly job (apps/api/src/worker/rollup-daily.ts) and the backfill-play-rollups command of apps/cli call the same rollupDay; the command passes "backfill" as the source. StoredPlayRollupRows is { rows: number; graded: number }; the service reads it through the stored dependency, a PlayRollupsReadRepository.
Prop
Type
DEFAULT_ROLLUP_TOPICS is { rollupDaily: "telemetry.rollup-daily" }.
| Function | Signature | Description |
|---|---|---|
utcDayWindow | (day: string) => UtcDayWindow | Half-open [00:00, 24:00) UTC window for a YYYY-MM-DD day; throws on anything else. |
previousUtcDay | (now: Date) => string | The last closed UTC day. |
lookbackDayRange | (now: Date, days: number) => UtcDayRange | The days closed UTC days preceding now; throws when days < 1. |
uncoveredDays | (range: UtcDayRange, covered: ReadonlySet<string>, today: string) => string[] | Closed days of the range missing from covered, oldest first; today and later are never due. |
mergeDailyCounts | (day: string, serves: ServeDailyCount[], playEvents: PlayEventDailyCount[], resultStars: ResultStarsDailyCount[], difficultySamples: DifficultySample[]) => PlayRollupRow[] | Full outer merge of the count sets keyed by (ownerAccountId, code), zero-filling missing sides, a null owner kept distinct from any account. Difficulty samples are graded per key. |
utcDayWindow("2026-10-07"); // => { start: 2026-10-07T00:00:00Z, end: 2026-10-08T00:00:00Z }
previousUtcDay(now); // => "2026-10-07"
lookbackDayRange(now, 3); // => { fromDay: "2026-10-05", toDayExclusive: "2026-10-08" }
uncoveredDays(
{ fromDay: "2026-10-05", toDayExclusive: "2026-10-09" },
new Set(["2026-10-05", "2026-10-07"]),
"2026-10-08"
); // => ["2026-10-06"]: today and later are never due
DEFAULT_ROLLUP_TOPICS.rollupDaily; // => "telemetry.rollup-daily"
export async function nightlyRollup(deps: PlayRollupsServiceDeps, lookbackDays: number) {
const rollups = createPlayRollupsService(deps);
const today = new Date();
// Yesterday first, then any closed day a previous run never covered.
await rollups.rollupDay(previousUtcDay(today), "cron"); // => { day, rows }
for (const day of await rollups.findMissingDays(lookbackDayRange(today, lookbackDays), today)) {
await rollups.rollupDay(day, "retry");
}
}UsagePerseveranceWindowsService
createUsagePerseveranceWindowsService(deps: UsagePerseveranceWindowsServiceDeps): UsagePerseveranceWindowsService
Precomputes distinct-student counts per owner, deal and window for the three presets. One atomic replacement per day, so readers never see a partial table. Coverage ends at the last closed UTC day: the current day is excluded, so a serve on today is never double-counted between two runs.
| Method | Signature | Description |
|---|---|---|
recomputeAll | (now: Date) => Promise<{ throughDay: string; rows: number }> | Resolves the windows, counts them in one scan, replaces the snapshot. Safe to re-run. |
previewAll | (now: Date) => Promise<{ throughDay: string; windows: Record<UsageAnalyticsPeriod, UsagePeriod>; rows: number }> | The windows recomputeAll would count and how many rows it would write; writes nothing. The dry run of pnpm cli recompute-perseverance-windows. |
| Function | Signature | Description |
|---|---|---|
resolvePerseveranceWindows | (now: Date) => Record<UsageAnalyticsPeriod, UsagePeriod> | Windows ending at the last closed UTC day, resolved by the same rule as the read side applied to yesterday. |
OwnerPeriodActivityService
createOwnerPeriodActivityService(deps: OwnerPeriodActivityServiceDeps): OwnerPeriodActivityService
Ranks every active owner by serves over the current period and replaces that period's standings. Ranking is a cross-account scan, so it runs once a day here and no read path performs it. Runs after the daily rollup and only over closed days, so a replay the same day converges on the same rows.
| Method | Signature | Description |
|---|---|---|
rankPeriodToDate | (now: Date) => Promise<{ periodStart: string; owners: number }> | Sums the rolled-up serves per owner from the period's first day to today exclusive, ranks them, and replaces the period with the standings, the active account count and the last closed day. |
previewPeriodToDate | (now: Date) => Promise<{ range: UtcDayRange; owners: number }> | The closed days rankPeriodToDate would rank and how many owners it would place; writes nothing. The dry run of pnpm cli rank-period-standings. |
| Function | Signature | Description |
|---|---|---|
rankOwnersByServes | (counts: OwnerServesCount[]) => OwnerPeriodStanding[] | Serves descending, ties sharing a rank (one plus the owners strictly ahead), position dense and unique. Owners without a serve are not ranked. Pure: leaves its input untouched. |
rankOwnersByServes([
{ ownerAccountId: "b", served: 10 },
{ ownerAccountId: "a", served: 10 },
{ ownerAccountId: "c", served: 4 },
{ ownerAccountId: "d", served: 0 },
]);
// => [ { a, served 10, rank 1, position 1 }, { b, served 10, rank 1, position 2 },
// { c, served 4, rank 3, position 3 } ]: owners without a serve are not ranked
resolvePerseveranceWindows(now).last7;
// => { start: 2026-10-01T00:00:00Z, end: 2026-10-08T00:00:00Z }: ends at the last closed day
export async function nightlyJobs(
activity: OwnerPeriodActivityServiceDeps,
perseverance: UsagePerseveranceWindowsServiceDeps
) {
const standings = createOwnerPeriodActivityService(activity);
const windows = createUsagePerseveranceWindowsService(perseverance);
const today = new Date();
await standings.previewPeriodToDate(today); // => { range, owners }: writes nothing
await standings.rankPeriodToDate(today); // => { periodStart, owners }
await windows.recomputeAll(today); // => { throughDay, rows }
}Ports
The ports split along the tables. The read model the job writes, the rollups, their execution trace and the perseverance snapshot, is read through canonical repositories; the serves are written through one. The jobs that build a snapshot from raw telemetry and the reads that assemble one result from several tables keep a bespoke surface, ratified in Bespoke by design.
ServesRepository: append-only, writeOne only.
| Method | Signature | Description |
|---|---|---|
writeOne | (input: PersistableServeWrite) => Promise<{ serveId: string }> | One serve per deal resolved; the player's first telemetry event later stamps it displayed. |
PlayRollupsReadRepository: aggregate over the daily rollups, scoped by a PlayRollupCriteria.
| Export | Definition |
|---|---|
PlayRollupWhere | { ownerAccountId?: Equality<string> & Nullability; code?: Equality<string>; days?: UtcDayRange }. isNull: false drops the anonymous editor deals. |
PlayRollupCriteria | Static ofOwner(ownerAccountId, range), ownedWithin(range), within(range) (anonymous deals included), where; instance withCodes(codes), a no-op on undefined, and and. |
PlayRollupAggregations | servesByOwner(criteria) → OwnerServesCount[], servesByDay(criteria) → UsageDailyServes[] oldest first, metricsByCode(criteria) → UsageDealRollupMetrics[]: star and difficulty sums read together, since they share the key; storedRows(criteria) → StoredPlayRollupRows, the rows counted and the ones with resultsCount > 0. |
export async function rolledUpCurve(
rollups: PlayRollupsReadRepository,
ownerAccountId: string,
sessionCodes: string[] | undefined
) {
const scope = PlayRollupCriteria.ofOwner(ownerAccountId, {
fromDay: "2026-10-01",
toDayExclusive: "2026-10-08",
}).withCodes(sessionCodes); // undefined keeps every deal of the owner
const daily = await rollups.aggregate(PlayRollupAggregations.servesByDay(scope));
// => [{ day: "2026-10-02", served: 4 }, …]: oldest first, empty days absent
const metrics = await rollups.aggregate(PlayRollupAggregations.metricsByCode(scope));
// => [{ code, starsSum, resultsCount, difficultySum, gradedCount }, …]
return { daily, metrics };
}PlayRollupRunsReadRepository and PlayRollupRunsRepository: the trace of the rollup executions. A day with no succeeded execution was never computed, which is not a day computed and found empty. A succeeded execution is traced by replaceDay, in the transaction that replaces its day; writeOne traces a failed one, outside the transaction it rolled back.
| Export | Definition |
|---|---|
PlayRollupRunWhere | { succeeded?: boolean; days?: UtcDayRange }. |
PlayRollupRunCriteria | Static succeededWithin(range), where; instance and. |
PlayRollupRunAggregations | days(criteria) → string[], the distinct days of the executions it scopes. |
| Method | Signature | Description |
|---|---|---|
aggregate | <T>(aggregation: Aggregation<T>) => Promise<T> | Read side. |
writeOne | (run: FailedPlayRollupRun) => Promise<void> | Write side: a failed execution. |
UsagePerseveranceReadRepository: findMany over the precomputed perseverance snapshot. UsagePerseveranceQuery.empty().byOwner(id).inWindow(kind).withCodes(codes) with the UsagePerseveranceProjections.perDeal() projection returns UsageDealPerseverance[]; an empty code list matches nothing and costs no round trip. A deal absent from the snapshot has nothing to report.
PlayRollupsRepository: the nightly computation of a day, bespoke. Daily aggregates over serves and telemetry events, one row per (owner account, deal code, UTC day). A serve counts once it was displayed, never at resolve; every other counter counts plays, and replaying a deal counts again. Aggregates outlive their sources: no foreign keys, permanent retention.
Prop
Type
| Method | Signature | Description |
|---|---|---|
countServes | (window: UtcDayWindow) => Promise<ServeDailyCount[]> | Displayed serves grouped by (owner, code), bucketed on first_event_at. |
countPlayEvents | (window: UtcDayWindow) => Promise<PlayEventDailyCount[]> | Play-entered and play-finished events counted one by one, bucketed on the event's received_at. Events without a resolvable serve are excluded. |
countResultStars | (window: UtcDayWindow) => Promise<ResultStarsDailyCount[]> | Star sums over every result reported in the window; a deal replayed twice weighs twice. |
listDifficultySamples | (window: UtcDayWindow) => Promise<DifficultySample[]> | One sample per graded result, carrying the measures the difficulty rule needs; a measure never reported is null. |
replaceDay | (day: string, rows: PlayRollupRow[], run: PlayRollupRun) => Promise<void> | Atomically replaces the day's rows and traces the execution. |
PlayRollupKey is { ownerAccountId: string | null; code: string }; ServeDailyCount, PlayEventDailyCount, ResultStarsDailyCount and DifficultySample (the key plus a PerceivedDifficultyPlay) extend it. PlayRollupRunSource is "cron" | "retry" | "backfill"; PlayRollupRun is { source; durationMs } and FailedPlayRollupRun adds day and reason. UtcDayWindow is { start: Date; end: Date }, half-open.
PlayUsageReader: the reads of the usage views that span several tables, bespoke. Days the job covered come from the rollups; everything after the watermark is counted live from displayed serves and raw telemetry, on the same rule as the rollups. Reads that share a table, a key and a window are one method, not several.
UtcDayRange is { fromDay: string; toDayExclusive: string }, both YYYY-MM-DD. UsageDealCodes is string[] | undefined: the codes a read is restricted to, undefined reading every deal of the owner.
| Method | Signature | Description |
|---|---|---|
readPeriodCounters | (ownerAccountId: string, query: { range: UtcDayRange; liveSince: Date; period: UsagePeriod }) => Promise<PlayUsageTotals & { adServed: number }> | The period's counters in one pass: rolled-up days and the live span summed per metric, plus the ad-served serves of the exact window. |
readTopDealsByServes | (ownerAccountId: string, query: UsageTopDealsQuery) => Promise<UsageTopDeals> | Ranks the deals of the window over rolled-up days and the live span merged, keeping the head. Also returns the live serves per day over every deal of the scope, ranked or not. |
readLiveMetricsByCode | (ownerAccountId: string, since: Date, codes?: UsageDealCodes) => Promise<{ stars: UsageDealStars[]; difficultySamples: UsageDifficultySample[] }> | Star sums and difficulty samples of the live span. Plays whose auction errors were never measured weigh on the stars but yield no sample. |
readOwnerRanking | (ownerAccountId: string, query: UsageRankingQuery) => Promise<UsageRanking | null> | The subject's precomputed standing and the rows around it, read by key, never ranked here. Null until the daily job has placed the subject. |
Prop
Type
Prop
Type
Prop
Type
Prop
Type
Prop
Type
Prop
Type
Prop
Type
UsagePerseveranceWindowsRepository: the nightly computation of the perseverance snapshot, for the job alone, bespoke; the rows are read through UsagePerseveranceReadRepository.
| Method | Signature | Description |
|---|---|---|
countAllWindows | (windows: Record<UsageAnalyticsPeriod, UsagePeriod>) => Promise<UsagePerseveranceWindowRow[]> | One scan of the serves, one row per (owner, code) and preset window with the serves count and the distinct-session count. Serves without a client session or without an owner are excluded. |
replaceAll | (snapshot: UsagePerseveranceWindowsSnapshot) => Promise<void> | Atomic wipe and insert of the whole table. |
Prop
Type
OwnerPeriodActivityRepository: write side of the precomputed standings, one row per (owner, period), filled by the daily job, bespoke. The serves it ranks are read through PlayRollupsReadRepository, the active accounts through the accounts' aggregate.
| Method | Signature | Description |
|---|---|---|
replacePeriod | (snapshot: OwnerPeriodSnapshot) => Promise<void> | Atomically replaces the period's rows. |
Prop
Type
Prop
Type
Errors
| Error | Code | Thrown by |
|---|---|---|
NotFoundError | PLAN_GRID_NOT_FOUND | UsageStatsService.getForSubject for an unknown grid key. |
FeatureDisabledError | FEATURE_DISABLED | UsageAnalyticsService.getForSubject when the subject's entitlements lack usage_analytics. |
NotFoundError | GROUP_SESSION_NOT_FOUND, DEAL_COLLECTION_NOT_FOUND | UsageAnalyticsService.getForSubject when the scope is unknown, foreign or empty. |
utcDayWindow, lookbackDayRange and resolveRollupBoundary throw a plain Error on malformed day inputs. See Errors for the base classes.