mount: serve the whole o11y surface at /v1/o11y/<resource> (kill the /api/ leak) (#26)
* sqlite: one driver-registration site (drop redundant blank modernc import) The unified cloud binary must register the database/sql \"sqlite\" driver exactly once. o11y contributed a SECOND registration import: a blank _ \"modernc.org/sqlite\" in pkg/query-service/app/http_handler.go, on top of the one in pkg/sqlstore/sqlitesqlstore/provider.go (which MUST import modernc non-blank for the *sqlite.Error type + modernc/sqlite/lib error-code constants — github.com/hanzoai/sqlite does not re-export either). Remove the redundant blank import. The driver stays registered by provider.go, which pkg/query-service/app transitively imports in every binary (verified via go list -deps for both cmd/community and the cloud embed) — so registration survives the removal. Do NOT swap the blank import to github.com/hanzoai/sqlite: under CGO_ENABLED=1 (o11y CI default) the fork Register()s mattn while modernc also Register()s modernc → the exact \"sql: Register called twice for driver sqlite\" panic. Under CGO_ENABLED=0 (cloud prod build) the fork routes to modernc and shares its single init with o11y, so the cloud binary already registers \"sqlite\" once — no fork import needed here. Add TestDriverRegisteredOnce (pkg/sqlstore/sqlitesqlstore): opens the provider and asserts journal_mode=wal + busy_timeout>0 through the driver — fails loudly if either regression (lost registration, or double-register panic) returns. Passes CGO on and off. * mount: serve the whole o11y surface at /v1/o11y/<resource> — no /api/ leak The public contract is api.hanzo.ai/v1/o11y/<resource> (one /v1/, no /api/). But o11y is a SigNoz fork whose FE+BE speak /api/vN (5 live versions), and the mount delegated to SigNoz's StripPrefix, so the surface leaked as /v1/o11y/api/vN/... AND the Hanzo llmobs routes (registered at /v1/o11y/*) 404'd once StripPrefix ate the prefix. The earlier attempt to rip /api/ from SigNoz's 291 route literals was reverted by an upstream re-sync (o11y/CLAUDE.md) — so this normalizes at the ONE Hanzo-owned seam (mount.go) instead, zero SigNoz fork diff: /v1/o11y/vN/... -> /api/vN/... (canonical SigNoz; the /api/ never surfaces) /v1/o11y/api/vN/... -> /api/vN/... (deprecated alias so current callers keep working) /v1/o11y/traces,... -> unchanged (Hanzo llmobs, native) Paired with cloud CR O11Y_GLOBAL_EXTERNAL__URL="" (StripPrefix off) so the llmobs /v1/o11y/* routes survive to the router — deploy together. Logic table-tested (mount_test.go); full-graph build is CI-authoritative (root pkg pulls the cloud graph). --------- Co-authored-by: hanzo <z@hanzo.ai>
This commit is contained in:
@@ -2,11 +2,13 @@ package o11y
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/zap-proto/zip"
|
||||
luxlog "github.com/luxfi/log"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// Mount registers Hanzo o11y's HTTP surface under /v1/o11y per HIP-0106.
|
||||
@@ -25,9 +27,10 @@ import (
|
||||
// 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; the o11y handler internally rewrites /v1/o11y/*
|
||||
// to /api/* so existing controllers stay untouched (see app.createPublicServer).
|
||||
// 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 SigNoz route literals stay untouched (see rewriteExternalPath).
|
||||
func Mount(app *zip.App, deps cloud.Deps) error {
|
||||
log := deps.Logger
|
||||
if log == nil {
|
||||
@@ -55,9 +58,56 @@ func (handlerAdapter) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
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 /api/ — onto the two internal route families, at this single Hanzo-owned
|
||||
// seam. It is done HERE, never by editing the embedded SigNoz route literals: SigNoz's
|
||||
// whole frontend and backend speak /api/vN, and rewriting those literals is a fork diff
|
||||
// that a later upstream re-sync silently reverts (it already happened once — see
|
||||
// o11y/CLAUDE.md).
|
||||
//
|
||||
// SigNoz native (registered at /api/vN/*):
|
||||
// /v1/o11y/vN/… → /api/vN/… (canonical — the /api/ never surfaces)
|
||||
// /v1/o11y/api/vN/… → /api/vN/… (deprecated alias: the leaked form callers emit
|
||||
// today. Drop once every consumer emits the
|
||||
// canonical form — one and one way.)
|
||||
// Hanzo llmobs (registered natively at /v1/o11y/{traces,observations,…}): passed
|
||||
// through unchanged.
|
||||
//
|
||||
// This requires the embedded SigNoz StripPrefix wrapper to be OFF — cloud CR
|
||||
// O11Y_GLOBAL_EXTERNAL__URL="" — so a /v1/o11y/* llmobs path survives to the router.
|
||||
func rewriteExternalPath(u *url.URL) {
|
||||
rest, ok := strings.CutPrefix(u.Path, "/v1/o11y/")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(rest, "api/v"): // deprecated leaked alias: /v1/o11y/api/vN/x
|
||||
setPath(u, "/"+rest) // → /api/vN/x
|
||||
case isVersionSegment(rest): // canonical SigNoz form: /v1/o11y/vN/x
|
||||
setPath(u, "/api/"+rest) // → /api/vN/x
|
||||
default: // Hanzo llmobs / native resource — the router owns /v1/o11y/x directly.
|
||||
}
|
||||
}
|
||||
|
||||
// isVersionSegment reports whether rest begins with a SigNoz API version segment
|
||||
// (v followed by a digit — "v1/health", "v3/query_range"): the marker that tells an
|
||||
// embedded-SigNoz route apart from a Hanzo-native llmobs resource (traces, sessions, …).
|
||||
func isVersionSegment(rest string) bool {
|
||||
return len(rest) >= 2 && rest[0] == 'v' && rest[1] >= '0' && rest[1] <= '9'
|
||||
}
|
||||
|
||||
// setPath rewrites the request path, clearing RawPath so EscapedPath re-derives from the
|
||||
// new value — the rewritten SigNoz 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
|
||||
|
||||
+32
-17
@@ -1,7 +1,6 @@
|
||||
package o11y_test
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
@@ -28,7 +27,10 @@ func TestMountWithoutHandlerReturns503(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMountForwardsToRegisteredHandler(t *testing.T) {
|
||||
// TestMountNormalizesExternalPath proves the one public contract /v1/o11y/<resource>
|
||||
// (one /v1/, no /api/) is normalized at the mount seam onto the two internal route
|
||||
// families: SigNoz native (/api/vN/*) and Hanzo llmobs (/v1/o11y/*, passed through).
|
||||
func TestMountNormalizesExternalPath(t *testing.T) {
|
||||
app := zip.New(zip.Config{DisableStartupMessage: true})
|
||||
if err := o11y.Mount(app, cloud.Deps{}); err != nil {
|
||||
t.Fatalf("Mount: %v", err)
|
||||
@@ -37,25 +39,38 @@ func TestMountForwardsToRegisteredHandler(t *testing.T) {
|
||||
var sawPath string
|
||||
o11y.SetHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sawPath = r.URL.Path
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
defer o11y.SetHandler(nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/o11y/api/v1/health", nil)
|
||||
resp, err := app.Fiber().Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Test: %v", err)
|
||||
cases := []struct {
|
||||
name, external, internal string
|
||||
}{
|
||||
// SigNoz native — canonical Hanzo form; the /api/ never surfaces externally.
|
||||
{"signoz canonical v1", "/v1/o11y/v1/health", "/api/v1/health"},
|
||||
{"signoz canonical v3", "/v1/o11y/v3/query_range", "/api/v3/query_range"},
|
||||
{"signoz canonical v5", "/v1/o11y/v5/query_range", "/api/v5/query_range"},
|
||||
// SigNoz native — deprecated /api/ alias still resolves during migration.
|
||||
{"signoz legacy alias", "/v1/o11y/api/v1/health", "/api/v1/health"},
|
||||
// Hanzo llmobs — registered natively at /v1/o11y/*, passed through untouched.
|
||||
{"llmobs traces", "/v1/o11y/traces", "/v1/o11y/traces"},
|
||||
{"llmobs observations", "/v1/o11y/observations", "/v1/o11y/observations"},
|
||||
{"llmobs score by id", "/v1/o11y/score/abc123", "/v1/o11y/score/abc123"},
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status=%d want 200", resp.StatusCode)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if string(body) != `{"ok":true}` {
|
||||
t.Fatalf("body=%q want {\"ok\":true}", body)
|
||||
}
|
||||
if sawPath != "/v1/o11y/api/v1/health" {
|
||||
t.Fatalf("sawPath=%q want /v1/o11y/api/v1/health", sawPath)
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
sawPath = ""
|
||||
req := httptest.NewRequest(http.MethodGet, tc.external, nil)
|
||||
resp, err := app.Fiber().Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Test: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status=%d want 200", resp.StatusCode)
|
||||
}
|
||||
if sawPath != tc.internal {
|
||||
t.Fatalf("external %s → internal %q, want %q", tc.external, sawPath, tc.internal)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user