@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.
- 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/MiniappEngine.d.ts.map +1 -1
- package/build/services/MiniappEngine.js +2 -0
- package/build/services/MiniappEngine.js.map +1 -1
- package/build/services/MiniappLauncher.d.ts +24 -2
- package/build/services/MiniappLauncher.d.ts.map +1 -1
- package/build/services/MiniappLauncher.js +58 -6
- 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/MiniappEngine.ts +2 -0
- package/src/services/MiniappLauncher.ts +58 -5
- 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
|
@@ -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 {
|
|
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
|
|
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 {
|
|
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
|
|
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 {
|
|
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,
|
|
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
|
-
|
|
90
|
-
|
|
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
|
-
/**
|
|
94
|
-
|
|
95
|
-
|
|
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)
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
}
|