@typetorch/framework 0.3.2 → 0.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -1
- package/out/analytics/SCHEMA.md +13 -6
- package/out/analytics/engine.d.ts +8 -2
- package/out/analytics/engine.luau +31 -26
- package/out/analytics/protocol.d.ts +4 -1
- package/out/analytics/protocol.luau +6 -3
- package/out/analytics/server.d.ts +14 -0
- package/out/analytics/server.luau +157 -48
- package/out/devtools/admin-server.d.ts +4 -1
- package/out/devtools/admin-server.luau +5 -5
- package/out/devtools/admin-ui.luau +12 -2
- package/out/devtools/assets-ui.d.ts +10 -0
- package/out/devtools/assets-ui.luau +51 -26
- package/out/devtools/client.luau +546 -221
- package/out/devtools/feeds.d.ts +53 -0
- package/out/devtools/feeds.luau +200 -0
- package/out/devtools/health.d.ts +18 -2
- package/out/devtools/health.luau +183 -25
- package/out/devtools/layout.d.ts +142 -0
- package/out/devtools/layout.luau +766 -0
- package/out/devtools/network-inspector.d.ts +2 -0
- package/out/devtools/network-inspector.luau +302 -167
- package/out/devtools/panes.d.ts +60 -0
- package/out/devtools/panes.luau +1388 -0
- package/out/devtools/server.luau +22 -1
- package/out/devtools/state-ui.d.ts +5 -0
- package/out/devtools/state-ui.luau +12 -5
- package/out/kernel.d.ts +117 -3
- package/out/kernel.luau +13 -1
- package/out/version.d.ts +1 -1
- package/out/version.luau +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -120,6 +120,17 @@ export class CoinService extends Module implements OnStart {
|
|
|
120
120
|
logs (server, own client, other players' clients), client and server dex, network stats, module state, a branch and
|
|
121
121
|
build picker (Server > Branch) and a Claude prompt. Prod-channel servers are read-only. The window can be dragged by
|
|
122
122
|
its header and resized from its corner (double-tap the header to reset).
|
|
123
|
+
- **Panes and windows (dev menu):** every page shows in a pane with a small header: tap the title to pick another
|
|
124
|
+
page, or use Split right, Split down, Open in new window and Close. Drag the line between two panes to resize them.
|
|
125
|
+
The sidebar opens pages in the focused pane (the highlighted one; tap a pane to focus it). Floating windows move by
|
|
126
|
+
their title bar and resize from their corner (double-tap the title bar to maximise); their title bar also has
|
|
127
|
+
Minimise, Dock (back into the main panel) and Close. Windows can be split too. The layout, the windows and each
|
|
128
|
+
pane's own settings (for example the open nodes of two Modules > State panes) survive swaps; a fresh join starts
|
|
129
|
+
with one pane. Claude, Server > Branch and the Manage pages open once (picking them again focuses their pane); every
|
|
130
|
+
other page can be open several times. Panes nobody sees (menu closed, window minimised) stop refreshing, and panes
|
|
131
|
+
on the same data share one request (Status, Overview, Assets, Network > Stats, server Logs, Packets); State panes
|
|
132
|
+
share one query budget. On phones and small screens panes only stack (two at most), and windows open maximised with
|
|
133
|
+
one pane each.
|
|
123
134
|
- **State explorer (Modules > State, Server | Client):** every live module of the running generation (services on the
|
|
124
135
|
server, controllers on the client) and the persist store. Tap a row to open it: the module's own fields (not its
|
|
125
136
|
methods), then tables, arrays, Maps and Sets, 100 entries per page (Prev / Next). Each value has a short preview:
|
|
@@ -297,7 +308,9 @@ const variant = analytics.experiment("onboarding", ["short", "long"]); // same a
|
|
|
297
308
|
and the next generation sends them. In edit mode (UI Labs) it is inert.
|
|
298
309
|
- **Server calls take the player first** (`track(player, ...)`); without one an event is server-only (no player id).
|
|
299
310
|
Client calls are always about the local player and are marked `src = "client"` (a client can lie). The server
|
|
300
|
-
checks their shape, size (props at most 4 KB) and rate.
|
|
311
|
+
checks their shape, size (props at most 4 KB) and rate. `purchase` and `currency` are server-only (revenue and the
|
|
312
|
+
economy are server-authoritative): on the client they warn once and send nothing, and the server refuses them from
|
|
313
|
+
clients.
|
|
301
314
|
- **Options** (all on by default): `sessions` (joins, leaves, device, join source, account age bucket, Premium,
|
|
302
315
|
country, friends in the server, first-ever vs returning, days since the last visit), `tech` (FPS, ping, memory, load
|
|
303
316
|
time, client and server errors, swaps and rollbacks, leaves within 60 s of a swap), `zones` (parts or models tagged
|
|
@@ -328,6 +341,16 @@ const variant = analytics.experiment("onboarding", ["short", "long"]); // same a
|
|
|
328
341
|
`flushSeconds` (15) or at 500 rows, at most ~10 HttpService requests a minute, retries with backoff, and a last
|
|
329
342
|
flush on shutdown (kernel 0.3.2). Delivery is at least once (a swap mid-request sends that batch again).
|
|
330
343
|
`analytics.stats()` (server) has the counters; `flush()` sends soon.
|
|
344
|
+
- **The cloud test sends nothing.** `typetorch test --cloud` boots the payload headless in a Luau Execution task where
|
|
345
|
+
HttpService works; there the engine (stub kernel `test = true`, or `workspace:GetAttribute("TypeTorchTest")`) collects
|
|
346
|
+
as usual but never uploads: no event rows, no identity rows, no HTTP request, so a prod deploy never puts a fake
|
|
347
|
+
server session into your analytics.
|
|
348
|
+
- **Bounds on what a client can cause:** at most 32 experiments per session (each is stamped on every later row), and
|
|
349
|
+
a client's `exp` messages count against its event budget (120 a minute, 5,000 a session). Clients can't send
|
|
350
|
+
`purchase` or `currency` rows; the analytics server's ingest also refuses client-marked ones (older engines) and its
|
|
351
|
+
revenue queries count `purchase` rows the server sent.
|
|
352
|
+
- **Friends in the server** (the join's `friends`): one `Players:GetFriendsAsync` per join (at most 10 pages, none
|
|
353
|
+
when the player is alone) intersected with the players there, not a web call per player.
|
|
331
354
|
|
|
332
355
|
**Settings** (server only, never sent to clients): the ConfigService key `TypeTorchAnalytics`, written with
|
|
333
356
|
`writeSettings()` from `@typetorch/analytics` (Open Cloud, universe:write) or in Creator Hub (Configs); no CLI command
|
|
@@ -367,6 +390,8 @@ bun run build # rbxtsc --type package -> out/
|
|
|
367
390
|
`scripts/test-state-inspect.luau` checks the state explorer's core (previews, raw reads only, cycles, paging,
|
|
368
391
|
Maps/Sets with Instance keys, the depth limit, filters, request validation) and that `src/version.ts` matches
|
|
369
392
|
package.json.
|
|
393
|
+
`scripts/test-layout.luau` checks the dev menu's pane layout core (`devtools/layout.ts`: splits and their limits,
|
|
394
|
+
closing, pop out and dock, singleton pages, ratios, focus, and cleaning a remembered layout).
|
|
370
395
|
`scripts/test-analytics.luau` checks the analytics engine's pure parts (experiment assignment, settings, the queue
|
|
371
396
|
and HTTP budget, tt-rec-1, the sink request bodies); `test-analytics-server.luau` and `test-analytics-client.luau`
|
|
372
397
|
run the compiled server and client cores against mocked services (sessions, intake, retries, a swap with a request
|
package/out/analytics/SCHEMA.md
CHANGED
|
@@ -31,7 +31,7 @@ Integers are written with every digit (never exponent notation).
|
|
|
31
31
|
| `state` | string | The player's state **after** this event: `zone:<z>\|screen:<s>\|activity:<a>`, empty parts left out (`""` when nothing is known). Server-only events: `activity:<server activity>` or `""` |
|
|
32
32
|
| `exp` | string | JSON object of the player's active experiments, `{"onboarding":"short"}`; `{}` when none |
|
|
33
33
|
| `sexp` | string | The server's experiment: the artifact id of a kernel A/B experiment pin, or `""` |
|
|
34
|
-
| `src` | string | `server`, or `client` (sent by a client: it can lie; the server checked shapes and rate) |
|
|
34
|
+
| `src` | string | `server`, or `client` (sent by a client: it can lie; the server checked shapes and rate). Never `client` on `purchase` or `currency` rows |
|
|
35
35
|
| `props` | string | JSON object text, at most 4096 bytes. Too large: `{"_trunc":<bytes>}`; not encodable: `{"_err":"encode"}`; a non-object value `x`: `{"value":x}` |
|
|
36
36
|
|
|
37
37
|
### recordings
|
|
@@ -57,7 +57,7 @@ First-ever-session detail, one row per packed chunk.
|
|
|
57
57
|
|
|
58
58
|
| kind | name | props |
|
|
59
59
|
|---|---|---|
|
|
60
|
-
| session | `join` | `from`: `direct` (Home, search, sorts: Roblox doesn't say), `teleport` (same game), `teleport_game` (another game), `referral` (invite/referral link), `share` (launch data), `follow` (followed someone); `ctx?` (GameJoinContext.JoinSource name); `tp?` (had teleport data); `party?` (players teleported along); `age` (account age: `<1d`, `1-7d`, `7-30d`, `30-90d`, `90-365d`, `1-3y`, `3y+`); `prem` (Premium); `country?` (ISO code); `friends` (friends in the server, at most
|
|
60
|
+
| session | `join` | `from`: `direct` (Home, search, sorts: Roblox doesn't say), `teleport` (same game), `teleport_game` (another game), `referral` (invite/referral link), `share` (launch data), `follow` (followed someone); `ctx?` (GameJoinContext.JoinSource name); `tp?` (had teleport data); `party?` (players teleported along); `age` (account age: `<1d`, `1-7d`, `7-30d`, `30-90d`, `90-365d`, `1-3y`, `3y+`); `prem` (Premium); `country?` (ISO code); `friends` (how many of the player's friends are in the server: one friend-list lookup per join, at most 10 pages, `0` when it fails); `ret` (days since the last visit, `-1` first visit or unknown); `late?` (the engine started over 60 s after the join). `t` is the join time |
|
|
61
61
|
| session | `device` (src client) | `input` (`kbm`, `touch`, `gamepad`, `vr`, `unknown`), `w`, `h` (viewport points), `touch`, `kb`, `mouse`, `pad`, `vr`. Once per session |
|
|
62
62
|
| session | `leave` | `secs` (session length), `why`: `left`, `shutdown`, `gone` (left while no generation ran the engine), `swap60?` (left within 60 s of a hot swap) |
|
|
63
63
|
| tech | `server` | Every `techEvery` s: `fps` (physics), `hb` (Heartbeat rate), `mem` (MB, 10s), `players`, `ping50?`, `ping90?` (ms), `q`, `qr` (queued events, recordings), `dropped`, `sent`, `fails`, `health?`, `errors?` (kernel 0.3.2 health) |
|
|
@@ -71,17 +71,21 @@ First-ever-session detail, one row per packed chunk.
|
|
|
71
71
|
| zone | `leave` | `zone`, `to?`, `secs` |
|
|
72
72
|
| state | `activity` | `to`, `from` (server or client src) |
|
|
73
73
|
| state | `screen` | `to`, `from` (`""` = none). Client: ScreenGuis in PlayerGui (Enabled), GuiObjects tagged `TTScreen` (Visible), `screen()` |
|
|
74
|
-
| experiment | `<experiment>` | `variant`, `variants` (count), `forced?`. Once per session when assigned or changed |
|
|
74
|
+
| experiment | `<experiment>` | `variant`, `variants` (count), `forced?`. Once per session when assigned or changed. At most 32 experiments per session: a 33rd `experiment()` returns the control (the first variant) and warns. A client's experiment calls count against its event budget (below) |
|
|
75
75
|
| recording_meta | `start` | `share` (the recorded share then) |
|
|
76
76
|
| recording_meta | `end` | `chunks`, `bytes`, `why`: `window` (60 s after the first input), `cap` (10 min, or the server's 256 KB / 80 chunks), `left`, `end` |
|
|
77
77
|
| fleet | `heartbeat` | The kernel's `fleetStatus()` (kernel 0.3.2+), every 60 ± 10 s and once at each generation start. pid `""` |
|
|
78
78
|
| fleet | `deploy_report` | One kernel deploy report `{s, b, a, j, r, e?, d?, t, g, k, p}` (r: `swapped`, `failed`, `rolled_back`, `skipped`, `booted`), once per (s, j, r) per server, across generations. pid `""` |
|
|
79
79
|
| custom | `<name>` | `track(name, props)`: the game's props |
|
|
80
80
|
| funnel | `<funnel>` | `step(funnel, index, name?)`: `i`, `step?` |
|
|
81
|
-
| purchase | `<kind>` | `purchase({product, robux, where?, kind?})`: name = `kind` (default `product`); `product`, `robux`, `where?` |
|
|
82
|
-
| currency | `<currency>` | `currency(name, delta, reason)`: `delta`, `reason` |
|
|
81
|
+
| purchase | `<kind>` | Server only. `purchase(player?, {product, robux, where?, kind?})`: name = `kind` (default `product`); `product`, `robux`, `where?` |
|
|
82
|
+
| currency | `<currency>` | Server only. `currency(player?, name, delta, reason)`: `delta`, `reason` |
|
|
83
83
|
|
|
84
|
-
Client-sent kinds are limited to `custom`, `funnel`, `
|
|
84
|
+
Client-sent kinds are limited to `custom`, `funnel`, `state`, `tech`, within a budget of 120 a minute and 5,000 a
|
|
85
|
+
session per player. `purchase` and `currency` are server-only (revenue and the economy are server-authoritative): on
|
|
86
|
+
the client `purchase()` and `currency()` warn once and send nothing, the server refuses those kinds from clients, and
|
|
87
|
+
the analytics server's ingest refuses such rows marked `src = client` (older engines let clients send them). Revenue
|
|
88
|
+
counts only server-sent `purchase` rows; rows from before `src` existed (no `src`) still count.
|
|
85
89
|
|
|
86
90
|
## tt-rec-1
|
|
87
91
|
|
|
@@ -174,6 +178,9 @@ Unknown fields are ignored. Settings errors are warned once (never with the toke
|
|
|
174
178
|
other 4xx drop the batch (counted as rejected).
|
|
175
179
|
- **Delivery is at least once:** a hot swap during a request sends its rows again. Readers may drop exact duplicate
|
|
176
180
|
rows.
|
|
181
|
+
- **The cloud test sends nothing:** inside `typetorch test --cloud` (the stub kernel's `test = true`, or the workspace
|
|
182
|
+
attribute `TypeTorchTest`) rows are collected as usual but dropped at each flush: no event, recording or identity
|
|
183
|
+
rows and no HTTP request, so a deploy never puts a fake server session into the analytics.
|
|
177
184
|
- **Queue:** 10,000 events (1,000 while no settings are known), 2,000 fleet rows, 300 recording chunks; over a cap the
|
|
178
185
|
oldest are dropped and counted (`tech/server` `dropped`). The queue lives in the kernel's persist store across hot
|
|
179
186
|
swaps and is flushed on shutdown (kernel 0.3.2 onClose; older kernels: one BindToClose relay).
|
|
@@ -15,10 +15,16 @@ export declare class AnalyticsEngine {
|
|
|
15
15
|
/** A funnel step, e.g. `step("onboarding", 3, "opened_shop")`. Indexes start at 1 by convention. */
|
|
16
16
|
step(funnel: string, index: number, name?: string): void;
|
|
17
17
|
step(player: Player, funnel: string, index: number, name?: string): void;
|
|
18
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* A Robux purchase (log it once the receipt is granted). The event name is `kind` ("product" by default). Server
|
|
20
|
+
* only: revenue is server-authoritative, so on the client this warns once and sends nothing.
|
|
21
|
+
*/
|
|
19
22
|
purchase(purchase: AnalyticsPurchase): void;
|
|
20
23
|
purchase(player: Player, purchase: AnalyticsPurchase): void;
|
|
21
|
-
/**
|
|
24
|
+
/**
|
|
25
|
+
* Currency in (+) or out (-), e.g. `currency(player, "coins", 50, "round_reward")`. Server only: the economy is
|
|
26
|
+
* server-authoritative, so on the client this warns once and sends nothing.
|
|
27
|
+
*/
|
|
22
28
|
currency(name: string, delta: number, reason: string): void;
|
|
23
29
|
currency(player: Player, name: string, delta: number, reason: string): void;
|
|
24
30
|
/**
|
|
@@ -26,6 +26,17 @@ local function isPlayer(value)
|
|
|
26
26
|
end
|
|
27
27
|
return _condition
|
|
28
28
|
end
|
|
29
|
+
--* Server-only calls made on the client: warned once each per generation; the server refuses these kinds from clients.
|
|
30
|
+
local warnedServerOnly = {}
|
|
31
|
+
local function serverOnly(call)
|
|
32
|
+
local _call = call
|
|
33
|
+
if warnedServerOnly[_call] ~= nil then
|
|
34
|
+
return nil
|
|
35
|
+
end
|
|
36
|
+
local _call_1 = call
|
|
37
|
+
warnedServerOnly[_call_1] = true
|
|
38
|
+
warn("[src/analytics/engine.ts:31]", `[analytics] {call}() is server-only (revenue and currency are server-authoritative): the client sends nothing`)
|
|
39
|
+
end
|
|
29
40
|
local function currentServer(options)
|
|
30
41
|
if serverCore then
|
|
31
42
|
return serverCore
|
|
@@ -87,11 +98,11 @@ do
|
|
|
87
98
|
local existing = serverCore ~= nil
|
|
88
99
|
self.server = currentServer(options)
|
|
89
100
|
if existing and options.settings ~= nil then
|
|
90
|
-
warn("[src/analytics/engine.ts:
|
|
101
|
+
warn("[src/analytics/engine.ts:75]", "[analytics] an engine already runs: its settings stay")
|
|
91
102
|
end
|
|
92
103
|
else
|
|
93
104
|
if options.settings ~= nil then
|
|
94
|
-
warn("[src/analytics/engine.ts:
|
|
105
|
+
warn("[src/analytics/engine.ts:77]", "[analytics] settings are read on the server only; the client ignores them")
|
|
95
106
|
end
|
|
96
107
|
self.client = currentClient(options)
|
|
97
108
|
end
|
|
@@ -150,6 +161,10 @@ do
|
|
|
150
161
|
end
|
|
151
162
|
end
|
|
152
163
|
function AnalyticsEngine:purchase(first, second)
|
|
164
|
+
if self.client then
|
|
165
|
+
serverOnly("purchase")
|
|
166
|
+
return nil
|
|
167
|
+
end
|
|
153
168
|
local player = if isPlayer(first) then first else nil
|
|
154
169
|
local info = (if player then second else first)
|
|
155
170
|
local _condition = not info
|
|
@@ -162,7 +177,7 @@ do
|
|
|
162
177
|
end
|
|
163
178
|
end
|
|
164
179
|
if _condition then
|
|
165
|
-
warn("[src/analytics/engine.ts:
|
|
180
|
+
warn("[src/analytics/engine.ts:129]", "[analytics] purchase({ product, robux }) needs numbers")
|
|
166
181
|
return nil
|
|
167
182
|
end
|
|
168
183
|
local _kind = info.kind
|
|
@@ -180,37 +195,27 @@ do
|
|
|
180
195
|
if _result ~= nil then
|
|
181
196
|
_result:track(player, "purchase", kind, props)
|
|
182
197
|
end
|
|
183
|
-
local _result_1 = self.client
|
|
184
|
-
if _result_1 ~= nil then
|
|
185
|
-
_result_1:track("purchase", kind, props)
|
|
186
|
-
end
|
|
187
198
|
end
|
|
188
199
|
function AnalyticsEngine:currency(first, second, third, fourth)
|
|
200
|
+
if self.client then
|
|
201
|
+
serverOnly("currency")
|
|
202
|
+
return nil
|
|
203
|
+
end
|
|
189
204
|
if isPlayer(first) then
|
|
190
|
-
local props = {
|
|
191
|
-
delta = third,
|
|
192
|
-
reason = fourth,
|
|
193
|
-
}
|
|
194
205
|
local _result = self.server
|
|
195
206
|
if _result ~= nil then
|
|
196
|
-
_result:track(first, "currency", second,
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
_result_1:track("currency", second, props)
|
|
207
|
+
_result:track(first, "currency", second, {
|
|
208
|
+
delta = third,
|
|
209
|
+
reason = fourth,
|
|
210
|
+
})
|
|
201
211
|
end
|
|
202
212
|
else
|
|
203
|
-
local props = {
|
|
204
|
-
delta = second,
|
|
205
|
-
reason = third,
|
|
206
|
-
}
|
|
207
213
|
local _result = self.server
|
|
208
214
|
if _result ~= nil then
|
|
209
|
-
_result:track(nil, "currency", first,
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
_result_1:track("currency", first, props)
|
|
215
|
+
_result:track(nil, "currency", first, {
|
|
216
|
+
delta = second,
|
|
217
|
+
reason = third,
|
|
218
|
+
})
|
|
214
219
|
end
|
|
215
220
|
end
|
|
216
221
|
end
|
|
@@ -281,7 +286,7 @@ do
|
|
|
281
286
|
return self.client:experiment(first, variants)
|
|
282
287
|
end
|
|
283
288
|
if self.server then
|
|
284
|
-
warn("[src/analytics/engine.ts:
|
|
289
|
+
warn("[src/analytics/engine.ts:204]", `[analytics] experiment("{first}") on the server needs the player: experiment(player, name, variants)`)
|
|
285
290
|
end
|
|
286
291
|
local _condition = variants[1]
|
|
287
292
|
if _condition == nil then
|
|
@@ -52,7 +52,10 @@ export interface ServerHello {
|
|
|
52
52
|
/** Live experiment overrides by name. */
|
|
53
53
|
exps: Record<string, ExperimentOverride>;
|
|
54
54
|
}
|
|
55
|
-
/**
|
|
55
|
+
/**
|
|
56
|
+
* Kinds a client may send in "ev". Never `purchase` or `currency`: revenue and the economy are server-authoritative
|
|
57
|
+
* (a client could otherwise add fake payers and Robux), so the server refuses them from clients.
|
|
58
|
+
*/
|
|
56
59
|
export declare const CLIENT_KINDS: Set<string>;
|
|
57
60
|
/** A client's hello, checked field by field (it comes from an untrusted client). */
|
|
58
61
|
export declare function cleanHello(value: unknown): HelloInfo | undefined;
|
|
@@ -38,12 +38,15 @@ local INPUTS = {
|
|
|
38
38
|
["vr"] = true,
|
|
39
39
|
["unknown"] = true,
|
|
40
40
|
}
|
|
41
|
-
|
|
41
|
+
--[[
|
|
42
|
+
*
|
|
43
|
+
* Kinds a client may send in "ev". Never `purchase` or `currency`: revenue and the economy are server-authoritative
|
|
44
|
+
* (a client could otherwise add fake payers and Robux), so the server refuses them from clients.
|
|
45
|
+
|
|
46
|
+
]]
|
|
42
47
|
local CLIENT_KINDS = {
|
|
43
48
|
["custom"] = true,
|
|
44
49
|
["funnel"] = true,
|
|
45
|
-
["purchase"] = true,
|
|
46
|
-
["currency"] = true,
|
|
47
50
|
["state"] = true,
|
|
48
51
|
["tech"] = true,
|
|
49
52
|
}
|
|
@@ -74,6 +74,12 @@ export declare class ServerAnalytics {
|
|
|
74
74
|
private readonly identity;
|
|
75
75
|
private sexp;
|
|
76
76
|
private stopped;
|
|
77
|
+
/**
|
|
78
|
+
* `typetorch test --cloud`: the payload boots headless in a Luau Execution task, where HttpService works. The engine
|
|
79
|
+
* then collects as usual but sends nothing (no rows, no identity rows, no HTTP), or every prod deploy would put a
|
|
80
|
+
* fake server session into the owner's analytics. Set by the stub kernel's `test` or the TypeTorchTest attribute.
|
|
81
|
+
*/
|
|
82
|
+
private readonly testMode;
|
|
77
83
|
constructor(options: AnalyticsOptions, scope: GenerationScope, trove: Trove);
|
|
78
84
|
private serverExperiment;
|
|
79
85
|
private stateOf;
|
|
@@ -109,6 +115,12 @@ export declare class ServerAnalytics {
|
|
|
109
115
|
*/
|
|
110
116
|
private resolve;
|
|
111
117
|
private joinSource;
|
|
118
|
+
/**
|
|
119
|
+
* How many of the player's friends are in this server: one `GetFriendsAsync` per join (at most FRIEND_PAGES_MAX
|
|
120
|
+
* pages) intersected with the players here, instead of an IsFriendsWith call per player. Stops early once every
|
|
121
|
+
* other player was found; after each yield it stops with the count so far if the player left or the generation
|
|
122
|
+
* stopped. No call when the player is alone; a failed call counts what it read (0 at first).
|
|
123
|
+
*/
|
|
112
124
|
private friendsHere;
|
|
113
125
|
private emitJoin;
|
|
114
126
|
private onPlayer;
|
|
@@ -117,6 +129,8 @@ export declare class ServerAnalytics {
|
|
|
117
129
|
private recordingAllowed;
|
|
118
130
|
private sendHello;
|
|
119
131
|
private onHello;
|
|
132
|
+
/** One client-sent event (or an "exp" message, which may add a row): within the player's budget and the session cap. */
|
|
133
|
+
private takeClientEvent;
|
|
120
134
|
private onEvents;
|
|
121
135
|
private onChunk;
|
|
122
136
|
private startIntake;
|
|
@@ -88,6 +88,16 @@ local PENDING_MAX = 200
|
|
|
88
88
|
local CLIENT_BURST = 120
|
|
89
89
|
local CLIENT_PER_MINUTE = 120
|
|
90
90
|
local CLIENT_SESSION_MAX = 5000
|
|
91
|
+
--[[
|
|
92
|
+
*
|
|
93
|
+
* Distinct experiments one session may hold. Each one is re-encoded into `exp` and stamped on every later row, and a
|
|
94
|
+
* client can name experiments ("exp" messages), so without a cap one player could grow `exp` past the ingest limit
|
|
95
|
+
* (1 KB) and have every row of theirs dropped, or spend the server's time re-encoding it.
|
|
96
|
+
|
|
97
|
+
]]
|
|
98
|
+
local EXPERIMENTS_MAX = 32
|
|
99
|
+
--* Friend-list pages read per join for the join's `friends` (one web call each; most players fit on the first).
|
|
100
|
+
local FRIEND_PAGES_MAX = 10
|
|
91
101
|
--* A recording: at most this many bytes, chunks and seconds after the join.
|
|
92
102
|
local RECORDING_MAX_BYTES = 262144
|
|
93
103
|
local RECORDING_MAX_CHUNKS = 80
|
|
@@ -131,6 +141,20 @@ local function ageBucket(days)
|
|
|
131
141
|
end
|
|
132
142
|
return "3y+"
|
|
133
143
|
end
|
|
144
|
+
--* Whether this generation runs inside `typetorch test --cloud` (cli/src/cloudtest.ts: stub kernel `test = true`, attribute).
|
|
145
|
+
local function isCloudTest(kernel)
|
|
146
|
+
local _result = kernel
|
|
147
|
+
if _result ~= nil then
|
|
148
|
+
_result = _result.test
|
|
149
|
+
end
|
|
150
|
+
if _result == true then
|
|
151
|
+
return true
|
|
152
|
+
end
|
|
153
|
+
local ok, flag = pcall(function()
|
|
154
|
+
return Workspace:GetAttribute("TypeTorchTest")
|
|
155
|
+
end)
|
|
156
|
+
return ok and flag == true
|
|
157
|
+
end
|
|
134
158
|
local function newSession()
|
|
135
159
|
return {
|
|
136
160
|
pid = "",
|
|
@@ -214,6 +238,7 @@ do
|
|
|
214
238
|
self.sexp = ""
|
|
215
239
|
self.stopped = false
|
|
216
240
|
self.kernel = scope.server
|
|
241
|
+
self.testMode = isCloudTest(scope.server)
|
|
217
242
|
self.store = TypeTorch:persist(PERSIST_KEY, newStore)
|
|
218
243
|
self.events = RowQueue.new(self.store.events, EVENTS_CAP_UNCONFIGURED)
|
|
219
244
|
self.recordings = RowQueue.new(self.store.recordings, RECORDINGS_CAP)
|
|
@@ -256,6 +281,9 @@ do
|
|
|
256
281
|
trove:add(function()
|
|
257
282
|
self.stopped = true
|
|
258
283
|
end)
|
|
284
|
+
if self.testMode then
|
|
285
|
+
print("[analytics] cloud test: rows are collected but nothing is sent (no uploads, no identity rows)")
|
|
286
|
+
end
|
|
259
287
|
self:startSettings()
|
|
260
288
|
self:startIntake()
|
|
261
289
|
self:startSessions()
|
|
@@ -456,7 +484,7 @@ do
|
|
|
456
484
|
end
|
|
457
485
|
function ServerAnalytics:track(player, kind, name, props)
|
|
458
486
|
if not isEventName(name) then
|
|
459
|
-
warn("[src/analytics/server.ts:
|
|
487
|
+
warn("[src/analytics/server.ts:458]", `[analytics] {kind} event name must be 1-64 characters: {string.sub(tostring(name), 1, 80)}`)
|
|
460
488
|
return nil
|
|
461
489
|
end
|
|
462
490
|
local session = if player then self:sessionOf(player) else nil
|
|
@@ -564,7 +592,7 @@ do
|
|
|
564
592
|
end
|
|
565
593
|
local control = _condition
|
|
566
594
|
if not isExperimentName(name) or not isVariantList(variants) then
|
|
567
|
-
warn("[src/analytics/server.ts:
|
|
595
|
+
warn("[src/analytics/server.ts:509]", `[analytics] experiment("{string.sub(tostring(name), 1, 64)}"): bad name or variants (1-16 distinct names)`)
|
|
568
596
|
return control
|
|
569
597
|
end
|
|
570
598
|
local session = self:sessionOf(player)
|
|
@@ -578,24 +606,40 @@ do
|
|
|
578
606
|
if not session.ready then
|
|
579
607
|
return control
|
|
580
608
|
end
|
|
581
|
-
local _exp = session.
|
|
582
|
-
local
|
|
583
|
-
local
|
|
609
|
+
local _exp = session.exp
|
|
610
|
+
local _name = name
|
|
611
|
+
local _condition_1 = not (_exp[_name] ~= nil)
|
|
612
|
+
if _condition_1 then
|
|
613
|
+
-- ▼ ReadonlyMap.size ▼
|
|
614
|
+
local _size = 0
|
|
615
|
+
for _ in session.exp do
|
|
616
|
+
_size += 1
|
|
617
|
+
end
|
|
618
|
+
-- ▲ ReadonlyMap.size ▲
|
|
619
|
+
_condition_1 = _size >= EXPERIMENTS_MAX
|
|
620
|
+
end
|
|
621
|
+
if _condition_1 then
|
|
622
|
+
self:warn(`experiment("{name}") not assigned: a session holds at most {EXPERIMENTS_MAX} experiments`)
|
|
623
|
+
return control
|
|
624
|
+
end
|
|
625
|
+
local _exp_1 = session.pid
|
|
626
|
+
local _exp_2 = name
|
|
627
|
+
local _exp_3 = variants
|
|
584
628
|
local _result = self.settings
|
|
585
629
|
if _result ~= nil then
|
|
586
630
|
local _experiments_1 = _result.experiments
|
|
587
|
-
local
|
|
588
|
-
_result = _experiments_1[
|
|
631
|
+
local _name_1 = name
|
|
632
|
+
_result = _experiments_1[_name_1]
|
|
589
633
|
end
|
|
590
|
-
local assignment = assignVariant(
|
|
591
|
-
local
|
|
592
|
-
local
|
|
593
|
-
local current =
|
|
634
|
+
local assignment = assignVariant(_exp_1, _exp_2, _exp_3, _result)
|
|
635
|
+
local _exp_4 = session.exp
|
|
636
|
+
local _name_1 = name
|
|
637
|
+
local current = _exp_4[_name_1]
|
|
594
638
|
if assignment.active and current ~= assignment.variant then
|
|
595
|
-
local
|
|
596
|
-
local
|
|
639
|
+
local _exp_5 = session.exp
|
|
640
|
+
local _name_2 = name
|
|
597
641
|
local _variant = assignment.variant
|
|
598
|
-
|
|
642
|
+
_exp_5[_name_2] = _variant
|
|
599
643
|
session.expJson = encodeProps(session.exp)
|
|
600
644
|
self:emit(session, "experiment", name, encodeProps({
|
|
601
645
|
variant = assignment.variant,
|
|
@@ -603,9 +647,9 @@ do
|
|
|
603
647
|
forced = assignment.forced or nil,
|
|
604
648
|
}))
|
|
605
649
|
elseif not assignment.active and current ~= nil then
|
|
606
|
-
local
|
|
607
|
-
local
|
|
608
|
-
|
|
650
|
+
local _exp_5 = session.exp
|
|
651
|
+
local _name_2 = name
|
|
652
|
+
_exp_5[_name_2] = nil
|
|
609
653
|
session.expJson = encodeProps(session.exp)
|
|
610
654
|
end
|
|
611
655
|
return assignment.variant
|
|
@@ -613,7 +657,7 @@ do
|
|
|
613
657
|
function ServerAnalytics:applySettings(parsed)
|
|
614
658
|
local errorsText = table.concat(parsed.errors, "; ")
|
|
615
659
|
if errorsText ~= table.concat(self.settingsErrors, "; ") and errorsText ~= "" then
|
|
616
|
-
warn("[src/analytics/server.ts:
|
|
660
|
+
warn("[src/analytics/server.ts:544]", `[analytics] {SETTINGS_KEY}: {errorsText}`)
|
|
617
661
|
end
|
|
618
662
|
self.settingsErrors = parsed.errors
|
|
619
663
|
local hadSettings = self.settings ~= nil
|
|
@@ -690,7 +734,7 @@ do
|
|
|
690
734
|
snapshot = result
|
|
691
735
|
else
|
|
692
736
|
if attempt == 1 then
|
|
693
|
-
warn("[src/analytics/server.ts:
|
|
737
|
+
warn("[src/analytics/server.ts:590]", `[analytics] ConfigService unavailable: {string.sub(tostring(result), 1, 120)}`)
|
|
694
738
|
end
|
|
695
739
|
task.wait(delay)
|
|
696
740
|
delay = math.min(delay * 2, 30)
|
|
@@ -930,21 +974,71 @@ do
|
|
|
930
974
|
return info
|
|
931
975
|
end
|
|
932
976
|
function ServerAnalytics:friendsHere(player)
|
|
933
|
-
local
|
|
934
|
-
local checked = 0
|
|
977
|
+
local others = {}
|
|
935
978
|
for _, other in Players:GetPlayers() do
|
|
936
|
-
if other
|
|
937
|
-
|
|
979
|
+
if other ~= player then
|
|
980
|
+
local _userId = other.UserId
|
|
981
|
+
others[_userId] = true
|
|
982
|
+
end
|
|
983
|
+
end
|
|
984
|
+
-- ▼ ReadonlySet.size ▼
|
|
985
|
+
local _size = 0
|
|
986
|
+
for _ in others do
|
|
987
|
+
_size += 1
|
|
988
|
+
end
|
|
989
|
+
-- ▲ ReadonlySet.size ▲
|
|
990
|
+
if _size == 0 then
|
|
991
|
+
return 0
|
|
992
|
+
end
|
|
993
|
+
local ok, pages = pcall(function()
|
|
994
|
+
return Players:GetFriendsAsync(player.UserId)
|
|
995
|
+
end)
|
|
996
|
+
if not ok or pages == nil then
|
|
997
|
+
return 0
|
|
998
|
+
end
|
|
999
|
+
local count = 0
|
|
1000
|
+
for page = 1, FRIEND_PAGES_MAX do
|
|
1001
|
+
if self.stopped or player.Parent == nil then
|
|
1002
|
+
break
|
|
1003
|
+
end
|
|
1004
|
+
local pageOk, entries = pcall(function()
|
|
1005
|
+
return pages:GetCurrentPage()
|
|
1006
|
+
end)
|
|
1007
|
+
if not pageOk or not (type(entries) == "table") then
|
|
1008
|
+
break
|
|
1009
|
+
end
|
|
1010
|
+
for _, entry in entries do
|
|
1011
|
+
local _condition = type(entry) == "table"
|
|
1012
|
+
if _condition then
|
|
1013
|
+
local _id = entry.Id
|
|
1014
|
+
-- ▼ Set.delete ▼
|
|
1015
|
+
local _valueExisted = others[_id] ~= nil
|
|
1016
|
+
others[_id] = nil
|
|
1017
|
+
-- ▲ Set.delete ▲
|
|
1018
|
+
_condition = _valueExisted
|
|
1019
|
+
end
|
|
1020
|
+
if _condition then
|
|
1021
|
+
count += 1
|
|
1022
|
+
end
|
|
1023
|
+
end
|
|
1024
|
+
-- ▼ ReadonlySet.size ▼
|
|
1025
|
+
local _size_1 = 0
|
|
1026
|
+
for _ in others do
|
|
1027
|
+
_size_1 += 1
|
|
1028
|
+
end
|
|
1029
|
+
-- ▲ ReadonlySet.size ▲
|
|
1030
|
+
local _condition = _size_1 == 0
|
|
1031
|
+
if not _condition then
|
|
1032
|
+
_condition = pages.IsFinished or page == FRIEND_PAGES_MAX
|
|
938
1033
|
end
|
|
939
|
-
if
|
|
1034
|
+
if _condition then
|
|
940
1035
|
break
|
|
941
1036
|
end
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
return player:IsFriendsWith(other.UserId)
|
|
1037
|
+
local nextOk = pcall(function()
|
|
1038
|
+
return pages:AdvanceToNextPageAsync()
|
|
945
1039
|
end)
|
|
946
|
-
if
|
|
947
|
-
|
|
1040
|
+
if not nextOk then
|
|
1041
|
+
break
|
|
948
1042
|
end
|
|
949
1043
|
end
|
|
950
1044
|
return count
|
|
@@ -1203,14 +1297,9 @@ do
|
|
|
1203
1297
|
_helloWaiting[_player] = true
|
|
1204
1298
|
end
|
|
1205
1299
|
end
|
|
1206
|
-
function ServerAnalytics:
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
return nil
|
|
1210
|
-
end
|
|
1211
|
-
local list = batch
|
|
1212
|
-
if #list > CLIENT_BATCH_MAX then
|
|
1213
|
-
return nil
|
|
1300
|
+
function ServerAnalytics:takeClientEvent(player, session)
|
|
1301
|
+
if session.clientEvents >= CLIENT_SESSION_MAX then
|
|
1302
|
+
return false
|
|
1214
1303
|
end
|
|
1215
1304
|
local _clientBudgets = self.clientBudgets
|
|
1216
1305
|
local _player = player
|
|
@@ -1225,15 +1314,24 @@ do
|
|
|
1225
1314
|
local _budget = budget
|
|
1226
1315
|
_clientBudgets_1[_player_1] = _budget
|
|
1227
1316
|
end
|
|
1317
|
+
if not takeToken(budget, os.clock(), CLIENT_BURST, CLIENT_PER_MINUTE) then
|
|
1318
|
+
return false
|
|
1319
|
+
end
|
|
1320
|
+
session.clientEvents += 1
|
|
1321
|
+
return true
|
|
1322
|
+
end
|
|
1323
|
+
function ServerAnalytics:onEvents(player, session, batch)
|
|
1324
|
+
local _batch = batch
|
|
1325
|
+
if not (type(_batch) == "table") then
|
|
1326
|
+
return nil
|
|
1327
|
+
end
|
|
1328
|
+
local list = batch
|
|
1329
|
+
if #list > CLIENT_BATCH_MAX then
|
|
1330
|
+
return nil
|
|
1331
|
+
end
|
|
1228
1332
|
local now = nowMs()
|
|
1229
1333
|
local halfPing = math.floor(math.clamp(player:GetNetworkPing(), 0, 2) * 500)
|
|
1230
1334
|
for _, raw in list do
|
|
1231
|
-
if session.clientEvents >= CLIENT_SESSION_MAX then
|
|
1232
|
-
return nil
|
|
1233
|
-
end
|
|
1234
|
-
if not takeToken(budget, os.clock(), CLIENT_BURST, CLIENT_PER_MINUTE) then
|
|
1235
|
-
return nil
|
|
1236
|
-
end
|
|
1237
1335
|
local event = cleanClientEvent(raw)
|
|
1238
1336
|
if not event then
|
|
1239
1337
|
continue
|
|
@@ -1247,7 +1345,9 @@ do
|
|
|
1247
1345
|
if not ok or not (type(decoded) == "table") then
|
|
1248
1346
|
continue
|
|
1249
1347
|
end
|
|
1250
|
-
session
|
|
1348
|
+
if not self:takeClientEvent(player, session) then
|
|
1349
|
+
return nil
|
|
1350
|
+
end
|
|
1251
1351
|
local t = now - age - halfPing
|
|
1252
1352
|
if kind == "state" then
|
|
1253
1353
|
local to = decoded.to
|
|
@@ -1367,8 +1467,9 @@ do
|
|
|
1367
1467
|
local _binding = args
|
|
1368
1468
|
local name = _binding[1]
|
|
1369
1469
|
local variants = _binding[2]
|
|
1470
|
+
-- A client names an experiment: within its event budget (it may add an "experiment" row and grows `exp`).
|
|
1370
1471
|
-- experiment() may wait for the pid: not on the kernel's message thread.
|
|
1371
|
-
if isExperimentName(name) and isVariantList(variants) then
|
|
1472
|
+
if isExperimentName(name) and isVariantList(variants) and self:takeClientEvent(player, session) then
|
|
1372
1473
|
task.spawn(function()
|
|
1373
1474
|
return self:experiment(player, name, variants)
|
|
1374
1475
|
end)
|
|
@@ -1380,7 +1481,7 @@ do
|
|
|
1380
1481
|
end
|
|
1381
1482
|
end)
|
|
1382
1483
|
if not ok then
|
|
1383
|
-
warn("[src/analytics/server.ts:
|
|
1484
|
+
warn("[src/analytics/server.ts:1058]", `[analytics] client message failed: {string.sub(tostring(err), 1, 200)}`)
|
|
1384
1485
|
end
|
|
1385
1486
|
end)
|
|
1386
1487
|
self.trove:add(function()
|
|
@@ -1660,7 +1761,7 @@ do
|
|
|
1660
1761
|
return nil
|
|
1661
1762
|
end
|
|
1662
1763
|
self.lastWarnAt = now
|
|
1663
|
-
warn("[src/analytics/server.ts:
|
|
1764
|
+
warn("[src/analytics/server.ts:1247]", `[analytics] {message}`)
|
|
1664
1765
|
end
|
|
1665
1766
|
function ServerAnalytics:post(request)
|
|
1666
1767
|
local ok, response = pcall(function()
|
|
@@ -1682,6 +1783,14 @@ do
|
|
|
1682
1783
|
return response.StatusCode
|
|
1683
1784
|
end
|
|
1684
1785
|
function ServerAnalytics:flushOnce(final)
|
|
1786
|
+
if self.testMode then
|
|
1787
|
+
-- The cloud test: drop what was collected instead of sending it (the task's rows are not real play).
|
|
1788
|
+
table.clear(self.store.events.rows)
|
|
1789
|
+
table.clear(self.store.fleet.rows)
|
|
1790
|
+
table.clear(self.store.recordings.rows)
|
|
1791
|
+
self.store.identities = {}
|
|
1792
|
+
return nil
|
|
1793
|
+
end
|
|
1685
1794
|
local settings = self.settings
|
|
1686
1795
|
if not settings or self.flushing then
|
|
1687
1796
|
return nil
|
|
@@ -1850,7 +1959,7 @@ do
|
|
|
1850
1959
|
self.nextFlushAt = 0
|
|
1851
1960
|
end
|
|
1852
1961
|
function ServerAnalytics:close()
|
|
1853
|
-
if self.closing or self.stopped then
|
|
1962
|
+
if self.closing or self.stopped or self.testMode then
|
|
1854
1963
|
return nil
|
|
1855
1964
|
end
|
|
1856
1965
|
self.closing = true
|
|
@@ -85,7 +85,10 @@ export interface AdminServer {
|
|
|
85
85
|
kernel?: string;
|
|
86
86
|
/** Its kernel takes pin messages (0.2.3+); undefined when unknown. */
|
|
87
87
|
ab?: boolean;
|
|
88
|
-
/**
|
|
88
|
+
/**
|
|
89
|
+
* Kernel 0.3.2+: "ok" | "failed" (nothing runs) | "unverified" | "degraded" (a deploy failed or rolled back there);
|
|
90
|
+
* 0.3.6: "backup" (the backup build baked into the place runs).
|
|
91
|
+
*/
|
|
89
92
|
health?: string;
|
|
90
93
|
/** Kernel 0.3.2+: its applied deploy seq. */
|
|
91
94
|
seq?: number;
|