mirror of
https://github.com/hanzoai/observability.git
synced 2026-10-30 20:22:53 +00:00
hanzoai/dashboards shipped as its own binary (msgType 206) serving five console
tRPC routers: dashboards, widgets, table batch-actions, view presets, monitors.
It is now this service's 200+ ordinal range and its dash_*.go files.
They were never two concerns. A dashboard is a saved QUERY over exactly the
traces, observations and scores this service owns, and both halves were blocked
on the SAME ClickHouse shim — kept apart, that shim had to be written twice
against two copies of one OLTP model, and chart/scoreHistogram could not reach
the scores they aggregate without a network hop. The duplication was already
visible in the capability bits: both services had independently minted a
PermAnalyticsRead. Merged, there is one.
Ordinals are the standalone binary's 0–29 offset by 200, not renumbered into the
gaps: the offset is mechanical and reversible, renumbering would silently
repoint a client built against the old ids.
Three conventions were doubled and are now single:
- TENANCY. authorize() answered both "may this caller invoke this method" and
"whose rows may it touch", returning a defaultOrg the handlers ignored while
each re-read projectId from its own params. Split: authorize() is the cap gate,
scope() is the ONE tenant resolver, and its project is passed to every handler.
A pipelined call now INHERITS its target's project instead of re-reading the
payload, so pipelining cannot be used to hop tenants. Server.defaultOrg was
dead once scope() existed and is deleted rather than left as unread state on a
security-relevant struct.
- NOT-IMPLEMENTED. Observability answered stubs 200 + Empty{Stubbed:true};
dashboards answered 501. Unified on 501 through one Server.stub helper. A
stubbed 200 carrying an empty result is indistinguishable at the call site from
a real query that matched nothing — the UI renders "no data" and the missing
backend never surfaces. The status is the one field a caller cannot skip
reading; a flag in the body is one it can, and eventually does.
- COLUMN NAMES. fDashDescription, fWidgetDescription and fDescription were three
constants for the column "description". One constant per column name now.
scope() reads the payload's field at text @0 without knowing its type, which is
silently wrong for any request struct that puts another text field first — and
wrong by reading ANOTHER TENANT'S rows, not by failing. That assumption is now
pinned by TestEveryRequestStructCarriesProjectIdAtZero over the schema itself
(43 declarations, 9 result structs exempted). Verified it fails when the
invariant is broken, not just that it passes.
Verified: 22 tests green (14 obs + 8 dashboards, bodies ported byte-identical so
they diff against the originals) plus the invariant; go vet clean; make zap-gen
byte-identical on regen; CGO_ENABLED=0 build ok.
go.sum carried the same moved-tag corruption fixed in hanzoai/dashboards
(age/pq/zap hashes restored to the checksum DB; threshold v1.9.4→v1.9.5 and
orm v0.6.14→v0.6.16, both unbuildable from a clean cache).
Co-authored-by: Hanzo Dev <[email protected]>
153 lines
4.7 KiB
Go
153 lines
4.7 KiB
Go
// Command observability is a Hanzo Base-native Go service binary: a typed ZAP
|
||
// capability-RPC backend for the Langfuse-style observability data model
|
||
// (traces, observations, sessions, scores, score-configs, events), built on
|
||
// Hanzo Base (embedded SQLite + plugins). It replaces 8 console in-process tRPC
|
||
// routers with native capability RPC.
|
||
//
|
||
// Architecture (the reference pattern the other migration service-binaries
|
||
// follow):
|
||
//
|
||
// base.New() → Base app: embedded SQLite, hooks, migrations
|
||
// ├── vault (optional) → per-org encrypted SQLite shard (KEK)
|
||
// ├── zap.MustRegister → generic ORM transport (msgType 100–103)
|
||
// └── server.Register(node) → THIS service's typed router (msgType 203)
|
||
// apis.NewRouter(app) → sidecar HTTP (health/metrics), NOT app data
|
||
// app.Start() → serves HTTP :8090 + ZAP :9992
|
||
//
|
||
// The .zap schema (proto/) is the source of truth; gen/ is its Go projection.
|
||
package main
|
||
|
||
import (
|
||
"context"
|
||
"crypto/rand"
|
||
"log"
|
||
"os"
|
||
|
||
"github.com/hanzoai/base"
|
||
"github.com/hanzoai/base/core"
|
||
"github.com/hanzoai/base/plugins/vault"
|
||
"github.com/hanzoai/base/tools/hook"
|
||
luxlog "github.com/luxfi/log"
|
||
zaplib "github.com/luxfi/zap"
|
||
zcap "github.com/zap-proto/go/cap"
|
||
|
||
"github.com/hanzoai/observability/server"
|
||
)
|
||
|
||
func main() {
|
||
app := base.New()
|
||
|
||
var zapAddr string
|
||
app.RootCmd.PersistentFlags().StringVar(&zapAddr, "zap", envOr("ZAP_ADDR", "127.0.0.1:9992"),
|
||
"address for the typed ZAP capability-RPC listener")
|
||
|
||
var defaultOrg string
|
||
app.RootCmd.PersistentFlags().StringVar(&defaultOrg, "org", envOr("OBSERVABILITY_ORG", "default"),
|
||
"default organization scope when a capability carries no org binding")
|
||
|
||
var vaultDir string
|
||
app.RootCmd.PersistentFlags().StringVar(&vaultDir, "vaultDir", os.Getenv("VAULT_DIR"),
|
||
"directory for per-org encrypted SQLite shards (enables the vault plugin)")
|
||
|
||
app.RootCmd.ParseFlags(os.Args[1:])
|
||
|
||
// Optional: per-org encrypted SQLite backing via the vault plugin. Enabled
|
||
// only when --vaultDir is set so local dev stays single-file SQLite. The
|
||
// master KEK comes from KMS in production; a process-ephemeral key is used
|
||
// when VAULT_MASTER_KEY is unset (dev only — shards won't persist across
|
||
// restarts, which is correct for throwaway dev data).
|
||
if vaultDir != "" {
|
||
vault.MustRegister(app, vault.Config{
|
||
Enabled: true,
|
||
DataDir: vaultDir,
|
||
OrgID: defaultOrg,
|
||
MasterKey: masterKey(),
|
||
})
|
||
}
|
||
|
||
// Provision the observability collections (trace/observation/session/score/
|
||
// score-config) at bootstrap. Idempotent; no env-seeded rows (OLTP tables).
|
||
server.RegisterCollections(app)
|
||
|
||
// Stand up the typed ZAP router alongside Base's serve lifecycle. A dedicated
|
||
// luxfi/zap node carries the capability RPC (NoDiscovery: direct dial only —
|
||
// service discovery is the gateway's job, not mDNS here).
|
||
logger := luxlog.New("component", "observability")
|
||
node := zaplib.NewNode(zaplib.NodeConfig{
|
||
NodeID: "observability",
|
||
Port: portOf(zapAddr),
|
||
NoDiscovery: true,
|
||
})
|
||
|
||
// Verifier: bootstrap (ed25519, no issuer registry → Kind+Permissions
|
||
// enforced, signature step skipped). Wire IssuerKey to the IAM pubkey
|
||
// registry to enable full cryptographic verification.
|
||
srv := server.NewServer(app, logger, zcap.Verifier{})
|
||
srv.Register(node)
|
||
|
||
app.OnServe().Bind(&hook.Handler[*core.ServeEvent]{
|
||
Id: "observabilityZapNode",
|
||
Func: func(e *core.ServeEvent) error {
|
||
if err := e.Next(); err != nil {
|
||
return err
|
||
}
|
||
if err := node.Start(); err != nil {
|
||
return err
|
||
}
|
||
logger.Info("observability ZAP router listening", "addr", zapAddr, "msgType", server.MsgTypeRouterBase)
|
||
return nil
|
||
},
|
||
})
|
||
app.OnTerminate().Bind(&hook.Handler[*core.TerminateEvent]{
|
||
Id: "observabilityZapNodeStop",
|
||
Func: func(e *core.TerminateEvent) error {
|
||
node.Stop()
|
||
return e.Next()
|
||
},
|
||
})
|
||
|
||
if err := app.Start(); err != nil {
|
||
log.Fatal(err)
|
||
}
|
||
}
|
||
|
||
// masterKey returns the 32-byte vault master KEK: from VAULT_MASTER_KEY (hex or
|
||
// raw 32 bytes) in production, else a process-ephemeral random key for dev.
|
||
func masterKey() []byte {
|
||
if v := os.Getenv("VAULT_MASTER_KEY"); len(v) >= 32 {
|
||
return []byte(v)[:32]
|
||
}
|
||
k := make([]byte, 32)
|
||
_, _ = rand.Read(k)
|
||
return k
|
||
}
|
||
|
||
func envOr(k, def string) string {
|
||
if v := os.Getenv(k); v != "" {
|
||
return v
|
||
}
|
||
return def
|
||
}
|
||
|
||
// portOf extracts the port from a host:port address, defaulting to 9992.
|
||
func portOf(addr string) int {
|
||
for i := len(addr) - 1; i >= 0; i-- {
|
||
if addr[i] == ':' {
|
||
p := 0
|
||
for _, c := range addr[i+1:] {
|
||
if c < '0' || c > '9' {
|
||
return 9992
|
||
}
|
||
p = p*10 + int(c-'0')
|
||
}
|
||
if p == 0 {
|
||
return 9992
|
||
}
|
||
return p
|
||
}
|
||
}
|
||
return 9992
|
||
}
|
||
|
||
var _ = context.Background // reserved for future graceful-shutdown wiring
|