package runtime import ( "context" "errors" "fmt" "log" "time" "browser.local/run/config" "browser.local/run/protocol" ) const metricReportTimeout = 5 * time.Second // MetricCollector provides only generic local utilization data. Game-specific // observations belong to a plugin-declared bridge, never the Run runtime. type MetricCollector interface { Collect(context.Context, string) (MetricUtilization, error) } type MetricUtilization struct { CPUPercent *float64 MemoryPercent *float64 DiskPercent *float64 } type defaultMetricCollector struct{} func (defaultMetricCollector) Collect(ctx context.Context, workspaceRoot string) (MetricUtilization, error) { var utilization MetricUtilization var collectionErrors []error if cpuPercent, err := hostCPUPercent(ctx); err != nil { collectionErrors = append(collectionErrors, fmt.Errorf("collect CPU utilization: %w", err)) } else { utilization.CPUPercent = &cpuPercent } if memoryPercent, err := hostMemoryPercent(); err != nil { collectionErrors = append(collectionErrors, fmt.Errorf("collect memory utilization: %w", err)) } else { utilization.MemoryPercent = &memoryPercent } if err := ctx.Err(); err != nil { collectionErrors = append(collectionErrors, err) } else if diskPercent, err := workspaceDiskPercent(workspaceRoot); err != nil { collectionErrors = append(collectionErrors, fmt.Errorf("collect disk utilization: %w", err)) } else { utilization.DiskPercent = &diskPercent } return utilization, errors.Join(collectionErrors...) } // WithMetricCollector overrides the generic host collector for tests and // platform-specific collectors. It has no access to plugin inputs or secrets. func WithMetricCollector(collector MetricCollector) LifecycleExecutorOption { return func(executor *LifecycleExecutor) { executor.metricCollector = collector } } func (worker *Worker) reportMetricsDegraded(ctx context.Context, trigger string) { if err := worker.ReportMetricsOnce(ctx); err != nil { log.Printf("RUN phase=metrics status=degraded trigger=%s error=%s", safeOptional(trigger), err.Error()) } } func (worker *Worker) ReportMetricsOnce(ctx context.Context) error { if worker.cfg.ComponentKind != config.PackageComponentRun || worker.cfg.ServerInstanceID == "" { return nil } state, err := worker.registeredState() if err != nil { return err } sample := protocol.MetricSample{ServerInstanceID: worker.cfg.ServerInstanceID, Online: worker.managedServerProcessOnline(state), Source: "run", CollectedAt: time.Now().UTC()} if worker.metricCollector != nil { utilization, collectErr := worker.metricCollector.Collect(ctx, worker.cfg.WorkspaceRoot) sample.CPUPercent = utilization.CPUPercent sample.MemoryPercent = utilization.MemoryPercent sample.DiskPercent = utilization.DiskPercent if collectErr != nil { log.Printf("RUN phase=metrics status=utilization_unavailable error=%s", collectErr.Error()) } } reportCtx, cancel := context.WithTimeout(ctx, metricReportTimeout) defer cancel() response, err := worker.client.IngestMetricBatch(reportCtx, protocol.MetricBatchIngestRequest{RunEndpointID: state.RunEndpointID, SessionToken: state.SessionToken, Samples: []protocol.MetricSample{sample}}) if err != nil { return err } if !response.Accepted || response.AcceptedCount != 1 { return fmt.Errorf("metric batch was not accepted") } log.Printf("RUN phase=metrics status=accepted server=%s online=%t", sample.ServerInstanceID, sample.Online) return nil } func (worker *Worker) managedServerProcessOnline(state WorkerState) bool { source, ok := worker.executor.managed.(ManagedProcessObservationSource) if !ok { return false } for _, identity := range source.ManagedProcessObservations() { if identity.ServerInstanceID != worker.cfg.ServerInstanceID || identity.RunEndpointID != state.RunEndpointID { continue } if worker.executor.managed.Status(identity).State == "running" { return true } } return false }