@mentra/engine 3.4.0-dev.1207 → 3.4.0-dev.1209
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/build/facades/glasses.d.ts.map +1 -1
- package/build/facades/glasses.js +5 -3
- package/build/facades/glasses.js.map +1 -1
- package/build/generated/releaseMetadata.js +5 -5
- package/build/generated/releaseMetadata.js.map +1 -1
- package/build/services/AppRegistry.d.ts +2 -0
- package/build/services/AppRegistry.d.ts.map +1 -1
- package/build/services/AppRegistry.js.map +1 -1
- package/build/services/LocalMiniappRuntime.d.ts +2 -0
- package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
- package/build/services/LocalMiniappRuntime.js +18 -1
- package/build/services/LocalMiniappRuntime.js.map +1 -1
- package/build/services/MentraJSCrashController.d.ts +2 -0
- package/build/services/MentraJSCrashController.d.ts.map +1 -1
- package/build/services/MentraJSCrashController.js +4 -0
- package/build/services/MentraJSCrashController.js.map +1 -1
- package/build/services/MentraJSRouter.d.ts.map +1 -1
- package/build/services/MentraJSRouter.js +14 -3
- package/build/services/MentraJSRouter.js.map +1 -1
- package/build/services/MiniappLauncher.d.ts +6 -0
- package/build/services/MiniappLauncher.d.ts.map +1 -1
- package/build/services/MiniappLauncher.js +20 -3
- package/build/services/MiniappLauncher.js.map +1 -1
- package/build/services/fleet/FleetReporter.d.ts.map +1 -1
- package/build/services/fleet/FleetReporter.js +43 -0
- package/build/services/fleet/FleetReporter.js.map +1 -1
- package/build/services/fleet/fleetCollector.d.ts +1 -1
- package/build/services/fleet/fleetCollector.d.ts.map +1 -1
- package/build/services/fleet/fleetCollector.js +146 -5
- package/build/services/fleet/fleetCollector.js.map +1 -1
- package/build/services/fleet/fleetEvents.d.ts +39 -3
- package/build/services/fleet/fleetEvents.d.ts.map +1 -1
- package/build/services/fleet/fleetEvents.js +66 -22
- package/build/services/fleet/fleetEvents.js.map +1 -1
- package/build/services/fleet/fleetQueue.d.ts +26 -0
- package/build/services/fleet/fleetQueue.d.ts.map +1 -1
- package/build/services/fleet/fleetQueue.js +94 -8
- package/build/services/fleet/fleetQueue.js.map +1 -1
- package/build/services/fleet/telemetryLimits.d.ts +16 -0
- package/build/services/fleet/telemetryLimits.d.ts.map +1 -0
- package/build/services/fleet/telemetryLimits.js +69 -0
- package/build/services/fleet/telemetryLimits.js.map +1 -0
- package/build/services/installMiniapp.d.ts +13 -0
- package/build/services/installMiniapp.d.ts.map +1 -1
- package/build/services/installMiniapp.js +64 -1
- package/build/services/installMiniapp.js.map +1 -1
- package/build/services/miniappReleaseInstall.js +1 -1
- package/build/services/miniappReleaseInstall.js.map +1 -1
- package/build/stores/apps.d.ts.map +1 -1
- package/build/stores/apps.js +16 -1
- package/build/stores/apps.js.map +1 -1
- package/package.json +8 -8
- package/src/facades/glasses.ts +5 -3
- package/src/generated/releaseMetadata.ts +5 -5
- package/src/services/AppRegistry.ts +2 -0
- package/src/services/LocalMiniappRuntime.ts +20 -1
- package/src/services/MentraJSCrashController.ts +5 -0
- package/src/services/MentraJSRouter.ts +15 -3
- package/src/services/MiniappLauncher.ts +18 -2
- package/src/services/fleet/FleetReporter.ts +40 -0
- package/src/services/fleet/fleetCollector.ts +160 -6
- package/src/services/fleet/fleetEvents.ts +104 -20
- package/src/services/fleet/fleetQueue.ts +100 -7
- package/src/services/fleet/telemetryLimits.ts +78 -0
- package/src/services/installMiniapp.ts +78 -2
- package/src/services/miniappReleaseInstall.ts +1 -1
- package/src/stores/apps.ts +19 -2
|
@@ -13,6 +13,13 @@
|
|
|
13
13
|
* sequence and the oldest and newest record keys.
|
|
14
14
|
* - `l:<partition>` holds the losses not yet reported: `{dropped, expired, from, to, updatedAt}`.
|
|
15
15
|
* - `n:<partition>` holds the collector-session sequence counters, as `[collectorSessionId, next][]`.
|
|
16
|
+
* - `t:<partition>` holds the miniapp telemetry lost and not yet reported, per package (`FleetTelemetryLosses`).
|
|
17
|
+
*
|
|
18
|
+
* Telemetry (Phase 12) has a share of the queue: a `telemetry_event`'s record ID starts with `tel.`, so its key says
|
|
19
|
+
* what it is. Telemetry takes at most 2,000 of the 10,000 places. When that share or the queue is full, the oldest
|
|
20
|
+
* telemetry record gives way, and a record of any other kind never does to telemetry: an arriving record that finds
|
|
21
|
+
* the queue full evicts the oldest telemetry before anything else. Telemetry that is evicted, expires, or that MFM
|
|
22
|
+
* rejects is counted per package under `t:`, never as a coverage gap, and the reporter sends it as `telemetry_loss`.
|
|
16
23
|
*
|
|
17
24
|
* MMKV can list keys only by listing all of them, so the first call for a partition in a process scans the store
|
|
18
25
|
* once, repairs the index if a crash left it behind, and keeps the partition's ordered record keys in memory.
|
|
@@ -29,7 +36,7 @@
|
|
|
29
36
|
* `sweepExpired` is how the 7-day bound still holds for it: one scan finds every partition, expires its old
|
|
30
37
|
* records and removes what an empty partition no longer needs.
|
|
31
38
|
*/
|
|
32
|
-
import {FLEET_LIMITS, type FleetRecord} from "@mentra/cloud-protocol"
|
|
39
|
+
import {FLEET_LIMITS, FLEET_TELEMETRY_LIMITS, type FleetRecord} from "@mentra/cloud-protocol"
|
|
33
40
|
import {normalizeCoreUrl} from "./coreUrl"
|
|
34
41
|
import {getFleetStore} from "./fleetStorage"
|
|
35
42
|
import {sha256HexOfText, utf8ByteLength} from "./utf8"
|
|
@@ -54,6 +61,18 @@ export interface FleetLosses {
|
|
|
54
61
|
to: string | null
|
|
55
62
|
}
|
|
56
63
|
|
|
64
|
+
/** The telemetry the queue (or the Mentra App's limits) lost, per package, since the last `takeTelemetryLosses`. */
|
|
65
|
+
export interface FleetTelemetryLosses {
|
|
66
|
+
packages: Record<string, {overLimit: number; evicted: number; expired: number; rejected: number}>
|
|
67
|
+
from: string | null
|
|
68
|
+
to: string | null
|
|
69
|
+
}
|
|
70
|
+
export type FleetTelemetryLossKind = "overLimit" | "evicted" | "expired" | "rejected"
|
|
71
|
+
|
|
72
|
+
/** A telemetry record's ID starts with this, so the queue tells telemetry from the rest by its key. */
|
|
73
|
+
export const TELEMETRY_RECORD_PREFIX = "tel."
|
|
74
|
+
export const isTelemetryRecordId = (recordId: string) => recordId.startsWith(TELEMETRY_RECORD_PREFIX)
|
|
75
|
+
|
|
57
76
|
/** A partition is one account on one Core. */
|
|
58
77
|
export interface FleetQueue {
|
|
59
78
|
/** Adds a record. Drops the oldest beyond 10,000 and counts the drop. Never throws: a record that cannot be stored is counted as dropped. */
|
|
@@ -86,6 +105,10 @@ export interface FleetQueue {
|
|
|
86
105
|
* an idle reporter does not wake to sweep). `now` defaults to the queue's clock.
|
|
87
106
|
*/
|
|
88
107
|
sweepExpired(now?: number): void
|
|
108
|
+
/** Counts `count` of `packageName`'s telemetry as lost, as of `at` (epoch ms). Never throws. */
|
|
109
|
+
noteTelemetryLoss(partition: string, packageName: string, kind: FleetTelemetryLossKind, count: number, at?: number): void
|
|
110
|
+
/** The telemetry lost since the last call, per package, and resets the counters; `null` when nothing was lost. */
|
|
111
|
+
takeTelemetryLosses(partition: string, now: number): FleetTelemetryLosses | null
|
|
89
112
|
/** Forgets everything held for the partition: records, losses and sequence counters. */
|
|
90
113
|
clearPartition(partition: string): void
|
|
91
114
|
size(partition: string): number
|
|
@@ -138,6 +161,7 @@ const recordPrefix = (partition: string) => `q:${partition}:`
|
|
|
138
161
|
const indexKey = (partition: string) => `i:${partition}`
|
|
139
162
|
const lossKey = (partition: string) => `l:${partition}`
|
|
140
163
|
const sequenceKey = (partition: string) => `n:${partition}`
|
|
164
|
+
const telemetryLossKey = (partition: string) => `t:${partition}`
|
|
141
165
|
|
|
142
166
|
function recordKey(partition: string, sequence: number, recordId: string): string {
|
|
143
167
|
return `${recordPrefix(partition)}${String(sequence).padStart(SEQUENCE_WIDTH, "0")}:${recordId}`
|
|
@@ -213,6 +237,7 @@ interface FoundPartition {
|
|
|
213
237
|
records: string[]
|
|
214
238
|
hasIndex: boolean
|
|
215
239
|
hasLosses: boolean
|
|
240
|
+
hasTelemetryLosses: boolean
|
|
216
241
|
hasSequences: boolean
|
|
217
242
|
}
|
|
218
243
|
|
|
@@ -224,7 +249,7 @@ function groupByPartition(keys: string[]): Map<string, FoundPartition> {
|
|
|
224
249
|
const entry = (partition: string): FoundPartition => {
|
|
225
250
|
let existing = found.get(partition)
|
|
226
251
|
if (!existing) {
|
|
227
|
-
existing = {records: [], hasIndex: false, hasLosses: false, hasSequences: false}
|
|
252
|
+
existing = {records: [], hasIndex: false, hasLosses: false, hasTelemetryLosses: false, hasSequences: false}
|
|
228
253
|
found.set(partition, existing)
|
|
229
254
|
}
|
|
230
255
|
return existing
|
|
@@ -236,6 +261,7 @@ function groupByPartition(keys: string[]): Map<string, FoundPartition> {
|
|
|
236
261
|
} else if (key.startsWith("i:")) entry(key.slice(2)).hasIndex = true
|
|
237
262
|
else if (key.startsWith("l:")) entry(key.slice(2)).hasLosses = true
|
|
238
263
|
else if (key.startsWith("n:")) entry(key.slice(2)).hasSequences = true
|
|
264
|
+
else if (key.startsWith("t:")) entry(key.slice(2)).hasTelemetryLosses = true
|
|
239
265
|
}
|
|
240
266
|
return found
|
|
241
267
|
}
|
|
@@ -367,10 +393,11 @@ function buildQueue(store: FleetKeyValueStore, options: FleetQueueOptions): Flee
|
|
|
367
393
|
|
|
368
394
|
/** Discards records that cannot be read, counting them as dropped. */
|
|
369
395
|
function discardUnreadable(partition: string, state: PartitionState, unreadable: string[]): void {
|
|
396
|
+
// An unreadable telemetry record names no miniapp to count it for, and is never a coverage gap.
|
|
370
397
|
noteLosses(
|
|
371
398
|
partition,
|
|
372
399
|
"dropped",
|
|
373
|
-
unreadable.map(() => undefined),
|
|
400
|
+
unreadable.filter((key) => !isTelemetryKey(partition, key)).map(() => undefined),
|
|
374
401
|
)
|
|
375
402
|
const gone = new Set(unreadable)
|
|
376
403
|
for (const key of unreadable) removeKey(key)
|
|
@@ -378,7 +405,26 @@ function buildQueue(store: FleetKeyValueStore, options: FleetQueueOptions): Flee
|
|
|
378
405
|
persistIndex(partition, state)
|
|
379
406
|
}
|
|
380
407
|
|
|
408
|
+
const idOffsetOf = (partition: string) => recordPrefix(partition).length + SEQUENCE_WIDTH + 1
|
|
409
|
+
const isTelemetryKey = (partition: string, key: string) => isTelemetryRecordId(key.slice(idOffsetOf(partition)))
|
|
410
|
+
|
|
411
|
+
/** Removes the telemetry record at `key`, counting it as `kind` for its package. */
|
|
412
|
+
function evictTelemetry(partition: string, state: PartitionState, key: string, kind: "evicted" | "expired", at: number = now()): void {
|
|
413
|
+
const record = readEntry(key)?.record
|
|
414
|
+
const packageName = record?.kind === "telemetry_event" ? record.payload.packageName : null
|
|
415
|
+
if (packageName !== null) noteTelemetryLoss(partition, packageName, kind, 1, at, record!.observedAt)
|
|
416
|
+
state.keys.splice(state.keys.indexOf(key), 1)
|
|
417
|
+
removeKey(key)
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Keeps the partition inside its places: telemetry inside its share, and the whole inside 10,000, the oldest
|
|
422
|
+
* telemetry going first. Only a queue full of other records drops the oldest record of all, as a coverage gap.
|
|
423
|
+
*/
|
|
381
424
|
function trimToCapacity(partition: string, state: PartitionState): void {
|
|
425
|
+
const telemetry = state.keys.filter((key) => isTelemetryKey(partition, key))
|
|
426
|
+
while (telemetry.length > FLEET_TELEMETRY_LIMITS.queueShare) evictTelemetry(partition, state, telemetry.shift()!, "evicted")
|
|
427
|
+
while (state.keys.length > FLEET_LIMITS.phoneQueueMaxRecords && telemetry.length > 0) evictTelemetry(partition, state, telemetry.shift()!, "evicted")
|
|
382
428
|
const excess = state.keys.length - FLEET_LIMITS.phoneQueueMaxRecords
|
|
383
429
|
if (excess <= 0) return
|
|
384
430
|
const observedAts = state.keys.slice(0, excess).map((key) => readEntry(key)?.record.observedAt)
|
|
@@ -386,6 +432,29 @@ function buildQueue(store: FleetKeyValueStore, options: FleetQueueOptions): Flee
|
|
|
386
432
|
removeOldest(state, excess)
|
|
387
433
|
}
|
|
388
434
|
|
|
435
|
+
function readTelemetryLosses(partition: string): (FleetTelemetryLosses & {updatedAt: number}) | null {
|
|
436
|
+
const value = readJson(telemetryLossKey(partition)) as (FleetTelemetryLosses & {updatedAt: number}) | null
|
|
437
|
+
return value && typeof value === "object" && value.packages && typeof value.packages === "object" ? value : null
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function noteTelemetryLoss(partition: string, packageName: string, kind: FleetTelemetryLossKind, count: number, at: number = now(), observedAt?: string): void {
|
|
441
|
+
if (!(count > 0)) return
|
|
442
|
+
try {
|
|
443
|
+
const stored = readTelemetryLosses(partition) ?? {packages: {}, from: null, to: null, updatedAt: at}
|
|
444
|
+
const entry = stored.packages[packageName] ?? {overLimit: 0, evicted: 0, expired: 0, rejected: 0}
|
|
445
|
+
entry[kind] += count
|
|
446
|
+
stored.packages[packageName] = entry
|
|
447
|
+
const when = observedAt ?? new Date(at).toISOString()
|
|
448
|
+
stored.from = earlier(stored.from, when)
|
|
449
|
+
stored.to = later(stored.to, when)
|
|
450
|
+
stored.updatedAt = at
|
|
451
|
+
store.set(telemetryLossKey(partition), JSON.stringify(stored))
|
|
452
|
+
} catch (error) {
|
|
453
|
+
// Telemetry losses are best effort: a count that cannot be stored is lost with the events it counted.
|
|
454
|
+
warn("could not persist a telemetry loss count", error)
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
389
458
|
function expire(partition: string, state: PartitionState, at: number): void {
|
|
390
459
|
const cutoff = at - FLEET_LIMITS.phoneQueueMaxAgeMs
|
|
391
460
|
const expired: Array<string | undefined> = []
|
|
@@ -395,9 +464,14 @@ function buildQueue(store: FleetKeyValueStore, options: FleetQueueOptions): Flee
|
|
|
395
464
|
// a young record can sit ahead of older ones; those wait until it ages out, which only delays their expiry.
|
|
396
465
|
for (; head < state.keys.length; head += 1) {
|
|
397
466
|
const entry = readEntry(state.keys[head])
|
|
398
|
-
if (entry === null)
|
|
399
|
-
|
|
400
|
-
else
|
|
467
|
+
if (entry === null) {
|
|
468
|
+
if (!isTelemetryKey(partition, state.keys[head]!)) unreadable.push(undefined)
|
|
469
|
+
} else if (entry.enqueuedAt < cutoff) {
|
|
470
|
+
// Expired telemetry is counted per package, never as a coverage gap.
|
|
471
|
+
if (entry.record.kind === "telemetry_event") {
|
|
472
|
+
noteTelemetryLoss(partition, entry.record.payload.packageName, "expired", 1, at, entry.record.observedAt)
|
|
473
|
+
} else expired.push(entry.record.observedAt)
|
|
474
|
+
} else break
|
|
401
475
|
}
|
|
402
476
|
if (head === 0) return
|
|
403
477
|
noteLosses(partition, "expired", expired, at)
|
|
@@ -417,6 +491,10 @@ function buildQueue(store: FleetKeyValueStore, options: FleetQueueOptions): Flee
|
|
|
417
491
|
const losses = readLosses(partition)
|
|
418
492
|
if (losses === null || at - losses.updatedAt > FLEET_LIMITS.phoneQueueMaxAgeMs) removeKey(lossKey(partition))
|
|
419
493
|
}
|
|
494
|
+
if (found.hasTelemetryLosses) {
|
|
495
|
+
const losses = readTelemetryLosses(partition)
|
|
496
|
+
if (losses === null || at - losses.updatedAt > FLEET_LIMITS.phoneQueueMaxAgeMs) removeKey(telemetryLossKey(partition))
|
|
497
|
+
}
|
|
420
498
|
}
|
|
421
499
|
|
|
422
500
|
function loadSequences(partition: string): Array<[string, number]> {
|
|
@@ -441,7 +519,9 @@ function buildQueue(store: FleetKeyValueStore, options: FleetQueueOptions): Flee
|
|
|
441
519
|
state.nextSeq += 1
|
|
442
520
|
} catch (error) {
|
|
443
521
|
warn("could not store a record", error)
|
|
444
|
-
|
|
522
|
+
// Telemetry that cannot be stored is its miniapp's loss, never a coverage gap.
|
|
523
|
+
if (record?.kind === "telemetry_event") noteTelemetryLoss(partition, record.payload.packageName, "evicted", 1, now(), record.observedAt)
|
|
524
|
+
else noteLosses(partition, "dropped", [record?.observedAt])
|
|
445
525
|
return
|
|
446
526
|
}
|
|
447
527
|
trimToCapacity(partition, state)
|
|
@@ -516,11 +596,24 @@ function buildQueue(store: FleetKeyValueStore, options: FleetQueueOptions): Flee
|
|
|
516
596
|
}
|
|
517
597
|
},
|
|
518
598
|
|
|
599
|
+
noteTelemetryLoss(partition, packageName, kind, count, at) {
|
|
600
|
+
noteTelemetryLoss(partition, packageName, kind, count, at)
|
|
601
|
+
},
|
|
602
|
+
|
|
603
|
+
takeTelemetryLosses(partition, at) {
|
|
604
|
+
expire(partition, load(partition), at)
|
|
605
|
+
const losses = readTelemetryLosses(partition)
|
|
606
|
+
if (losses === null || Object.keys(losses.packages).length === 0) return null
|
|
607
|
+
removeKey(telemetryLossKey(partition))
|
|
608
|
+
return {packages: losses.packages, from: losses.from, to: losses.to}
|
|
609
|
+
},
|
|
610
|
+
|
|
519
611
|
clearPartition(partition) {
|
|
520
612
|
const state = load(partition)
|
|
521
613
|
for (const key of state.keys) removeKey(key)
|
|
522
614
|
removeKey(indexKey(partition))
|
|
523
615
|
removeKey(lossKey(partition))
|
|
616
|
+
removeKey(telemetryLossKey(partition))
|
|
524
617
|
removeKey(sequenceKey(partition))
|
|
525
618
|
state.keys = []
|
|
526
619
|
state.nextSeq = 0
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Mentra App's limits on a miniapp's own events (Fleet spec, Miniapp telemetry), applied before an event is
|
|
3
|
+
* queued. MFM checks them again and rejects what still breaks one, so nothing here may let a bad record through.
|
|
4
|
+
*
|
|
5
|
+
* - An event whose name is not 1-64 lowercase letters, digits and underscores, starting with a letter, is dropped.
|
|
6
|
+
* - A property whose name breaks the same rule, or whose value is not a finite number, a boolean or a string, is
|
|
7
|
+
* dropped; a string longer than 64 characters is cut to 64. Only the first 25 properties, in the order given, are
|
|
8
|
+
* kept. What was dropped and cut is counted on the event.
|
|
9
|
+
* - A miniapp sends at most 60 events a minute and 2,000 a UTC day from this phone; the rest are dropped.
|
|
10
|
+
*
|
|
11
|
+
* Every drop of a whole event is counted by the caller as the miniapp's `overLimit` loss.
|
|
12
|
+
*/
|
|
13
|
+
import {FLEET_TELEMETRY_LIMITS, FLEET_TELEMETRY_NAME} from "@mentra/cloud-protocol"
|
|
14
|
+
|
|
15
|
+
export interface LimitedEvent {
|
|
16
|
+
event: string
|
|
17
|
+
properties: Record<string, string | number | boolean>
|
|
18
|
+
droppedProperties: number
|
|
19
|
+
cutValues: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Cuts a string to at most `max` UTF-16 code units without splitting a surrogate pair. */
|
|
23
|
+
function cut(value: string, max: number): string {
|
|
24
|
+
if (value.length <= max) return value
|
|
25
|
+
const end = value.charCodeAt(max - 1)
|
|
26
|
+
return value.slice(0, end >= 0xd800 && end <= 0xdbff ? max - 1 : max)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const isName = (value: unknown): value is string => typeof value === "string" && FLEET_TELEMETRY_NAME.test(value)
|
|
30
|
+
|
|
31
|
+
/** The event as it may be queued, or null when it must be dropped whole. Never throws. */
|
|
32
|
+
export function limitEvent(event: unknown, properties: unknown): LimitedEvent | null {
|
|
33
|
+
if (!isName(event)) return null
|
|
34
|
+
const kept: Record<string, string | number | boolean> = {}
|
|
35
|
+
let droppedProperties = 0
|
|
36
|
+
let cutValues = 0
|
|
37
|
+
let count = 0
|
|
38
|
+
const entries =
|
|
39
|
+
properties !== null && typeof properties === "object" && !Array.isArray(properties) ? Object.entries(properties as Record<string, unknown>) : []
|
|
40
|
+
for (const [name, value] of entries) {
|
|
41
|
+
const valid = isName(name) && ((typeof value === "number" && Number.isFinite(value)) || typeof value === "boolean" || typeof value === "string")
|
|
42
|
+
if (!valid || count >= FLEET_TELEMETRY_LIMITS.maxProperties || name in kept) {
|
|
43
|
+
droppedProperties += 1
|
|
44
|
+
continue
|
|
45
|
+
}
|
|
46
|
+
if (typeof value === "string" && value.length > FLEET_TELEMETRY_LIMITS.maxStringValueLength) {
|
|
47
|
+
kept[name] = cut(value, FLEET_TELEMETRY_LIMITS.maxStringValueLength)
|
|
48
|
+
cutValues += 1
|
|
49
|
+
} else kept[name] = value as string | number | boolean
|
|
50
|
+
count += 1
|
|
51
|
+
}
|
|
52
|
+
return {event, properties: kept, droppedProperties, cutValues}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const MINUTE_MS = 60_000
|
|
56
|
+
const utcDay = (at: number) => new Date(at).toISOString().slice(0, 10)
|
|
57
|
+
|
|
58
|
+
/** Each miniapp's rate on this phone: 60 events in any minute and 2,000 a UTC day. Kept in memory. */
|
|
59
|
+
export class TelemetryRate {
|
|
60
|
+
private readonly recent = new Map<string, number[]>()
|
|
61
|
+
private readonly daily = new Map<string, {day: string; count: number}>()
|
|
62
|
+
|
|
63
|
+
/** Whether `packageName` may send one more event at `at`; counts it when it may. */
|
|
64
|
+
admit(packageName: string, at: number): boolean {
|
|
65
|
+
const times = (this.recent.get(packageName) ?? []).filter((time) => at - time < MINUTE_MS && time <= at)
|
|
66
|
+
const day = utcDay(at)
|
|
67
|
+
const today = this.daily.get(packageName)
|
|
68
|
+
const sent = today?.day === day ? today.count : 0
|
|
69
|
+
if (times.length >= FLEET_TELEMETRY_LIMITS.eventsPerMinute || sent >= FLEET_TELEMETRY_LIMITS.eventsPerDay) {
|
|
70
|
+
this.recent.set(packageName, times)
|
|
71
|
+
return false
|
|
72
|
+
}
|
|
73
|
+
times.push(at)
|
|
74
|
+
this.recent.set(packageName, times)
|
|
75
|
+
this.daily.set(packageName, {day, count: sent + 1})
|
|
76
|
+
return true
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* development build of the name it serves, over HTTP or HTTPS: never registered, never updated from its URL, with
|
|
18
18
|
* its own data, tokens marked as a local build's, and no system privileges ({@link installLocalBuild}).
|
|
19
19
|
*/
|
|
20
|
-
import type {RegistryRegisterRequest, RegistryRegisterResponse} from "@mentra/cloud-protocol"
|
|
20
|
+
import type {FleetInstallFailureReason, RegistryRegisterRequest, RegistryRegisterResponse} from "@mentra/cloud-protocol"
|
|
21
21
|
import {
|
|
22
22
|
isLocalNetworkHost,
|
|
23
23
|
normalizeMiniappUrl,
|
|
@@ -31,6 +31,7 @@ import {MiniappRunningError} from "../utils/storeInstallRuntime"
|
|
|
31
31
|
import appRegistry, {type InstallableReleaseIdentity, type MiniappReleaseIdentity} from "./AppRegistry"
|
|
32
32
|
import {cloudClientService} from "./CloudClientService"
|
|
33
33
|
import type {ManagedAssignment} from "./fleet/assignmentReconciler"
|
|
34
|
+
import {fleetEvents, type FleetInstallEvent} from "./fleet/fleetEvents"
|
|
34
35
|
import localMiniappRuntime from "./LocalMiniappRuntime"
|
|
35
36
|
import {checkMiniappAge, type AgeGateVerdict, type AgeRatedMiniapp} from "./miniappAgeGate"
|
|
36
37
|
import {isPackageNameAllowedForUrl} from "./miniappDomainRule"
|
|
@@ -89,6 +90,12 @@ export interface InstallMiniappOptions {
|
|
|
89
90
|
expectedPackageName?: string
|
|
90
91
|
/** True once the install should stop. Nothing commits after that. */
|
|
91
92
|
isCancelled?: () => boolean
|
|
93
|
+
/**
|
|
94
|
+
* Called once the installer knows the package, the release it would replace (null for none) and the version it would
|
|
95
|
+
* install, before anything is downloaded or asked: how Fleet reports the attempt whatever comes after
|
|
96
|
+
* (`installMiniapp`).
|
|
97
|
+
*/
|
|
98
|
+
onResolved?: (packageName: string, previousVersion: string | null, version: string) => void
|
|
92
99
|
/**
|
|
93
100
|
* Headless only: called when the update of a running miniapp is staged and waits for the person's answer to its
|
|
94
101
|
* update prompt. The call resolves once they answer: `installed`, or `deferred` for Not now.
|
|
@@ -223,6 +230,7 @@ export function createMiniappInstaller(
|
|
|
223
230
|
// A miniapp installed from this URL opens, unless a development build of its name runs in its place: installing
|
|
224
231
|
// it again replaces that build. Fleet's reconciler decides for itself what an assignment installs.
|
|
225
232
|
const active = deps.activeRelease(packageName)
|
|
233
|
+
options.onResolved?.(packageName, active?.version ?? null, manifest.version)
|
|
226
234
|
if (options.source !== "assignment" && active && !deps.hasDevelopmentSelection(packageName)) {
|
|
227
235
|
const installedFrom = deps.updateUrl(packageName, active.identity)
|
|
228
236
|
if (installedFrom && normalizeMiniappUrl(installedFrom)?.url === target.url) {
|
|
@@ -370,6 +378,7 @@ async function installFromLocalNetwork(
|
|
|
370
378
|
assertCurrent()
|
|
371
379
|
const {manifest} = served
|
|
372
380
|
const packageName = manifest.packageName
|
|
381
|
+
options.onResolved?.(packageName, deps.activeRelease(packageName)?.version ?? null, manifest.version)
|
|
373
382
|
if (!deps.developmentAllowed(packageName)) {
|
|
374
383
|
throw new InstallMiniappError(
|
|
375
384
|
"access_denied",
|
|
@@ -730,10 +739,77 @@ const engineDependencies: InstallMiniappDependencies = {
|
|
|
730
739
|
|
|
731
740
|
let engineInstaller: ((url: string, options: InstallMiniappOptions) => Promise<InstallMiniappResult>) | null = null
|
|
732
741
|
|
|
742
|
+
/** Fleet's install reason for an install that stopped (Phase 12): `assignment_outcome`'s reasons where they apply. */
|
|
743
|
+
const FLEET_INSTALL_REASON: Record<InstallMiniappErrorCode, FleetInstallFailureReason> = {
|
|
744
|
+
not_a_miniapp: "not_a_miniapp",
|
|
745
|
+
domain_mismatch: "not_a_miniapp",
|
|
746
|
+
archived: "archived",
|
|
747
|
+
needs_newer_app: "incompatible_host",
|
|
748
|
+
access_denied: "access_denied",
|
|
749
|
+
age_restricted: "access_denied",
|
|
750
|
+
network: "network",
|
|
751
|
+
cancelled: "user_cancelled",
|
|
752
|
+
integrity: "download_failed",
|
|
753
|
+
signature_invalid: "signature_invalid",
|
|
754
|
+
}
|
|
755
|
+
|
|
733
756
|
/**
|
|
734
757
|
* Install the miniapp at a miniapp URL (see the module comment). Throws {@link InstallMiniappError}.
|
|
758
|
+
*
|
|
759
|
+
* Every attempt whose package the installer resolved is reported to Fleet (`miniapp_install`): an update when it
|
|
760
|
+
* replaces an installed release, an install otherwise; installed, deferred, or failed with its reason. One the person
|
|
761
|
+
* declined is deferred, not failed. An attempt that stopped before its package was known (an unreachable URL) cannot be
|
|
762
|
+
* attributed, and is not reported.
|
|
735
763
|
*/
|
|
736
764
|
export function installMiniapp(url: string, options: InstallMiniappOptions): Promise<InstallMiniappResult> {
|
|
737
|
-
engineInstaller ??= createMiniappInstaller(engineDependencies)
|
|
765
|
+
engineInstaller ??= withFleetReport(createMiniappInstaller(engineDependencies))
|
|
738
766
|
return engineInstaller(url, options)
|
|
739
767
|
}
|
|
768
|
+
|
|
769
|
+
/** `installer`, reporting each attempt to Fleet as `installMiniapp` describes. Exported for tests. */
|
|
770
|
+
export function withFleetReport(
|
|
771
|
+
installer: (url: string, options: InstallMiniappOptions) => Promise<InstallMiniappResult>,
|
|
772
|
+
): (url: string, options: InstallMiniappOptions) => Promise<InstallMiniappResult> {
|
|
773
|
+
return (url, options) => reportAttempt(installer, url, options)
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
async function reportAttempt(
|
|
777
|
+
installer: (url: string, options: InstallMiniappOptions) => Promise<InstallMiniappResult>,
|
|
778
|
+
url: string,
|
|
779
|
+
options: InstallMiniappOptions,
|
|
780
|
+
): Promise<InstallMiniappResult> {
|
|
781
|
+
const source = options.source === "assignment" ? "assignment" : options.source === "local" ? "local" : "url"
|
|
782
|
+
// What the installer resolved: the package and the release it replaces, for an update and for a failure.
|
|
783
|
+
type Attempt = {packageName: string; previousVersion: string | null; version: string}
|
|
784
|
+
let attempt: Attempt | null = null
|
|
785
|
+
const resolved: InstallMiniappOptions = {
|
|
786
|
+
...options,
|
|
787
|
+
onResolved: (packageName, previousVersion, version) => {
|
|
788
|
+
attempt = {packageName, previousVersion, version}
|
|
789
|
+
options.onResolved?.(packageName, previousVersion, version)
|
|
790
|
+
},
|
|
791
|
+
}
|
|
792
|
+
// Every report names the version the attempt was installing, whatever is installed when it is made.
|
|
793
|
+
const report = (fields: Pick<FleetInstallEvent, "outcome" | "reason">) => {
|
|
794
|
+
const known = attempt as Attempt | null
|
|
795
|
+
if (known === null) return
|
|
796
|
+
fleetEvents.install({
|
|
797
|
+
packageName: known.packageName,
|
|
798
|
+
operation: known.previousVersion ? "update" : "install",
|
|
799
|
+
source,
|
|
800
|
+
...(known.previousVersion ? {fromVersion: known.previousVersion} : {}),
|
|
801
|
+
toVersion: known.version,
|
|
802
|
+
...fields,
|
|
803
|
+
} as FleetInstallEvent)
|
|
804
|
+
}
|
|
805
|
+
try {
|
|
806
|
+
const result = await installer(url, resolved)
|
|
807
|
+
if (result.status === "installed") report({outcome: "succeeded"})
|
|
808
|
+
else if (result.status === "deferred") report({outcome: "deferred", reason: "unknown"})
|
|
809
|
+
return result
|
|
810
|
+
} catch (error) {
|
|
811
|
+
const reason = error instanceof InstallMiniappError ? FLEET_INSTALL_REASON[error.code] : "unknown"
|
|
812
|
+
report({outcome: reason === "user_cancelled" ? "deferred" : "failed", reason})
|
|
813
|
+
throw error
|
|
814
|
+
}
|
|
815
|
+
}
|
|
@@ -333,7 +333,7 @@ export function classifyManagedInstallError(error: unknown): {kind: ManagedInsta
|
|
|
333
333
|
|
|
334
334
|
/** Uninstall a withdrawn miniapp. Its data stays in its partition for a later reinstall. */
|
|
335
335
|
export async function uninstallManagedMiniapp(packageName: string): Promise<void> {
|
|
336
|
-
const result = await useAppStatusStore.getState().uninstall(packageName)
|
|
336
|
+
const result = await useAppStatusStore.getState().uninstall(packageName, undefined, {fleetSource: "assignment"})
|
|
337
337
|
if (result.is_error()) throw result.error
|
|
338
338
|
}
|
|
339
339
|
|
package/src/stores/apps.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {useMemo} from "react"
|
|
|
14
14
|
import {AppState} from "react-native"
|
|
15
15
|
import {AsyncResult, Result, result as Res} from "typesafe-ts"
|
|
16
16
|
import {create} from "zustand"
|
|
17
|
+
import type {FleetLaunchFailureReason} from "@mentra/cloud-protocol"
|
|
17
18
|
|
|
18
19
|
import type {ClientApp} from "../types/applet"
|
|
19
20
|
import {DeviceTypes} from "../types/enums"
|
|
@@ -364,7 +365,7 @@ function reportingLaunches(
|
|
|
364
365
|
return async (clientApp, opts) => {
|
|
365
366
|
let attempted = false
|
|
366
367
|
let started = false
|
|
367
|
-
const claim: {release?: () =>
|
|
368
|
+
const claim: {release?: () => FleetLaunchFailureReason | undefined} = {}
|
|
368
369
|
try {
|
|
369
370
|
started = await launch(clientApp, opts, {
|
|
370
371
|
begin: () => {
|
|
@@ -374,6 +375,8 @@ function reportingLaunches(
|
|
|
374
375
|
})
|
|
375
376
|
return started
|
|
376
377
|
} finally {
|
|
378
|
+
// The launcher classified any spawn failure it held back for this start; this report carries it.
|
|
379
|
+
const failureReason = claim.release?.()
|
|
377
380
|
if (attempted) {
|
|
378
381
|
fleetEvents.launch({
|
|
379
382
|
packageName: clientApp.packageName,
|
|
@@ -381,9 +384,9 @@ function reportingLaunches(
|
|
|
381
384
|
versionName: clientApp.version,
|
|
382
385
|
start: opts?.trigger ?? "automatic",
|
|
383
386
|
outcome: started ? "started" : "failed",
|
|
387
|
+
...(!started && failureReason ? {failureReason} : {}),
|
|
384
388
|
})
|
|
385
389
|
}
|
|
386
|
-
claim.release?.()
|
|
387
390
|
}
|
|
388
391
|
}
|
|
389
392
|
}
|
|
@@ -699,7 +702,21 @@ export const useAppStatusStore = create<AppStatusState>((set, get) => ({
|
|
|
699
702
|
if (uninstallingPackages.has(packageName)) throw new Error(`${packageName} is already being uninstalled`)
|
|
700
703
|
uninstallingPackages.add(packageName)
|
|
701
704
|
try {
|
|
705
|
+
// Only for Fleet's record: a lookup that fails (or throws) never stops the removal.
|
|
706
|
+
const removed =
|
|
707
|
+
version ??
|
|
708
|
+
(await Promise.resolve()
|
|
709
|
+
.then(() => appRegistry.getActiveVersion(packageName))
|
|
710
|
+
.catch(() => undefined))
|
|
702
711
|
const res = await appRegistry.uninstall(packageName, version, options)
|
|
712
|
+
// Fleet (Phase 12): every removal attempt, and how it went. The reason of a failed one is not known here.
|
|
713
|
+
fleetEvents.install({
|
|
714
|
+
packageName,
|
|
715
|
+
operation: "remove",
|
|
716
|
+
source: options?.fleetSource ?? "url",
|
|
717
|
+
...(removed ? {fromVersion: removed} : {}),
|
|
718
|
+
...(res.is_error() ? {outcome: "failed" as const, reason: "unknown" as const} : {outcome: "succeeded" as const}),
|
|
719
|
+
})
|
|
703
720
|
if (res.is_error()) throw res.error
|
|
704
721
|
set((s) => ({apps: s.apps.filter((a) => a.packageName !== packageName)}))
|
|
705
722
|
} finally {
|