Files
zeekayandHanzo Dev 456b5a860e feat: fold the dashboards service in — one Langfuse surface, one analytics shim
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]>
2026-07-27 13:32:12 -07:00

153 lines
4.7 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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