@mentra/engine 3.4.0-dev.1208 → 3.4.0-dev.1210

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 (71) 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/MiniappEngine.d.ts.map +1 -1
  21. package/build/services/MiniappEngine.js +2 -0
  22. package/build/services/MiniappEngine.js.map +1 -1
  23. package/build/services/MiniappLauncher.d.ts +24 -2
  24. package/build/services/MiniappLauncher.d.ts.map +1 -1
  25. package/build/services/MiniappLauncher.js +58 -6
  26. package/build/services/MiniappLauncher.js.map +1 -1
  27. package/build/services/fleet/FleetReporter.d.ts.map +1 -1
  28. package/build/services/fleet/FleetReporter.js +43 -0
  29. package/build/services/fleet/FleetReporter.js.map +1 -1
  30. package/build/services/fleet/fleetCollector.d.ts +1 -1
  31. package/build/services/fleet/fleetCollector.d.ts.map +1 -1
  32. package/build/services/fleet/fleetCollector.js +146 -5
  33. package/build/services/fleet/fleetCollector.js.map +1 -1
  34. package/build/services/fleet/fleetEvents.d.ts +39 -3
  35. package/build/services/fleet/fleetEvents.d.ts.map +1 -1
  36. package/build/services/fleet/fleetEvents.js +66 -22
  37. package/build/services/fleet/fleetEvents.js.map +1 -1
  38. package/build/services/fleet/fleetQueue.d.ts +26 -0
  39. package/build/services/fleet/fleetQueue.d.ts.map +1 -1
  40. package/build/services/fleet/fleetQueue.js +94 -8
  41. package/build/services/fleet/fleetQueue.js.map +1 -1
  42. package/build/services/fleet/telemetryLimits.d.ts +16 -0
  43. package/build/services/fleet/telemetryLimits.d.ts.map +1 -0
  44. package/build/services/fleet/telemetryLimits.js +69 -0
  45. package/build/services/fleet/telemetryLimits.js.map +1 -0
  46. package/build/services/installMiniapp.d.ts +13 -0
  47. package/build/services/installMiniapp.d.ts.map +1 -1
  48. package/build/services/installMiniapp.js +64 -1
  49. package/build/services/installMiniapp.js.map +1 -1
  50. package/build/services/miniappReleaseInstall.js +1 -1
  51. package/build/services/miniappReleaseInstall.js.map +1 -1
  52. package/build/stores/apps.d.ts.map +1 -1
  53. package/build/stores/apps.js +16 -1
  54. package/build/stores/apps.js.map +1 -1
  55. package/package.json +8 -8
  56. package/src/facades/glasses.ts +5 -3
  57. package/src/generated/releaseMetadata.ts +5 -5
  58. package/src/services/AppRegistry.ts +2 -0
  59. package/src/services/LocalMiniappRuntime.ts +20 -1
  60. package/src/services/MentraJSCrashController.ts +5 -0
  61. package/src/services/MentraJSRouter.ts +15 -3
  62. package/src/services/MiniappEngine.ts +2 -0
  63. package/src/services/MiniappLauncher.ts +58 -5
  64. package/src/services/fleet/FleetReporter.ts +40 -0
  65. package/src/services/fleet/fleetCollector.ts +160 -6
  66. package/src/services/fleet/fleetEvents.ts +104 -20
  67. package/src/services/fleet/fleetQueue.ts +100 -7
  68. package/src/services/fleet/telemetryLimits.ts +78 -0
  69. package/src/services/installMiniapp.ts +78 -2
  70. package/src/services/miniappReleaseInstall.ts +1 -1
  71. package/src/stores/apps.ts +19 -2
@@ -36,10 +36,16 @@
36
36
  * declared already.
37
37
  */
38
38
 
39
+ import type {FleetLaunchFailureReason} from "@mentra/cloud-protocol"
39
40
  import {File} from "expo-file-system"
40
41
  import {AppState, Platform} from "react-native"
41
42
 
42
- import {isDevMiniappAllowed, isMiniappAvailable, type MiniappAvailabilityMode} from "../runtime/bootstrap"
43
+ import {
44
+ getConfigValues,
45
+ isDevMiniappAllowed,
46
+ isMiniappAvailable,
47
+ type MiniappAvailabilityMode,
48
+ } from "../runtime/bootstrap"
43
49
  import {resolveDevBundleSource} from "../utils/devMiniappSnapshot"
44
50
  import {storage} from "../utils/storage/storage"
45
51
  import {MiniappRunningError} from "../utils/storeInstallRuntime"
@@ -47,6 +53,7 @@ import appRegistry, {getLocalAppRunningState, saveLocalAppRunningState} from "./
47
53
  import devServerBridge from "./DevServerBridge"
48
54
  import {fleetEvents} from "./fleet/fleetEvents"
49
55
  import localMiniappRuntime, {type InstalledMiniappManifest} from "./LocalMiniappRuntime"
56
+ import {checkManifestVersions} from "./manifestVersionGate"
50
57
  import type {MentraJSRouter} from "./MentraJSRouter"
51
58
  import {checkMiniappAge, type AgeGateVerdict, type AgeRatedMiniapp} from "./miniappAgeGate"
52
59
  import {developmentOriginName, miniappOrigin, miniappOriginUrl, type MiniappOriginPlatform} from "./miniappOrigin"
@@ -74,6 +81,23 @@ export class MiniappAgeRestrictedError extends Error {
74
81
  }
75
82
  }
76
83
 
84
+ /**
85
+ * The bundle needs a newer Mentra App, or a Mentra Miniapp SDK this one doesn't run: the rule installs refuse by,
86
+ * checked again as it launches. A release a newer Mentra App installed stays on disk when an older one replaces it (a
87
+ * development build replaced from TestFlight), and would otherwise start against features this one lacks. The message
88
+ * is for logs; a screen explains it in the person's language, as an install refused by the same rule does.
89
+ */
90
+ export class MiniappIncompatibleHostError extends Error {
91
+ constructor(
92
+ readonly packageName: string,
93
+ readonly miniappName: string,
94
+ readonly reason: string,
95
+ ) {
96
+ super(`${miniappName} can't run on this phone: ${reason}`)
97
+ this.name = "MiniappIncompatibleHostError"
98
+ }
99
+ }
100
+
77
101
  interface LauncherDeps {
78
102
  /** The host-constructed router (needs the native Crust binding). */
79
103
  router: MentraJSRouter
@@ -180,6 +204,21 @@ function serverOrigin(url: string): string | null {
180
204
  return match ? match[1].toLowerCase() : null
181
205
  }
182
206
 
207
+ /**
208
+ * Why a launch failed, as the code Fleet takes (Phase 12): never the error's text. A bundle that cannot be found is
209
+ * `bundle_missing`; one that needs a newer Mentra App `incompatible_host`; a deployment, age or access block
210
+ * `policy_blocked`; a context that did not start `spawn_failed`.
211
+ */
212
+ export function launchFailureReason(error: unknown): FleetLaunchFailureReason {
213
+ if (error instanceof MiniappIncompatibleHostError) return "incompatible_host"
214
+ if (error instanceof MiniappAgeRestrictedError || error instanceof MiniappAccessRevokedError) return "policy_blocked"
215
+ const message = error instanceof Error ? error.message : ""
216
+ if (message.includes("cannot resolve bundle")) return "bundle_missing"
217
+ if (message.includes("by deployment policy")) return "policy_blocked"
218
+ if (message.includes("spawn failed")) return "spawn_failed"
219
+ return "unknown"
220
+ }
221
+
183
222
  class MiniappLauncher {
184
223
  private deps: LauncherDeps | null = null
185
224
 
@@ -475,6 +514,15 @@ class MiniappLauncher {
475
514
  }
476
515
  }
477
516
 
517
+ /** Throws {@link MiniappIncompatibleHostError} when this Mentra App can't run the resolved bundle. */
518
+ assertRunsOnPhone(packageName: string, resolved: ResolvedBundle): void {
519
+ const manifest = resolved.installedManifest
520
+ const {hostVersion, supportedMiniappSdkRange} = getConfigValues()
521
+ if (!manifest || !hostVersion || !supportedMiniappSdkRange) return
522
+ const versions = checkManifestVersions(manifest, {hostVersion, supportedSdkRange: supportedMiniappSdkRange})
523
+ if (!versions.ok) throw new MiniappIncompatibleHostError(packageName, manifest.name || packageName, versions.reason)
524
+ }
525
+
478
526
  /**
479
527
  * Ensure a miniapp's background JS context is spawned. Idempotent — if the
480
528
  * context is already alive this is a no-op spawn-wise (it still resolves the
@@ -525,7 +573,7 @@ class MiniappLauncher {
525
573
  result = await pending
526
574
  } catch (error) {
527
575
  // A visible launch reports its own failure; one joined to a hidden wake fails with it.
528
- if (projectRunning && joinedHidden) this.reportLaunch(packageName, "failed", runtimeOptions)
576
+ if (projectRunning && joinedHidden) this.reportLaunch(packageName, "failed", runtimeOptions, undefined, launchFailureReason(error))
529
577
  throw error
530
578
  }
531
579
  if (projectRunning && this.promote(packageName)) this.reportLaunch(packageName, "started", runtimeOptions)
@@ -538,7 +586,7 @@ class MiniappLauncher {
538
586
  try {
539
587
  return await launch
540
588
  } catch (error) {
541
- if (projectRunning) this.reportLaunch(packageName, "failed", runtimeOptions)
589
+ if (projectRunning) this.reportLaunch(packageName, "failed", runtimeOptions, undefined, launchFailureReason(error))
542
590
  throw error
543
591
  } finally {
544
592
  this.inFlight.delete(packageName)
@@ -560,6 +608,7 @@ class MiniappLauncher {
560
608
  outcome: "started" | "failed",
561
609
  runtimeOptions?: RuntimeLaunchOptions,
562
610
  manifest?: InstalledMiniappManifest,
611
+ failureReason?: FleetLaunchFailureReason,
563
612
  ): void {
564
613
  fleetEvents.launchUnlessClaimed({
565
614
  packageName,
@@ -567,6 +616,7 @@ class MiniappLauncher {
567
616
  ...(manifest?.version ? {versionName: manifest.version} : {}),
568
617
  start: runtimeOptions?.trigger ?? "automatic",
569
618
  outcome,
619
+ ...(outcome === "failed" ? {failureReason: failureReason ?? "unknown"} : {}),
570
620
  })
571
621
  }
572
622
 
@@ -581,6 +631,8 @@ class MiniappLauncher {
581
631
  if (!resolved) {
582
632
  throw new Error(`MiniappLauncher: cannot resolve bundle for ${packageName}`)
583
633
  }
634
+ // Before the age gate, so a miniapp this app can't run never asks the person's age.
635
+ this.assertRunsOnPhone(packageName, resolved)
584
636
  const version = resolved.installedManifest?.version
585
637
  const projectRunning = runtimeOptions?.projectRunning ?? true
586
638
  // The age gate, before the deployment checks below, which then see the state after any question the person
@@ -654,9 +706,10 @@ class MiniappLauncher {
654
706
  * persisted running flag if ensureRunning fails (for example, bundle resolution,
655
707
  * deployment policy, or context spawning) so it does not retry every boot.
656
708
  * Each restore is an automatic launch to Fleet, reported by ensureRunning.
657
- * Not compatibility-gated: a previously-running background app shouldn't be
709
+ * Not gated on the glasses' hardware: a previously-running background app shouldn't be
658
710
  * dropped just because glasses are momentarily disconnected at boot — it
659
- * resumes when they reconnect, same as mid-session.
711
+ * resumes when they reconnect, same as mid-session. A release this Mentra App
712
+ * can't run is refused like any launch (see {@link assertRunsOnPhone}).
660
713
  */
661
714
  async autostartLocalMiniapps(): Promise<void> {
662
715
  const apps = await appRegistry.getInstalledMiniapps()
@@ -217,6 +217,8 @@ export function createFleetReporter(deps: FleetReporterDeps = {}): FleetReporter
217
217
  clearPartition: (partition) => queue.clearPartition(partition),
218
218
  size: (partition) => queue.size(partition),
219
219
  nextSequence: (partition, collectorSessionId) => queue.nextSequence(partition, collectorSessionId),
220
+ noteTelemetryLoss: (partition, packageName, kind, count, at) => queue.noteTelemetryLoss(partition, packageName, kind, count, at),
221
+ takeTelemetryLosses: (partition, at) => queue.takeTelemetryLosses(partition, at),
220
222
  }
221
223
  const collector = (deps.createCollector ?? ((q: FleetQueue) => createFleetCollector({queue: q})))(collectorQueue)
222
224
 
@@ -429,6 +431,7 @@ export function createFleetReporter(deps: FleetReporterDeps = {}): FleetReporter
429
431
  const at = now()
430
432
  // takeLosses clears the counts it returns, so their gap is queued before anything else can happen.
431
433
  recordLosses(r, queue.takeLosses(r.partition, at), at)
434
+ recordTelemetryLosses(r, at)
432
435
  const records = queue.nextBatch(r.partition, r.batchLimit, MAX_BATCH_BYTES)
433
436
  if (records.length === 0) return ASLEEP
434
437
  let outcome: FleetSendOutcome
@@ -685,9 +688,46 @@ export function createFleetReporter(deps: FleetReporterDeps = {}): FleetReporter
685
688
  }
686
689
  }
687
690
 
691
+ /**
692
+ * The miniapp telemetry lost since the last upload, per package, as one `telemetry_loss` record. Telemetry losses
693
+ * never become coverage gaps. Counts that cannot be queued are lost with the events they counted.
694
+ */
695
+ function recordTelemetryLosses(r: Run, at: number): void {
696
+ const losses = queue.takeTelemetryLosses(r.partition, at)
697
+ if (losses === null) return
698
+ const entries = Object.entries(losses.packages)
699
+ .map(([packageName, counts]) => ({packageName, ...counts}))
700
+ .filter((loss) => loss.overLimit + loss.evicted + loss.expired + loss.rejected > 0)
701
+ .slice(0, FLEET_LIMITS.maxTelemetryLossPackages)
702
+ if (entries.length === 0) return
703
+ const end = iso(at)
704
+ const from = losses.from !== null && timeOf(losses.from) !== null && timeOf(losses.from)! <= at ? losses.from : end
705
+ try {
706
+ queue.enqueue(r.partition, {
707
+ schemaVersion: FLEET_SCHEMA_VERSION,
708
+ recordId: newId(),
709
+ phoneDeviceId: phoneDeviceId(),
710
+ collectorSessionId: r.collectorSessionId,
711
+ sequence: queue.nextSequence(r.partition, r.collectorSessionId),
712
+ observedAt: end,
713
+ capabilities: ["miniapp_telemetry"],
714
+ kind: "telemetry_loss",
715
+ payload: {from, to: end, losses: entries},
716
+ })
717
+ } catch (error) {
718
+ warn("recording telemetry losses", error)
719
+ }
720
+ }
721
+
688
722
  function recordRejected(r: Run, record: FleetRecord, at: number): void {
689
723
  // A refused gap gets no gap of its own, so a rejection can never feed itself.
690
724
  if (record.kind === "coverage_gap") return
725
+ // Rejected telemetry is counted for its miniapp, never as a gap; a refused loss report is not counted again.
726
+ if (record.kind === "telemetry_event") {
727
+ queue.noteTelemetryLoss(r.partition, record.payload.packageName, "rejected", 1, at)
728
+ return
729
+ }
730
+ if (record.kind === "telemetry_loss") return
691
731
  const time = iso(timeOf(record.observedAt) ?? at)
692
732
  enqueueGap(r, {reason: "record_rejected", from: time, to: time, droppedRecordCount: 1}, at)
693
733
  }
@@ -72,9 +72,18 @@ import {useGlassesStore} from "../../stores/glasses"
72
72
  import {BgTimer} from "../../utils/timers"
73
73
  import {miniappRunningRegistry} from "../MiniappRunningRegistry"
74
74
  import {nativeMiniappExecution} from "../NativeMiniappExecution"
75
- import {fleetEvents, type FleetLaunchEvent, type FleetPhotoEvent} from "./fleetEvents"
75
+ import {
76
+ fleetEvents,
77
+ type FleetConnectionEvent,
78
+ type FleetCrashEvent,
79
+ type FleetInstallEvent,
80
+ type FleetLaunchEvent,
81
+ type FleetPhotoEvent,
82
+ type FleetTelemetryEvent,
83
+ } from "./fleetEvents"
76
84
  import {newId, phoneDeviceId} from "./fleetIds"
77
- import type {FleetQueue} from "./fleetQueue"
85
+ import {TELEMETRY_RECORD_PREFIX, type FleetQueue} from "./fleetQueue"
86
+ import {limitEvent, TelemetryRate} from "./telemetryLimits"
78
87
  import {appPhoneBattery} from "./phoneBatterySource"
79
88
  import {
80
89
  glassesProduct,
@@ -208,6 +217,9 @@ const MODELS_WITHOUT_BATTERY: ReadonlySet<string> = new Set<string>()
208
217
  * What the phone measures without glasses. With glasses connected it measures all of them, less the battery of glasses
209
218
  * that cannot report one. A phone that cannot report its own battery does not claim `phone_battery`.
210
219
  */
220
+ /** A disconnect this soon after the person asked for one is theirs. */
221
+ const USER_DISCONNECT_WINDOW_MS = 10_000
222
+
211
223
  const PHONE_CAPABILITIES: Capability[] = [
212
224
  "glasses_connection",
213
225
  "miniapp_execution",
@@ -215,6 +227,10 @@ const PHONE_CAPABILITIES: Capability[] = [
215
227
  "engine_active",
216
228
  "photo_operations",
217
229
  "phone_battery",
230
+ // Phase 12: what this Mentra App measures whatever is connected.
231
+ "miniapp_failures",
232
+ "glasses_connectivity",
233
+ "miniapp_telemetry",
218
234
  ]
219
235
 
220
236
  function warn(what: string, error: unknown): void {
@@ -592,6 +608,12 @@ class CollectorRun {
592
608
  private lastSnapshotKey: string | null = null
593
609
 
594
610
  private known: KnownGlasses | null = null
611
+ /** The store's last connection state, and when the glasses last dropped (for a reconnect's time). */
612
+ private rawConnection: string | null = null
613
+ private disconnectedAt: number | null = null
614
+ private connectFailed = false
615
+ /** Each miniapp's telemetry rate on this phone, for this run. */
616
+ private readonly telemetryRate = new TelemetryRate()
595
617
  private heldPhotos: HeldPhoto[] = []
596
618
  private battery: BatteryReading | null = null
597
619
  private phoneBattery: PhoneBatteryHeld | null = null
@@ -624,6 +646,10 @@ class CollectorRun {
624
646
  fleetEvents.subscribe({
625
647
  launch: (event) => this.guard("launch", () => this.onLaunch(event)),
626
648
  photo: (event) => this.guard("photo", () => this.onPhoto(event)),
649
+ telemetry: (event) => this.guard("telemetry", () => this.onTelemetry(event)),
650
+ crash: (event) => this.guard("crash", () => this.onCrash(event)),
651
+ install: (event) => this.guard("install", () => this.onInstall(event)),
652
+ connection: (event) => this.guard("connection", () => this.onConnection(event)),
627
653
  }),
628
654
  )
629
655
  const appState = AppState.addEventListener("change", (state) =>
@@ -712,10 +738,42 @@ class CollectorRun {
712
738
  this.evaluateSnapshotTriggers()
713
739
  }
714
740
 
741
+ /**
742
+ * Phase 12's connection events, from the store's own states: connected to disconnected is a disconnect (the
743
+ * person's own within 10 s of their asking, otherwise a lost link); connecting or bonding to disconnected without
744
+ * connecting is a failed connection, once until the glasses connect again and never while a lost link waits to come
745
+ * back; connected again after a disconnect is a reconnect, with the time it took.
746
+ * Simulated glasses have none.
747
+ */
748
+ private noteConnectionChange(raw: string, simulated: boolean, wall: number): void {
749
+ const previous = this.rawConnection
750
+ this.rawConnection = raw
751
+ if (previous === null || previous === raw || simulated) return
752
+ if (raw === "disconnected" && previous === "connected") {
753
+ const theirs = wall - fleetEvents.lastUserDisconnect() < USER_DISCONNECT_WINDOW_MS
754
+ this.onConnection({event: "disconnected", category: theirs ? "user_action" : "link_lost"})
755
+ this.disconnectedAt = theirs ? null : wall
756
+ } else if (raw === "disconnected" && (previous === "connecting" || previous === "bonding")) {
757
+ // Glasses away retry all the time: a lost link waiting to come back is its disconnect, and only the first failed
758
+ // connection between two connected spells is one, so an hour of retries is not an hour of records.
759
+ if (this.disconnectedAt === null && !this.connectFailed) {
760
+ this.connectFailed = true
761
+ this.onConnection({event: "connect_failed", category: "unknown"})
762
+ }
763
+ } else if (raw === "connected") {
764
+ this.connectFailed = false
765
+ if (this.disconnectedAt !== null) {
766
+ this.onConnection({event: "reconnected", category: "unknown", reconnectMs: wall - this.disconnectedAt})
767
+ this.disconnectedAt = null
768
+ }
769
+ }
770
+ }
771
+
715
772
  /** Notes what the glasses store says now: the connection, the glasses' identity and versions, and the battery. */
716
773
  private trackGlasses(wall: number): void {
717
774
  const s = useGlassesStore.getState()
718
775
  const state = mapConnection(s.connection.state)
776
+ this.noteConnectionChange(s.connection.state, isSimulatedGlasses(s.deviceModel), wall)
719
777
  if (state !== this.connection.state) {
720
778
  // Glasses that start connecting are not assumed to be the pair that was last here: a new connection is
721
779
  // identified afresh, from what the glasses say, before anything is attributed to a device. Only a new
@@ -1402,6 +1460,102 @@ class CollectorRun {
1402
1460
  ...(versionName ? {versionName} : {}),
1403
1461
  start: event.start,
1404
1462
  outcome: event.outcome,
1463
+ ...(event.outcome === "failed" && event.failureReason ? {failureReason: event.failureReason} : {}),
1464
+ },
1465
+ }))
1466
+ }
1467
+
1468
+ // --- Phase 12: a miniapp's own events, crashes, installs and connection changes -------------------------------
1469
+
1470
+ /** The open execution session of `packageName`, which a telemetry event or crash is attributed to. */
1471
+ private executionOf(packageName: string): string | undefined {
1472
+ return this.sessions.get(`miniapp_execution:${packageName}`)?.id
1473
+ }
1474
+
1475
+ private onTelemetry(event: FleetTelemetryEvent): void {
1476
+ const packageName = text(event.packageName)
1477
+ if (!packageName) return
1478
+ const at = this.env.now()
1479
+ const limited = limitEvent(event.event, event.properties)
1480
+ if (limited === null || !this.telemetryRate.admit(packageName, at)) {
1481
+ this.env.queue.noteTelemetryLoss(this.partition, packageName, "overLimit", 1, at)
1482
+ return
1483
+ }
1484
+ const versionName = text(event.versionName)
1485
+ const executionSessionId = this.executionOf(packageName)
1486
+ this.emit(
1487
+ at,
1488
+ (base) => ({
1489
+ ...base,
1490
+ kind: "telemetry_event",
1491
+ payload: {
1492
+ packageName,
1493
+ ...(versionName ? {versionName} : {}),
1494
+ ...(executionSessionId ? {executionSessionId} : {}),
1495
+ event: limited.event,
1496
+ properties: limited.properties,
1497
+ droppedProperties: limited.droppedProperties,
1498
+ cutValues: limited.cutValues,
1499
+ },
1500
+ }),
1501
+ TELEMETRY_RECORD_PREFIX,
1502
+ )
1503
+ }
1504
+
1505
+ private onCrash(event: FleetCrashEvent): void {
1506
+ const packageName = text(event.packageName)
1507
+ if (!packageName) return
1508
+ const versionName = text(event.versionName)
1509
+ const executionSessionId = this.executionOf(packageName)
1510
+ this.emit(this.env.now(), (base) => ({
1511
+ ...base,
1512
+ kind: "miniapp_crash",
1513
+ payload: {
1514
+ packageName,
1515
+ ...(versionName ? {versionName} : {}),
1516
+ ...(executionSessionId ? {executionSessionId} : {}),
1517
+ surface: event.surface,
1518
+ category: event.category,
1519
+ outcome: event.outcome,
1520
+ crashesInWindow: Math.min(FLEET_LIMITS.maxCrashesInWindow, Math.max(1, Math.round(event.crashesInWindow))),
1521
+ },
1522
+ }))
1523
+ }
1524
+
1525
+ private onInstall(event: FleetInstallEvent): void {
1526
+ const packageName = text(event.packageName)
1527
+ if (!packageName) return
1528
+ const fromVersion = text(event.fromVersion)
1529
+ const toVersion = text(event.toVersion)
1530
+ this.emit(this.env.now(), (base) => ({
1531
+ ...base,
1532
+ kind: "miniapp_install",
1533
+ payload: {
1534
+ packageName,
1535
+ operation: event.operation,
1536
+ source: event.source,
1537
+ ...(fromVersion ? {fromVersion} : {}),
1538
+ ...(toVersion ? {toVersion} : {}),
1539
+ outcome: event.outcome,
1540
+ ...(event.outcome === "succeeded" ? {} : {reason: event.reason ?? "unknown"}),
1541
+ },
1542
+ }))
1543
+ }
1544
+
1545
+ private onConnection(event: FleetConnectionEvent): void {
1546
+ const binding = this.currentBinding() ?? this.known?.binding ?? null
1547
+ const glassesModel = this.known?.model
1548
+ const appVersion = phoneInfo()?.appVersion ?? "unknown"
1549
+ this.emit(this.env.now(), (base) => ({
1550
+ ...base,
1551
+ kind: "connection_event",
1552
+ ...(binding ? {deviceBinding: binding} : {}),
1553
+ payload: {
1554
+ event: event.event,
1555
+ category: event.category,
1556
+ ...(glassesModel ? {glassesModel} : {}),
1557
+ appVersion,
1558
+ ...(event.event === "reconnected" ? {reconnectMs: Math.max(0, Math.round(event.reconnectMs ?? 0))} : {}),
1405
1559
  },
1406
1560
  }))
1407
1561
  }
@@ -1451,10 +1605,10 @@ class CollectorRun {
1451
1605
  return [...measured]
1452
1606
  }
1453
1607
 
1454
- private base(observedAt: number): RecordBase {
1608
+ private base(observedAt: number, idPrefix = ""): RecordBase {
1455
1609
  return {
1456
1610
  schemaVersion: FLEET_SCHEMA_VERSION,
1457
- recordId: newId(),
1611
+ recordId: `${idPrefix}${newId()}`,
1458
1612
  phoneDeviceId: this.phoneId,
1459
1613
  collectorSessionId: this.collectorSessionId,
1460
1614
  sequence: this.env.queue.nextSequence(this.partition, this.collectorSessionId),
@@ -1464,9 +1618,9 @@ class CollectorRun {
1464
1618
  }
1465
1619
 
1466
1620
  /** Builds a record and queues it in this run's partition. A record that cannot be built or stored is skipped. */
1467
- private emit(observedAt: number, make: (base: RecordBase) => FleetRecord): void {
1621
+ private emit(observedAt: number, make: (base: RecordBase) => FleetRecord, idPrefix = ""): void {
1468
1622
  try {
1469
- this.env.queue.enqueue(this.partition, make(this.base(observedAt)))
1623
+ this.env.queue.enqueue(this.partition, make(this.base(observedAt, idPrefix)))
1470
1624
  } catch (error) {
1471
1625
  warn("recording", error)
1472
1626
  }
@@ -15,9 +15,20 @@
15
15
  * the engine restores miniapps while the account it collects for may still be being resolved, and those launches are
16
16
  * that account's. The reporter discards them when the account changes. Photo stages are not kept.
17
17
  *
18
+ * Phase 12 adds a miniapp's own events (`telemetry`, from `session.telemetry.capture`, attributed by the runtime to the
19
+ * package whose bridge sent it), its crashes, install attempts, and the glasses' pairing and connection changes. None of
20
+ * these is kept while nothing listens: with no run collecting (Fleet not installed on the phone's Core, nobody signed
21
+ * in) they are dropped, and telemetry dropped this way is not counted.
22
+ *
18
23
  * This module imports nothing at run time, so the hot paths that import it carry no Fleet weight.
19
24
  */
20
- import type {FleetRecord} from "@mentra/cloud-protocol"
25
+ import type {
26
+ FleetConnectionEventPayload,
27
+ FleetLaunchFailureReason,
28
+ FleetMiniappCrashPayload,
29
+ FleetMiniappInstallPayload,
30
+ FleetRecord,
31
+ } from "@mentra/cloud-protocol"
21
32
 
22
33
  /** A miniapp start the engine attempted, and how it went. */
23
34
  export type FleetLaunchEvent = Extract<FleetRecord, {kind: "miniapp_launch"}>["payload"]
@@ -34,18 +45,42 @@ export interface UndeliveredLaunch {
34
45
  observedAt: number
35
46
  }
36
47
 
48
+ /** A miniapp's own event as the bridge delivered it: unchecked, the collector applies the limits. */
49
+ export interface FleetTelemetryEvent {
50
+ packageName: string
51
+ versionName?: string
52
+ event: unknown
53
+ properties: unknown
54
+ }
55
+
56
+ /** A crash: the collector adds the miniapp's execution session. */
57
+ export type FleetCrashEvent = Omit<FleetMiniappCrashPayload, "executionSessionId">
58
+ export type FleetInstallEvent = FleetMiniappInstallPayload
59
+ /** A pairing or connection change: the collector adds the glasses, their model and the Mentra App's version. */
60
+ export type FleetConnectionEvent = Pick<FleetConnectionEventPayload, "event" | "category" | "reconnectMs">
61
+
37
62
  export interface FleetEventListener {
38
63
  launch?: (event: FleetLaunchEvent) => void
39
64
  photo?: (event: FleetPhotoEvent) => void
65
+ telemetry?: (event: FleetTelemetryEvent) => void
66
+ crash?: (event: FleetCrashEvent) => void
67
+ install?: (event: FleetInstallEvent) => void
68
+ connection?: (event: FleetConnectionEvent) => void
40
69
  }
41
70
 
42
71
  /** More launches than this before the collector starts are not one boot's restores; the oldest go first. */
43
72
  const MAX_UNDELIVERED_LAUNCHES = 32
44
73
 
45
74
  const listeners = new Set<FleetEventListener>()
75
+ let lastUserDisconnectAt = 0
46
76
  const undelivered: UndeliveredLaunch[] = []
77
+ /** A start under way that reports its own launch: why a spawn it held back failed, if one did. */
78
+ interface LaunchClaim {
79
+ failureReason?: FleetLaunchFailureReason
80
+ }
81
+
47
82
  /** Starts under way that report their own launch, by package. */
48
- const claims = new Map<string, number>()
83
+ const claims = new Map<string, Set<LaunchClaim>>()
49
84
 
50
85
  function warnListenerFailed(error: unknown): void {
51
86
  // The event is left out of the warning: it names a package or an operation.
@@ -86,20 +121,34 @@ export const fleetEvents = {
86
121
  * same miniapp is under way, which reports it. Never throws.
87
122
  */
88
123
  launchUnlessClaimed(event: FleetLaunchEvent): void {
89
- if ((claims.get(event.packageName) ?? 0) > 0) return
90
- deliverLaunch(event)
124
+ const held = claims.get(event.packageName)
125
+ if (!held) {
126
+ deliverLaunch(event)
127
+ return
128
+ }
129
+ // The start reports this attempt as its own, so it keeps the reason the spawn failed for.
130
+ const reason = event.failureReason
131
+ if (reason === undefined) return
132
+ for (const claim of held) if (claim.failureReason === undefined || claim.failureReason === "unknown") claim.failureReason = reason
91
133
  },
92
134
 
93
- /** A start of `packageName` that reports its own launch is under way, until the returned function is called. */
94
- claimLaunch(packageName: string): () => void {
95
- claims.set(packageName, (claims.get(packageName) ?? 0) + 1)
135
+ /**
136
+ * A start of `packageName` that reports its own launch is under way, until the returned function is called. That
137
+ * function returns why a spawn the start held back failed, for the start's own report, or undefined.
138
+ */
139
+ claimLaunch(packageName: string): () => FleetLaunchFailureReason | undefined {
140
+ const claim: LaunchClaim = {}
141
+ const held = claims.get(packageName) ?? new Set<LaunchClaim>()
142
+ held.add(claim)
143
+ claims.set(packageName, held)
96
144
  let released = false
97
145
  return () => {
98
- if (released) return
99
- released = true
100
- const left = (claims.get(packageName) ?? 1) - 1
101
- if (left > 0) claims.set(packageName, left)
102
- else claims.delete(packageName)
146
+ if (!released) {
147
+ released = true
148
+ held.delete(claim)
149
+ if (held.size === 0 && claims.get(packageName) === held) claims.delete(packageName)
150
+ }
151
+ return claim.failureReason
103
152
  }
104
153
  },
105
154
 
@@ -115,13 +164,48 @@ export const fleetEvents = {
115
164
 
116
165
  /** A photo operation reached a stage. Never throws. */
117
166
  photo(event: FleetPhotoEvent): void {
118
- if (listeners.size === 0) return
119
- for (const listener of [...listeners]) {
120
- try {
121
- listener.photo?.(event)
122
- } catch (error) {
123
- warnListenerFailed(error)
124
- }
125
- }
167
+ deliver("photo", event)
168
+ },
169
+
170
+ /** A miniapp captured one of its own events. Never throws. */
171
+ telemetry(event: FleetTelemetryEvent): void {
172
+ deliver("telemetry", event)
173
+ },
174
+
175
+ /** A miniapp crashed. Never throws. */
176
+ crash(event: FleetCrashEvent): void {
177
+ deliver("crash", event)
126
178
  },
179
+
180
+ /** An install, update or removal of a miniapp was attempted. Never throws. */
181
+ install(event: FleetInstallEvent): void {
182
+ deliver("install", event)
183
+ },
184
+
185
+ /** The person asked the glasses to disconnect (or forgot or unpaired them): the next disconnect is theirs. */
186
+ userDisconnect(): void {
187
+ lastUserDisconnectAt = Date.now()
188
+ },
189
+
190
+ /** When the person last asked the glasses to disconnect (epoch ms), or 0. */
191
+ lastUserDisconnect(): number {
192
+ return lastUserDisconnectAt
193
+ },
194
+
195
+ /** The glasses' pairing or connection changed. Never throws. */
196
+ connection(event: FleetConnectionEvent): void {
197
+ deliver("connection", event)
198
+ },
199
+ }
200
+
201
+ /** Hands `event` to every listener of `kind`; with none, it is dropped. */
202
+ function deliver<K extends "photo" | "telemetry" | "crash" | "install" | "connection">(kind: K, event: Parameters<NonNullable<FleetEventListener[K]>>[0]): void {
203
+ if (listeners.size === 0) return
204
+ for (const listener of [...listeners]) {
205
+ try {
206
+ ;(listener[kind] as ((value: typeof event) => void) | undefined)?.(event)
207
+ } catch (error) {
208
+ warnListenerFailed(error)
209
+ }
210
+ }
127
211
  }