Files

121 lines
4.4 KiB
Go

package o11y
import (
"net/http"
"net/url"
"strings"
"sync"
"github.com/hanzoai/cloud"
luxlog "github.com/luxfi/log"
"github.com/zap-proto/zip"
)
// Mount registers Hanzo o11y's HTTP surface under /v1/o11y per HIP-0106.
//
// The o11y runtime (metrics, traces, logs, dashboards, alerts) is heavy:
// telemetry stores, rule manager, websocket attachments, opamp server.
// The standalone cmd/server boot path constructs it all. To keep the
// route layer composable with the unified cloud binary, Mount delegates
// to a handler registered by the runtime via SetHandler.
//
// Routing model:
//
// - Standalone: cmd/server/server.go constructs *Server, calls
// o11y.SetHandler(server.PublicHandler()), then cloud.MountAll wires it.
// - Cloud binary: same SetHandler call, executed from the cloud bootstrapper
// after o11y.New + app.NewServer.
// - Until a handler is registered, the routes 503 with a clear error.
//
// All traffic under /v1/o11y is delegated to the registered http.Handler via
// zip.AdaptNetHTTP; handlerAdapter normalizes the /v1/o11y/<resource> public
// contract onto the two internal route families HERE — the ONE Hanzo-owned seam —
// so the embedded O11y route literals stay untouched (see rewriteExternalPath).
func Mount(app *zip.App, deps cloud.Deps) error {
log := deps.Logger
if log == nil {
log = luxlog.New("module", "o11y")
}
log.Info("o11y: mounting routes", "prefix", "/v1/o11y")
// Native probe group, registered ahead of the delegation wildcard so Fiber's
// in-order match serves it off the mux tree (see health.go).
mountHealth(app)
app.All("/v1/o11y/*", zip.AdaptNetHTTP(handlerAdapter{}))
return nil
}
// handlerAdapter forwards each request to the registered runtime handler
// or returns 503 if none is set yet.
type handlerAdapter struct{}
func (handlerAdapter) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h := getHandler()
if h == nil {
http.Error(w, "o11y runtime not initialized", http.StatusServiceUnavailable)
return
}
rewriteExternalPath(r.URL)
h.ServeHTTP(w, r)
}
// rewriteExternalPath maps the ONE public o11y contract — api.hanzo.ai/v1/o11y/<resource>,
// one /v1/, no nested version, no /api/ — onto o11y's internal /api/ namespace, at this
// single Hanzo-owned seam. It is done HERE, never by editing the embedded O11y route
// literals: O11y's whole frontend and backend speak /api/vN, and rewriting those
// literals is a fork diff a later upstream re-sync silently reverts (it happened once —
// see o11y/CLAUDE.md).
//
// /v1/o11y/<resource> → /api/<resource> (canonical; resolves to a version-less
// alias — O11y, highest version wins —
// or a Hanzo llmobs route)
// /v1/o11y/api/vN/… → /api/vN/… (leaked form still emitted by the O11y
// SPA; kept working by dropping the mount
// prefix only, until it migrates)
//
// Because every rewritten path starts with /api/, the embedded O11y StripPrefix
// wrapper (ExternalPath=/v1/o11y) finds nothing to strip and passes through — so no CR
// change is needed. The version-less /api/<resource> aliases are registered by
// o11yapiserver.AddVersionlessAliases when the router is assembled.
func rewriteExternalPath(u *url.URL) {
rest, ok := strings.CutPrefix(u.Path, "/v1/o11y/")
if !ok {
return
}
if strings.HasPrefix(rest, "api/") {
setPath(u, "/"+rest) // /v1/o11y/api/vN/x → /api/vN/x (leaked form, kept working)
return
}
setPath(u, "/api/"+rest) // /v1/o11y/<resource> → /api/<resource>
}
// setPath rewrites the request path, clearing RawPath so EscapedPath re-derives from the
// new value — the rewritten O11y paths contain no characters needing escaping, and
// llmobs paths (the only ones carrying an {id}) are never rewritten.
func setPath(u *url.URL, p string) {
u.Path = p
u.RawPath = ""
}
var (
hmu sync.RWMutex
registered http.Handler
)
// SetHandler registers the o11y runtime's public HTTP handler. The
// standalone server calls this after app.NewServer; the unified cloud
// binary calls it after constructing the same runtime in-process.
// Safe for concurrent use; pass nil to unset.
func SetHandler(h http.Handler) {
hmu.Lock()
registered = h
hmu.Unlock()
}
func getHandler() http.Handler {
hmu.RLock()
h := registered
hmu.RUnlock()
return h
}