From 8a711e02545d89601d76478ac816efae573dd68b Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Thu, 30 Jul 2026 00:41:29 -0700 Subject: [PATCH] driver controller change (#3477) * fix: launch app * driver controller change --- .../controllers/drivers/DriverController.ts | 26 +++++++++++++++ src/gui/src/globals.js | 21 ++++++++++-- src/gui/src/helpers/launch_app.js | 32 +++++++++++++++++-- 3 files changed, 74 insertions(+), 5 deletions(-) diff --git a/src/backend/controllers/drivers/DriverController.ts b/src/backend/controllers/drivers/DriverController.ts index d19aa3393..197405f1a 100644 --- a/src/backend/controllers/drivers/DriverController.ts +++ b/src/backend/controllers/drivers/DriverController.ts @@ -18,6 +18,7 @@ * [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/). */ +import { metrics } from '@opentelemetry/api'; import type { Request, Response } from 'express'; import { actorUid } from '../../core/actor.js'; import { Context } from '../../core/context.js'; @@ -45,6 +46,17 @@ import { PuterController } from '../types.js'; type DriverInstance = WithLifecycle & Record; +// Every driver call is timed here already, for the lifecycle events below. +// Recording the same number as a histogram makes the per-interface latency +// distribution available downstream; which interfaces are worth keeping is a +// collector-side decision, not one made here, so this deliberately records +// everything and lets the export pipeline drop what it doesn't want. +const meter = metrics.getMeter('puter-backend'); +const driverCallDuration = meter.createHistogram('driver.call.duration', { + description: 'Wall time of a driver method call', + unit: 'ms', +}); + const extractUpstreamStatus = (e: { status?: number; statusCode?: number; @@ -429,6 +441,11 @@ export class DriverController extends PuterController { () => (fn as (...x: unknown[]) => any).call(driver, args), ); } catch (e) { + driverCallDuration.record(Date.now() - startedAt, { + driver: ifaceName, + 'driver.method': method, + outcome: 'error', + }); this.clients.event?.emit( `driver.${ifaceName}.${method}.error`, { @@ -446,6 +463,15 @@ export class DriverController extends PuterController { ); throw translateProviderError(e); } + + // Same window the span and the lifecycle events measure: for streamed + // results this is stream start, not stream drain. Worth remembering + // when reading AI latency — it is time-to-first-token, not total. + driverCallDuration.record(Date.now() - startedAt, { + driver: ifaceName, + 'driver.method': method, + outcome: 'ok', + }); this.clients.event?.emit( `driver.${ifaceName}.${method}.after`, { diff --git a/src/gui/src/globals.js b/src/gui/src/globals.js index 8f8537ae2..2dbe0fa44 100644 --- a/src/gui/src/globals.js +++ b/src/gui/src/globals.js @@ -241,8 +241,23 @@ window.Transaction = class { this.attributes = { ...attributes }; } + /** + * Absolute, but read off the monotonic clock. A plain wall-clock read + * can jump mid-transaction when the system clock is adjusted, which + * lands as a negative or wildly inflated duration. Adding `timeOrigin` + * keeps the stamps on a real timeline (reporters place spans by + * absolute time) while the gap between two of them stays monotonic. + */ + #now () { + return typeof performance !== 'undefined' + && typeof performance.now === 'function' + && typeof performance.timeOrigin === 'number' + ? performance.timeOrigin + performance.now() + : Date.now(); + } + start () { - this.start_ts = Date.now(); + this.start_ts = this.#now(); } /** @@ -254,11 +269,11 @@ window.Transaction = class { } getDuration () { - return Date.now() - this.start_ts; + return this.#now() - this.start_ts; } end () { - this.end_ts = Date.now(); + this.end_ts = this.#now(); this.duration = this.end_ts - this.start_ts; // emit an event diff --git a/src/gui/src/helpers/launch_app.js b/src/gui/src/helpers/launch_app.js index fd257f816..e077467dc 100644 --- a/src/gui/src/helpers/launch_app.js +++ b/src/gui/src/helpers/launch_app.js @@ -116,6 +116,11 @@ const fetchUserAppTokenForLaunch = async ({ appUid } = {}) => { */ const launch_app = async (options) => { let transaction; + // Ends once the app signals over IPC, i.e. when it can actually be used. + // Only apps built on the SDK ever signal, so this one is deliberately + // left unended (and therefore unreported) for the rest — better a series + // that covers fewer launches than one padded with timeouts. + let interactiveTransaction; // A transaction to trace the time it takes to launch an app and // for it to be ready. // Explorer is a special case, it's not an app per se, so it doesn't need a transaction. @@ -123,15 +128,31 @@ const launch_app = async (options) => { // Attribute the timing: the same span covers a tile click on a warm // dashboard and a cold landing on /app/, which are different // enough that a combined percentile describes neither. - transaction = new window.Transaction('app-is-ready', { + const launchAttributes = { 'launch.app': options?.name ?? options?.app_obj?.name ?? 'unknown', 'launch.dashboard_mode': !! window.is_dashboard_mode, 'launch.from_app_url': typeof window.location?.pathname === 'string' && window.location.pathname.startsWith('/app/'), 'launch.has_app_obj': !! options?.app_obj, + }; + // Exec-service launches never get the IPC listener attached below, + // so no interactive span is expected for them. Recording that here + // gives the interactive series a denominator: how many launches + // could have produced one. + const ipcTracked = ! options?.launched_by_exec_service; + + transaction = new window.Transaction('app-is-ready', { + ...launchAttributes, + 'launch.ipc_tracked': ipcTracked, }); transaction.start(); + + if ( ipcTracked ) { + interactiveTransaction = new window.Transaction( + 'app-interactive', launchAttributes); + interactiveTransaction.start(); + } } const uuid = options.uuid ?? window.uuidv4(); @@ -165,7 +186,9 @@ const launch_app = async (options) => { // If no `options.name` is provided, use the app name from the app_info options.name = options.name ?? app_info.name; - transaction?.annotate({ 'launch.app': options.name ?? 'unknown' }); + const resolvedAppName = { 'launch.app': options.name ?? 'unknown' }; + transaction?.annotate(resolvedAppName); + interactiveTransaction?.annotate(resolvedAppName); const requestedAppName = options.privateLaunchRequestedAppName ?? options.name ?? app_info.name ?? null; const privateAccessDecision = normalizePrivateAccessDecision(app_info.privateAccess); @@ -737,6 +760,11 @@ const launch_app = async (options) => { $(process.references.iframe).attr('data-appUsesSDK', 'true'); + // The app is talking to us, so it's genuinely usable now — + // unlike `app-is-ready`, which stops at the window element. + endLaunchTransaction(interactiveTransaction, 'ipc-attached'); + interactiveTransaction = undefined; + // Send any saved broadcasts to the new app globalThis.services.get('broadcast').sendSavedBroadcastsTo(uuid);