@typetorch/framework 0.3.5 → 0.4.0

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 CHANGED
@@ -201,6 +201,10 @@ leaves one behind. Each `on*` returns a disconnect function, which a trove can o
201
201
  TypeTorch.artifact; // { id, commit, commitHash, branch, channel, builtAt, seq, assetId }
202
202
  TypeTorch.generation; // 1, 2, 3... per server (or client)
203
203
  TypeTorch.branch; TypeTorch.channel; TypeTorch.serverType; // "public" | "private" | "reserved" | "studio"
204
+ // Kernel 0.3.9: `channel` is the RULES this server runs under ("prod" on every public server; split data stores by it);
205
+ // `branchChannel` is what the branch IS ("prod" for the default branch or one configured prod, else "dev");
206
+ // `rules` is the same as `channel`, by name.
207
+ TypeTorch.branchChannel; TypeTorch.rules;
204
208
  TypeTorch.isPinned(); TypeTorch.kernelVersion; TypeTorch.kernelApi; TypeTorch.jobId; TypeTorch.isStudio;
205
209
 
206
210
  // How this generation started: a branch change starts a new generation.
@@ -420,6 +424,17 @@ const variant = analytics.experiment("onboarding", ["short", "long"]); // same a
420
424
  `flushSeconds` (15) or at 500 rows, at most ~10 HttpService requests a minute, retries with backoff, and a last
421
425
  flush on shutdown (kernel 0.3.2). Delivery is at least once (a swap mid-request sends that batch again).
422
426
  `analytics.stats()` (server) has the counters; `flush()` sends soon.
427
+ - **When uploads fail** (a stale quick-tunnel URL, the dev PC's server or tunnel down, a wrong token, HttpService off;
428
+ Roblox reports most network problems as `HttpError: NetFail`, `DnsResolve` or `ConnectFail`): the server backs off
429
+ with jitter (5, 10, 20 ... 300 s, spread +-25% so a fleet of servers doesn't retry in step; a wrong token or URL waits
430
+ about 5 minutes, and a settings change retries at once), keeps the rows, and logs at most ONE line a minute: the
431
+ reason and the fix (`analytics upload failed 5 times in a row: NetFail: the connection broke mid-request. ... Retrying
432
+ in 80 s.`, with "(+N more in the last minute)" for the lines it held back) and one line when uploads work again.
433
+ `stats()` also has `failures` (in a row), `failed` (since the server started), `lastError`, `lastStatus`
434
+ (0 = no answer), `lastErrorAt` / `lastOkAt` (`os.time()`), `retryIn`, and, while failing, `reason` and `fix`. The dev
435
+ menu shows them: **Server > Status** has an *Analytics* line (queued, sent, `FAILING x5: ... retry in 80 s`) and a
436
+ *Fleet API* line (the kernel's sender: sent, failed, last error and its age), and **Attention** lists the reason and
437
+ the fix; `typetorch doctor` tests the address and token in the settings record.
423
438
  - **The cloud test sends nothing.** `typetorch test --cloud` boots the payload headless in a Luau Execution task where
424
439
  HttpService works; there the engine (stub kernel `test = true`, or `workspace:GetAttribute("TypeTorchTest")`) collects
425
440
  as usual but never uploads: no event rows, no identity rows, no HTTP request, so a prod deploy never puts a fake
@@ -431,6 +446,13 @@ const variant = analytics.experiment("onboarding", ["short", "long"]); // same a
431
446
  - **Friends in the server** (the join's `friends`): one `Players:GetFriendsAsync` per join (at most 10 pages, none
432
447
  when the player is alone) intersected with the players there, not a web call per player.
433
448
 
449
+ **Settings, framework 0.4.0 / kernel 0.4.0 (plans/21):** the record's `backend` = `{ url, key, analytics?: { flushSeconds,
450
+ recordShare } }` (`typetorch backend setup`) is the whole sink: DuckDB at `<url>/v1/ingest` with the one key (Basin
451
+ identities to `<url>/v1/identity`). The old `analytics` value below is read while `backend` is missing, and stays the
452
+ way to a Basin sink. The engine also tells the kernel each player's pid (`setAnalyticsId`: the kernel's error reports
453
+ count affected players by pid, never by name) and counts its own DataStore and HTTP requests for the budget view (dev
454
+ menu **Server > Budget**: bars per Roblox limit for the player count, who spends it, the DataStore budget left, memory).
455
+
434
456
  **Settings** (server only, never sent to clients): the signed settings record's `analytics` field (kernel 0.3.8,
435
457
  plans/20), written with `typetorch settings set analytics -` (JSON on stdin; `@typetorch/analytics`' `writeSettings()`
436
458
  and `bun run local` call it). Every new copy applies live within seconds (the CLI pings servers). Before kernel 0.3.8
@@ -481,9 +503,11 @@ bun run build # rbxtsc --type package -> out/
481
503
  shorthand, setCallback with values and Promises, predict, a swap, and the client side (invoke, invokeWithTimeout,
482
504
  `createClient({ defaultTimeout })`).
483
505
  `scripts/test-analytics.luau` checks the analytics engine's pure parts (experiment assignment, settings, the queue
484
- and HTTP budget, tt-rec-1, the sink request bodies); `test-analytics-server.luau` and `test-analytics-client.luau`
485
- run the compiled server and client cores against mocked services (sessions, intake, retries, a swap with a request
486
- in flight, shutdown; the recorder, screens, batching).
506
+ and HTTP budget, tt-rec-1, the sink request bodies, the failure help); `test-analytics-server.luau` and
507
+ `test-analytics-client.luau` run the compiled server and client cores against mocked services (sessions, intake,
508
+ retries, a NetFail streak and its one log line a minute, a swap with a request in flight, shutdown; the recorder,
509
+ screens, batching). `scripts/test-net-health.luau` checks the dev menu's Fleet API / Analytics status lines and
510
+ Attention issues.
487
511
  - **Publishing:** `npm publish` runs `prepublishOnly` (clean + build). The package ships only `out/` (no
488
512
  `.tsbuildinfo`), `README.md` and `LICENSE`; check with `bun pm pack --dry-run`.
489
513
 
@@ -176,8 +176,13 @@ Unknown fields are ignored. Settings errors are warned once (never with the toke
176
176
  - **Batches:** every `flushSeconds`, or at 500 queued events or 20 recordings. At most 500 event rows (fleet rows
177
177
  first) and 50 recording rows per request, about 900 KB each. At most ~10 requests a minute per server (burst 3).
178
178
  - **Answers:** 2xx sent; 413 the batch is halved and resent (a single row is dropped); 401/403/404 rows are kept and
179
- sending waits 300 s (or until the settings change); 408/429/5xx/no answer retry with backoff 5, 10, 20... 300 s;
180
- other 4xx drop the batch (counted as rejected).
179
+ sending waits about 300 s (225-375 s, jittered; or until the settings change); 408/429/5xx/no answer retry with
180
+ backoff 5, 10, 20... 300 s (each +-25%); other 4xx drop the batch (counted as rejected).
181
+ - **Failure log and status:** at most one log line a minute (the reason and the fix from `hints.ts`, the streak, the next
182
+ try; later lines say how many were held back) and one line when uploads work again. `stats()`: `failures` (in a
183
+ row), `failed` (total), `lastError`, `lastStatus` (0: no answer, an `HttpError` such as NetFail / DnsResolve),
184
+ `lastErrorAt`, `lastOkAt`, `retryIn`, and while failing `reason` and `fix`. The dev menu reads them through
185
+ `analytics/status.ts`.
181
186
  - **Delivery is at least once:** a hot swap during a request sends its rows again. Readers may drop exact duplicate
182
187
  rows.
183
188
  - **The cloud test sends nothing:** inside `typetorch test --cloud` (the stub kernel's `test = true`, or the workspace
@@ -0,0 +1,36 @@
1
+ /**
2
+ * What a failed upload to the dev's analytics / fleet server means, and what to do about it. Shared by the analytics
3
+ * engine's log line (at most one a minute) and the dev menu (Status > Analytics / Fleet API, the "Attention" list), so
4
+ * both say the same thing.
5
+ *
6
+ * `HttpService.RequestAsync` throws a message with Roblox's HttpError name in it ("HttpError: NetFail"); a reply that
7
+ * isn't 2xx has a status. The names that matter to a dev running the server on their own PC behind a Cloudflare quick
8
+ * tunnel (`bun run local`):
9
+ *
10
+ * NetFail the connection broke mid-request (curl send/receive error, nothing usable came back): the server or
11
+ * tunnel restarted, dropped it, or is overloaded. The usual one when the dev PC's tunnel flaps.
12
+ * DnsResolve the host name doesn't resolve: the quick tunnel's URL died with its cloudflared (a new run gets a new
13
+ * URL) and the settings still hold the old one.
14
+ * ConnectFail nothing accepted the connection.
15
+ * TimedOut it accepted but never answered.
16
+ * (HTTP 530/1033 a tunnel with nothing behind it, 502/504 a tunnel whose server is down, 401/403 a wrong token, 404 a
17
+ * wrong path.)
18
+ *
19
+ * Pure: no imports and no services, so it also runs offline under Lune (scripts/test-net-health.luau). Nothing here
20
+ * ever sees or returns a token.
21
+ */
22
+ export interface FailureHelp {
23
+ /** What happened, in a few words ("the connection broke mid-request"). */
24
+ reason: string;
25
+ /** What to do about it, one or two sentences. */
26
+ fix: string;
27
+ }
28
+ /** The Roblox HttpError name in an error message ("HttpError: NetFail" -> "NetFail"), or undefined. */
29
+ export declare function httpErrorName(message: string | undefined): string | undefined;
30
+ /**
31
+ * Help for a failed request. `status` is the HTTP status (0: no answer at all) and `message` the error text (from the
32
+ * thrown HttpError, or "HTTP 530" style text for a status; either may be missing).
33
+ */
34
+ export declare function describeFailure(status: number, message?: string): FailureHelp;
35
+ /** Help for a kernel Fleet sender error text: "HTTP 530", or an HttpError message (`status().fleet.lastError`). */
36
+ export declare function describeFleetError(lastError: string | undefined): FailureHelp | undefined;
@@ -0,0 +1,190 @@
1
+ -- Compiled with roblox-ts v3.0.0
2
+ --[[
3
+ *
4
+ * What a failed upload to the dev's analytics / fleet server means, and what to do about it. Shared by the analytics
5
+ * engine's log line (at most one a minute) and the dev menu (Status > Analytics / Fleet API, the "Attention" list), so
6
+ * both say the same thing.
7
+ *
8
+ * `HttpService.RequestAsync` throws a message with Roblox's HttpError name in it ("HttpError: NetFail"); a reply that
9
+ * isn't 2xx has a status. The names that matter to a dev running the server on their own PC behind a Cloudflare quick
10
+ * tunnel (`bun run local`):
11
+ *
12
+ * NetFail the connection broke mid-request (curl send/receive error, nothing usable came back): the server or
13
+ * tunnel restarted, dropped it, or is overloaded. The usual one when the dev PC's tunnel flaps.
14
+ * DnsResolve the host name doesn't resolve: the quick tunnel's URL died with its cloudflared (a new run gets a new
15
+ * URL) and the settings still hold the old one.
16
+ * ConnectFail nothing accepted the connection.
17
+ * TimedOut it accepted but never answered.
18
+ * (HTTP 530/1033 a tunnel with nothing behind it, 502/504 a tunnel whose server is down, 401/403 a wrong token, 404 a
19
+ * wrong path.)
20
+ *
21
+ * Pure: no imports and no services, so it also runs offline under Lune (scripts/test-net-health.luau). Nothing here
22
+ * ever sees or returns a token.
23
+
24
+ ]]
25
+ --* The Roblox HttpError name in an error message ("HttpError: NetFail" -> "NetFail"), or undefined.
26
+ local function httpErrorName(message)
27
+ if message == nil then
28
+ return nil
29
+ end
30
+ local name = string.match(message, "HttpError:%s*(%w+)")
31
+ return if type(name) == "string" then name else nil
32
+ end
33
+ local RUN_LOCAL = "run bun run local on the dev PC (it opens a new tunnel and updates these settings)"
34
+ --[[
35
+ *
36
+ * Help for a failed request. `status` is the HTTP status (0: no answer at all) and `message` the error text (from the
37
+ * thrown HttpError, or "HTTP 530" style text for a status; either may be missing).
38
+
39
+ ]]
40
+ local describeStatus
41
+ local function describeFailure(status, message)
42
+ if status > 0 then
43
+ return describeStatus(status)
44
+ end
45
+ local _condition = message
46
+ if _condition == nil then
47
+ _condition = ""
48
+ end
49
+ local text = _condition
50
+ local name = httpErrorName(text)
51
+ local lower = string.lower(text)
52
+ if (string.find(lower, "not enabled", 1, true)) ~= nil or (string.find(lower, "httpenabled", 1, true)) ~= nil then
53
+ return {
54
+ reason = "HttpService is off for this place",
55
+ fix = "turn on Game Settings > Security > Allow HTTP Requests (HttpService.HttpEnabled).",
56
+ }
57
+ end
58
+ if (string.find(lower, "exceeded limit", 1, true)) ~= nil or (string.find(lower, "too many requests", 1, true)) ~= nil then
59
+ return {
60
+ reason = "Roblox's HTTP limit for this server is used up",
61
+ fix = "a server may send 500 requests a minute in all; analytics sends about 10, so something else is using the rest.",
62
+ }
63
+ end
64
+ if name == "NetFail" then
65
+ return {
66
+ reason = "NetFail: the connection broke mid-request",
67
+ fix = `the dev PC's quick tunnel or server restarted, dropped it or is overloaded (a quick tunnel is for testing; use a real domain in production). Run typetorch doctor; if the address is stale, {RUN_LOCAL}.`,
68
+ }
69
+ end
70
+ if name == "DnsResolve" then
71
+ return {
72
+ reason = "DnsResolve: the host name doesn't resolve",
73
+ fix = `for a trycloudflare.com address the quick tunnel is gone and the URL changed: {RUN_LOCAL}. Otherwise check the URL for typos (typetorch doctor).`,
74
+ }
75
+ end
76
+ if name == "ConnectFail" then
77
+ return {
78
+ reason = "ConnectFail: nothing accepted the connection",
79
+ fix = `the server or its tunnel is down, or the address or port is wrong. Run typetorch doctor; {RUN_LOCAL} restarts both.`,
80
+ }
81
+ end
82
+ if name == "TimedOut" then
83
+ return {
84
+ reason = "TimedOut: the server didn't answer in time",
85
+ fix = "it is overloaded, asleep (the dev PC suspended?) or its tunnel is stuck. Run typetorch doctor.",
86
+ }
87
+ end
88
+ if name == "SslConnectFail" or name == "SslVerificationFail" then
89
+ return {
90
+ reason = `{name}: the TLS certificate was refused`,
91
+ fix = "use an address with a valid certificate (the Cloudflare tunnel, or Caddy on a VPS).",
92
+ }
93
+ end
94
+ if name == "InvalidUrl" then
95
+ return {
96
+ reason = "InvalidUrl: the address isn't a valid URL",
97
+ fix = "set it again with typetorch backend setup (typetorch doctor tests it).",
98
+ }
99
+ end
100
+ if name == "TooManyRedirects" or name == "InvalidRedirect" then
101
+ return {
102
+ reason = `{name}: the address redirects`,
103
+ fix = "use the final address directly in the settings.",
104
+ }
105
+ end
106
+ if name ~= nil then
107
+ return {
108
+ reason = `HttpError {name}`,
109
+ fix = "run typetorch doctor to test the address and token.",
110
+ }
111
+ end
112
+ return {
113
+ reason = if text == "" then "no answer" else string.sub(text, 1, 120),
114
+ fix = "run typetorch doctor to test the address and token.",
115
+ }
116
+ end
117
+ function describeStatus(status)
118
+ if status == 530 then
119
+ return {
120
+ reason = "HTTP 530: the tunnel has nothing behind it",
121
+ fix = `the dev PC's tunnel or server stopped: {RUN_LOCAL}.`,
122
+ }
123
+ end
124
+ if status == 502 or status == 504 then
125
+ return {
126
+ reason = `HTTP {status}: the server behind the tunnel doesn't answer`,
127
+ fix = `start the analytics server again: {RUN_LOCAL}.`,
128
+ }
129
+ end
130
+ if status == 401 or status == 403 then
131
+ return {
132
+ reason = `HTTP {status}: the server refused the token`,
133
+ fix = "put the server's API key (TYPETORCH_API_KEY; TT_ANALYTICS_INGEST_TOKENS before the rename) in the settings (typetorch backend setup); typetorch doctor tests it.",
134
+ }
135
+ end
136
+ if status == 404 then
137
+ return {
138
+ reason = "HTTP 404: no such address on the server",
139
+ fix = `the URL is wrong (analytics ends in /v1/ingest) or the tunnel points at nothing. Run typetorch doctor; {RUN_LOCAL}.`,
140
+ }
141
+ end
142
+ if status == 408 then
143
+ return {
144
+ reason = "HTTP 408: the server timed out",
145
+ fix = "it is overloaded; the engine retries with a growing pause.",
146
+ }
147
+ end
148
+ if status == 413 then
149
+ return {
150
+ reason = "HTTP 413: the batch was too large",
151
+ fix = "the engine splits it and sends again.",
152
+ }
153
+ end
154
+ if status == 429 then
155
+ return {
156
+ reason = "HTTP 429: rate limited",
157
+ fix = "too many requests for the server or tunnel; the engine retries with a growing pause.",
158
+ }
159
+ end
160
+ if status >= 500 then
161
+ return {
162
+ reason = `HTTP {status}: the server is failing`,
163
+ fix = "check the analytics server's log.",
164
+ }
165
+ end
166
+ return {
167
+ reason = `HTTP {status}`,
168
+ fix = "run typetorch doctor to test the address and token.",
169
+ }
170
+ end
171
+ --* Help for a kernel Fleet sender error text: "HTTP 530", or an HttpError message (`status().fleet.lastError`).
172
+ local function describeFleetError(lastError)
173
+ if lastError == nil then
174
+ return nil
175
+ end
176
+ local code = string.match(lastError, "^HTTP (%d+)$")
177
+ if type(code) == "string" then
178
+ local _condition = tonumber(code)
179
+ if _condition == nil then
180
+ _condition = 0
181
+ end
182
+ return describeFailure(_condition, lastError)
183
+ end
184
+ return describeFailure(0, lastError)
185
+ end
186
+ return {
187
+ httpErrorName = httpErrorName,
188
+ describeFailure = describeFailure,
189
+ describeFleetError = describeFleetError,
190
+ }
@@ -44,6 +44,11 @@ export interface BucketState {
44
44
  export declare function takeToken(state: BucketState, now: number, burst: number, perMinute: number): boolean;
45
45
  /** Seconds to wait after `failures` failed requests in a row: 5, 10, 20... at most 300, spread by `jitter` (0..1). */
46
46
  export declare function backoffSeconds(failures: number, jitter: number): number;
47
+ /**
48
+ * Seconds to wait after a settings problem (401/403/404: wrong token or URL): long, because retrying doesn't help until
49
+ * the settings change (that retries at once), spread by `jitter` (0..1) so a whole fleet of servers doesn't retry in step.
50
+ */
51
+ export declare function configWaitSeconds(jitter: number): number;
47
52
  /** What to do with a batch after an HTTP status (0 = no response: network error, timeout, HttpService off). */
48
53
  export type Outcome = "sent" | "retry" | "split" | "drop" | "config";
49
54
  /**
@@ -192,6 +192,15 @@ local function backoffSeconds(failures, jitter)
192
192
  local base = math.min(300, 5 * 2 ^ math.min(failures - 1, 10))
193
193
  return base * (0.75 + 0.5 * math.clamp(jitter, 0, 1))
194
194
  end
195
+ --[[
196
+ *
197
+ * Seconds to wait after a settings problem (401/403/404: wrong token or URL): long, because retrying doesn't help until
198
+ * the settings change (that retries at once), spread by `jitter` (0..1) so a whole fleet of servers doesn't retry in step.
199
+
200
+ ]]
201
+ local function configWaitSeconds(jitter)
202
+ return 300 * (0.75 + 0.5 * math.clamp(jitter, 0, 1))
203
+ end
195
204
  --* What to do with a batch after an HTTP status (0 = no response: network error, timeout, HttpService off).
196
205
  --[[
197
206
  *
@@ -219,6 +228,7 @@ return {
219
228
  newQueueState = newQueueState,
220
229
  takeToken = takeToken,
221
230
  backoffSeconds = backoffSeconds,
231
+ configWaitSeconds = configWaitSeconds,
222
232
  classifyStatus = classifyStatus,
223
233
  RowQueue = RowQueue,
224
234
  }
@@ -193,9 +193,25 @@ export interface AnalyticsStats {
193
193
  dropped: number;
194
194
  /** Rows the backend refused (HTTP 4xx). */
195
195
  rejected: number;
196
- /** Failed requests in a row. */
196
+ /** Failed requests in a row (0 after a success). */
197
197
  failures: number;
198
+ /** Failed requests since the server started, every attempt (across swaps). */
199
+ failed: number;
200
+ /**
201
+ * The last failure's message ("both: HttpError: NetFail", "both: HTTP 530 ..."). Kept after a later success:
202
+ * compare `lastErrorAt` with `lastOkAt`.
203
+ */
198
204
  lastError?: string;
205
+ /** HTTP status of the last failure (0: no answer at all, an HttpError). */
206
+ lastStatus?: number;
207
+ /** os.time() of the last failed request and of the last accepted one. */
208
+ lastErrorAt?: number;
209
+ lastOkAt?: number;
210
+ /** Seconds until the next attempt while backing off after failures. */
211
+ retryIn?: number;
212
+ /** While failing: what the last failure means and what to do (hints.ts); the same words the log line and the dev menu use. */
213
+ reason?: string;
214
+ fix?: string;
199
215
  /** Settings problems (never contains the token). */
200
216
  settingsErrors: string[];
201
217
  }
@@ -69,6 +69,10 @@ export declare class ServerAnalytics {
69
69
  private backoffUntil;
70
70
  private batchRows;
71
71
  private lastWarnAt;
72
+ /** Lines held back by the one-a-minute limit since the last one that was printed. */
73
+ private suppressed;
74
+ /** A failure line was printed since the last recovery line (so a recovery is only announced after an announced failure). */
75
+ private outageLogged;
72
76
  private flushing;
73
77
  private closing;
74
78
  private readonly identity;
@@ -114,6 +118,13 @@ export declare class ServerAnalytics {
114
118
  * changes nothing.
115
119
  */
116
120
  private resolve;
121
+ /**
122
+ * Kernel 0.4.0 (plans/21 C): the kernel's error reports count affected players by pid (never a name or UserId), so
123
+ * the kernel hears each player's pid. Older kernels: nothing.
124
+ */
125
+ private tellKernelPid;
126
+ /** Kernel 0.4.0: counts this engine's own requests for the budget view (older kernels: nothing). */
127
+ private countBudget;
117
128
  private joinSource;
118
129
  /**
119
130
  * How many of the player's friends are in this server: one `GetFriendsAsync` per join (at most FRIEND_PAGES_MAX
@@ -139,7 +150,10 @@ export declare class ServerAnalytics {
139
150
  private startZones;
140
151
  private startFleet;
141
152
  private onDeployReport;
153
+ /** At most one log line a minute; the ones held back are counted in the next line. Returns whether it was printed. */
142
154
  private warn;
155
+ /** A request was accepted: ends a failure streak, and says so once if the streak was announced. */
156
+ private noteSuccess;
143
157
  /** POST one request. Returns the HTTP status (0: no response). Never logs the token or the body. */
144
158
  private post;
145
159
  /** Sends one batch per table. `final`: shutdown, ignore the budget. */