@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.
Files changed (67) hide show
  1. package/build/facades/glasses.d.ts.map +1 -1
  2. package/build/facades/glasses.js +5 -3
  3. package/build/facades/glasses.js.map +1 -1
  4. package/build/generated/releaseMetadata.js +5 -5
  5. package/build/generated/releaseMetadata.js.map +1 -1
  6. package/build/services/AppRegistry.d.ts +2 -0
  7. package/build/services/AppRegistry.d.ts.map +1 -1
  8. package/build/services/AppRegistry.js.map +1 -1
  9. package/build/services/LocalMiniappRuntime.d.ts +2 -0
  10. package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
  11. package/build/services/LocalMiniappRuntime.js +18 -1
  12. package/build/services/LocalMiniappRuntime.js.map +1 -1
  13. package/build/services/MentraJSCrashController.d.ts +2 -0
  14. package/build/services/MentraJSCrashController.d.ts.map +1 -1
  15. package/build/services/MentraJSCrashController.js +4 -0
  16. package/build/services/MentraJSCrashController.js.map +1 -1
  17. package/build/services/MentraJSRouter.d.ts.map +1 -1
  18. package/build/services/MentraJSRouter.js +14 -3
  19. package/build/services/MentraJSRouter.js.map +1 -1
  20. package/build/services/MiniappLauncher.d.ts +6 -0
  21. package/build/services/MiniappLauncher.d.ts.map +1 -1
  22. package/build/services/MiniappLauncher.js +20 -3
  23. package/build/services/MiniappLauncher.js.map +1 -1
  24. package/build/services/fleet/FleetReporter.d.ts.map +1 -1
  25. package/build/services/fleet/FleetReporter.js +43 -0
  26. package/build/services/fleet/FleetReporter.js.map +1 -1
  27. package/build/services/fleet/fleetCollector.d.ts +1 -1
  28. package/build/services/fleet/fleetCollector.d.ts.map +1 -1
  29. package/build/services/fleet/fleetCollector.js +146 -5
  30. package/build/services/fleet/fleetCollector.js.map +1 -1
  31. package/build/services/fleet/fleetEvents.d.ts +39 -3
  32. package/build/services/fleet/fleetEvents.d.ts.map +1 -1
  33. package/build/services/fleet/fleetEvents.js +66 -22
  34. package/build/services/fleet/fleetEvents.js.map +1 -1
  35. package/build/services/fleet/fleetQueue.d.ts +26 -0
  36. package/build/services/fleet/fleetQueue.d.ts.map +1 -1
  37. package/build/services/fleet/fleetQueue.js +94 -8
  38. package/build/services/fleet/fleetQueue.js.map +1 -1
  39. package/build/services/fleet/telemetryLimits.d.ts +16 -0
  40. package/build/services/fleet/telemetryLimits.d.ts.map +1 -0
  41. package/build/services/fleet/telemetryLimits.js +69 -0
  42. package/build/services/fleet/telemetryLimits.js.map +1 -0
  43. package/build/services/installMiniapp.d.ts +13 -0
  44. package/build/services/installMiniapp.d.ts.map +1 -1
  45. package/build/services/installMiniapp.js +64 -1
  46. package/build/services/installMiniapp.js.map +1 -1
  47. package/build/services/miniappReleaseInstall.js +1 -1
  48. package/build/services/miniappReleaseInstall.js.map +1 -1
  49. package/build/stores/apps.d.ts.map +1 -1
  50. package/build/stores/apps.js +16 -1
  51. package/build/stores/apps.js.map +1 -1
  52. package/package.json +8 -8
  53. package/src/facades/glasses.ts +5 -3
  54. package/src/generated/releaseMetadata.ts +5 -5
  55. package/src/services/AppRegistry.ts +2 -0
  56. package/src/services/LocalMiniappRuntime.ts +20 -1
  57. package/src/services/MentraJSCrashController.ts +5 -0
  58. package/src/services/MentraJSRouter.ts +15 -3
  59. package/src/services/MiniappLauncher.ts +18 -2
  60. package/src/services/fleet/FleetReporter.ts +40 -0
  61. package/src/services/fleet/fleetCollector.ts +160 -6
  62. package/src/services/fleet/fleetEvents.ts +104 -20
  63. package/src/services/fleet/fleetQueue.ts +100 -7
  64. package/src/services/fleet/telemetryLimits.ts +78 -0
  65. package/src/services/installMiniapp.ts +78 -2
  66. package/src/services/miniappReleaseInstall.ts +1 -1
  67. 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) unreadable.push(undefined)
399
- else if (entry.enqueuedAt < cutoff) expired.push(entry.record.observedAt)
400
- else break
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
- noteLosses(partition, "dropped", [record?.observedAt])
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
 
@@ -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?: () => void} = {}
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 {