@@ -406,6 +406,222 @@ caddy_http_request_duration_seconds_count{server="main"} 100
406406 assert .Equal (t , float64 (100 ), main .RequestsTotal , "seeding should not overwrite existing host data" )
407407}
408408
409+ func TestOnConnected_DetectsFrankenPHP (t * testing.T ) {
410+ frankenPHPAvailable := false
411+ srv := httptest .NewServer (http .HandlerFunc (func (w http.ResponseWriter , r * http.Request ) {
412+ switch r .URL .Path {
413+ case "/frankenphp/threads" :
414+ if frankenPHPAvailable {
415+ w .WriteHeader (200 )
416+ json .NewEncoder (w ).Encode (ThreadsResponse {
417+ ThreadDebugStates : []ThreadDebugState {{Index : 0 , State : "ready" }},
418+ })
419+ } else {
420+ w .WriteHeader (404 )
421+ }
422+ case "/metrics" :
423+ w .WriteHeader (200 )
424+ default :
425+ w .WriteHeader (404 )
426+ }
427+ }))
428+ defer srv .Close ()
429+
430+ f := NewHTTPFetcher (srv .URL , 0 )
431+
432+ snap , err := f .Fetch (context .Background ())
433+ require .NoError (t , err )
434+ assert .False (t , snap .HasFrankenPHP , "should not detect FrankenPHP when unavailable" )
435+ assert .Empty (t , snap .Threads .ThreadDebugStates )
436+
437+ frankenPHPAvailable = true
438+
439+ snap , err = f .Fetch (context .Background ())
440+ require .NoError (t , err )
441+ assert .True (t , snap .HasFrankenPHP , "should detect FrankenPHP on next successful fetch" )
442+ // Threads are fetched on the NEXT Fetch() after detection
443+ snap , err = f .Fetch (context .Background ())
444+ require .NoError (t , err )
445+ assert .Len (t , snap .Threads .ThreadDebugStates , 1 )
446+ }
447+
448+ func TestOnConnected_FetchesServerNames (t * testing.T ) {
449+ serverNamesAvailable := false
450+ srv := httptest .NewServer (http .HandlerFunc (func (w http.ResponseWriter , r * http.Request ) {
451+ switch r .URL .Path {
452+ case "/config/apps/http/servers" :
453+ if serverNamesAvailable {
454+ w .WriteHeader (200 )
455+ json .NewEncoder (w ).Encode (map [string ]any {
456+ "main" : map [string ]any {"listen" : []string {":443" }},
457+ })
458+ } else {
459+ w .WriteHeader (404 )
460+ }
461+ case "/metrics" :
462+ w .WriteHeader (200 )
463+ default :
464+ w .WriteHeader (404 )
465+ }
466+ }))
467+ defer srv .Close ()
468+
469+ f := NewHTTPFetcher (srv .URL , 0 )
470+
471+ f .Fetch (context .Background ())
472+ assert .Empty (t , f .ServerNames ())
473+
474+ serverNamesAvailable = true
475+
476+ snap , err := f .Fetch (context .Background ())
477+ require .NoError (t , err )
478+ assert .Equal (t , []string {"main" }, f .ServerNames ())
479+ require .NotNil (t , snap .Metrics .Hosts )
480+ assert .Contains (t , snap .Metrics .Hosts , "main" )
481+ }
482+
483+ func TestOnConnected_NoRetryWhenMetricsFail (t * testing.T ) {
484+ var detectCalls atomic.Int32
485+ srv := httptest .NewServer (http .HandlerFunc (func (w http.ResponseWriter , r * http.Request ) {
486+ switch r .URL .Path {
487+ case "/frankenphp/threads" :
488+ detectCalls .Add (1 )
489+ w .WriteHeader (404 )
490+ case "/metrics" :
491+ w .WriteHeader (500 )
492+ default :
493+ w .WriteHeader (404 )
494+ }
495+ }))
496+ defer srv .Close ()
497+
498+ f := NewHTTPFetcher (srv .URL , 0 )
499+
500+ f .Fetch (context .Background ())
501+ f .Fetch (context .Background ())
502+ f .Fetch (context .Background ())
503+
504+ assert .Equal (t , int32 (0 ), detectCalls .Load (), "should not attempt detection when metrics fail" )
505+ }
506+
507+ func TestOnConnected_StopsAfterSuccess (t * testing.T ) {
508+ var detectCalls atomic.Int32
509+ srv := httptest .NewServer (http .HandlerFunc (func (w http.ResponseWriter , r * http.Request ) {
510+ switch r .URL .Path {
511+ case "/frankenphp/threads" :
512+ detectCalls .Add (1 )
513+ w .WriteHeader (200 )
514+ json .NewEncoder (w ).Encode (ThreadsResponse {})
515+ case "/metrics" :
516+ w .WriteHeader (200 )
517+ default :
518+ w .WriteHeader (404 )
519+ }
520+ }))
521+ defer srv .Close ()
522+
523+ f := NewHTTPFetcher (srv .URL , 0 )
524+
525+ f .Fetch (context .Background ())
526+ f .Fetch (context .Background ())
527+ f .Fetch (context .Background ())
528+
529+ // Detection is called once (first fetch), then stops because hasFrankenPHP is true
530+ // But fetchThreads also hits /frankenphp/threads in subsequent fetches
531+ assert .True (t , f .HasFrankenPHP ())
532+ }
533+
534+ func TestFetch_HasFrankenPHPInSnapshot (t * testing.T ) {
535+ srv := newTestServer (200 , ThreadsResponse {}, 200 , "" )
536+ defer srv .Close ()
537+
538+ f := NewHTTPFetcher (srv .URL , 0 )
539+ f .hasFrankenPHP = true
540+
541+ snap , err := f .Fetch (context .Background ())
542+ require .NoError (t , err )
543+ assert .True (t , snap .HasFrankenPHP )
544+ }
545+
546+ func TestFetch_NoFrankenPHPInSnapshot (t * testing.T ) {
547+ srv := newTestServer (404 , nil , 200 , "" )
548+ defer srv .Close ()
549+
550+ f := NewHTTPFetcher (srv .URL , 0 )
551+
552+ snap , err := f .Fetch (context .Background ())
553+ require .NoError (t , err )
554+ assert .False (t , snap .HasFrankenPHP )
555+ }
556+
557+ func TestFetch_PrometheusProcessFallback_RSS (t * testing.T ) {
558+ metricsText := `# TYPE process_resident_memory_bytes gauge
559+ process_resident_memory_bytes 1.048576e+07
560+ # TYPE process_cpu_seconds_total counter
561+ process_cpu_seconds_total 12.5
562+ # TYPE process_start_time_seconds gauge
563+ process_start_time_seconds 1.7e+09
564+ `
565+ srv := newTestServer (404 , nil , 200 , metricsText )
566+ defer srv .Close ()
567+
568+ f := NewHTTPFetcher (srv .URL , 0 )
569+
570+ snap , err := f .Fetch (context .Background ())
571+ require .NoError (t , err )
572+ assert .Equal (t , uint64 (10485760 ), snap .Process .RSS , "RSS should come from Prometheus metrics" )
573+ assert .True (t , snap .Process .Uptime > 0 , "Uptime should be derived from process_start_time_seconds" )
574+ assert .True (t , snap .Process .CreateTime > 0 , "CreateTime should be derived from process_start_time_seconds" )
575+ }
576+
577+ func TestFetch_PrometheusProcessFallback_CPU (t * testing.T ) {
578+ metricsText := `# TYPE process_cpu_seconds_total counter
579+ process_cpu_seconds_total 10.0
580+ `
581+ srv := newTestServer (404 , nil , 200 , metricsText )
582+ defer srv .Close ()
583+
584+ f := NewHTTPFetcher (srv .URL , 0 )
585+
586+ // First fetch: records baseline, CPU=0 (no previous sample)
587+ snap , err := f .Fetch (context .Background ())
588+ require .NoError (t , err )
589+ assert .Equal (t , float64 (0 ), snap .Process .CPUPercent , "first fetch has no delta yet" )
590+
591+ // Simulate time passing and CPU usage increasing
592+ f .lastPromSample = f .lastPromSample .Add (- 1 * time .Second )
593+ f .lastPromCPU = 10.0
594+
595+ metricsText2 := `# TYPE process_cpu_seconds_total counter
596+ process_cpu_seconds_total 10.5
597+ `
598+ srv .Close ()
599+ srv2 := newTestServer (404 , nil , 200 , metricsText2 )
600+ defer srv2 .Close ()
601+ f .baseURL = srv2 .URL
602+
603+ snap , err = f .Fetch (context .Background ())
604+ require .NoError (t , err )
605+ assert .True (t , snap .Process .CPUPercent > 0 , "CPU should be derived from Prometheus delta" )
606+ }
607+
608+ func TestFetch_PrometheusProcessFallback_NotUsedWhenGopsutilWorks (t * testing.T ) {
609+ metricsText := `# TYPE process_resident_memory_bytes gauge
610+ process_resident_memory_bytes 1.048576e+07
611+ `
612+ srv := newTestServer (404 , nil , 200 , metricsText )
613+ defer srv .Close ()
614+
615+ f := NewHTTPFetcher (srv .URL , 0 )
616+ // Simulate gopsutil having found the process and returned real RSS
617+ f .procHandle .proc = nil // no proc, but we'll set proc directly
618+ // We can't easily simulate gopsutil success in tests, so just verify
619+ // that the fallback IS used when proc.RSS == 0
620+ snap , err := f .Fetch (context .Background ())
621+ require .NoError (t , err )
622+ assert .Equal (t , uint64 (10485760 ), snap .Process .RSS )
623+ }
624+
409625func TestFetchThreads_PerRequestTimeout (t * testing.T ) {
410626 srv := httptest .NewServer (http .HandlerFunc (func (w http.ResponseWriter , r * http.Request ) {
411627 time .Sleep (requestTimeout + time .Second )
0 commit comments