Compare commits

...
Author SHA1 Message Date
3ac8ca7395 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>
2026-07-09 00:38:04 -07:00
Hanzo AI eb2de99089 fix(sqlite): converge onto hanzoai/sqlite — drop modernc, backend-correct DSN
Complete the half-done driver convergence in sqlitesqlstore/provider.go
(origin/main had added the driver_test.go pragma guard + the http_handler
comment, but provider.go still imported modernc directly):

- Drop "modernc.org/sqlite" + "modernc.org/sqlite/lib"; import
  github.com/hanzoai/sqlite, whose init registers the ONE "sqlite" driver
  (mattn/SQLCipher under cgo, modernc pure-Go otherwise) — no more direct
  modernc import that would double-register "sqlite" in the cgo cloud binary.
- WrapAlreadyExistsErrf now classifies via backend-neutral
  sqlite.IsConstraint{Unique,PrimaryKey,ForeignKey} instead of the
  modernc-typed *sqlite.Error + lib.SQLITE_CONSTRAINT_* checks.
- Build the DSN via sqlite.PragmaDSN so pragmas apply under BOTH backends.
  The old hardcoded modernc `_pragma=journal_mode(wal)` form is SILENTLY
  DROPPED by mattn — so under CGO=1 (how o11y links into the cloud binary)
  journal_mode/busy_timeout never applied. driver_test.go now passes under
  mattn too. _txlock (a driver param both backends honor) is appended.

go.mod: + hanzoai/sqlite v0.2.2 (pulls luxfi/crypto 1.19.26, mattn 1.14.47);
exclude mattn v2.0.3+incompatible (the mis-tagged pre-modules version the
cloud graph over-selects, which cannot build the cgo codec). Surgical — no
cloud-graph churn (cloud stays at its pseudo-version).

Verified: cmd/community builds -mod=readonly under CGO=0 AND CGO=1;
pkg/sqlstore tests green both backends (incl the pragma guard); 0 modernc
in the CGO=1 cmd/community graph (no double-registration). Pre-existing
unrelated: go build ./... hits a hanzoai/ai <-> go-openai ReasoningContent
skew in the cloud graph — not o11y source, not this change.
2026-07-08 15:06:28 -07:00
hanzo-devandGitHub 12e4cacf03 sqlite: one driver-registration site (drop redundant blank modernc import) (#25)
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.
2026-07-08 08:24:30 -07:00
f2ab8a12b0 fix(deps): add luxfi/age go.sum entry; reconcile go.mod against pinned cloud (#24)
go.sum was missing the checksum for github.com/luxfi/age (pulled transitively
via luxfi/zapdb@v1.10.0 through hanzoai/cloud), breaking the CI readonly build:
  missing go.sum entry for module providing package github.com/luxfi/age

Re-tidied against the CI-pinned cloud ref (884bdebd), which adds
luxfi/age v1.5.0 (indirect), marks google.golang.org/protobuf indirect
(direct use dropped in #23), and prunes stale go.sum entries.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-08 06:15:17 -07:00
53b455b4a7 opamp: compare via zap2pb; drop direct google.golang.org/protobuf (#23)
agent.go used google.golang.org/protobuf/proto only for proto.Equal on two
OpAMP messages. Route both through github.com/zap-proto/zap2pb (the sanctioned
ZAP<->protobuf boundary) so no Hanzo .go imports google.golang.org/protobuf
directly. Semantics identical (zap2pb.Equal wraps proto.Equal).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-07 17:28:10 -07:00
0ab53eb746 feat(metrics): native datastore metrics driver on upstream ch-go (no DDSketch fork) (#22)
pkg/datastoremetrics writes ZAP MetricBatch straight into the datastore
time_series_v4 + samples_v4 tables via upstream clickhouse-go v2, decomposing
classic Prometheus histograms/summaries into .bucket{le}/.count/.sum/.quantile
samples. Eliminates the signozclickhousemetrics exp_hist/DDSketch dependency on
the forked ch-go that blocked in-process metrics ingest.

Fingerprint (FNV-1a resource->scope->point, __name__ salt) ported verbatim from
the import-walled fork package so join keys stay byte-identical and the existing
query plane reads it unchanged. Reuses telemetrymetrics table-name constants.

Satisfies zapmetricreceiver.Handler. Pure buildRows transformation is exhaustively
unit-tested (counter/gauge/histogram/summary, join-key consistency, hour-floor,
independent FNV cross-check). CGO_ENABLED=0 build + vet + tests green.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-07 14:26:52 -07:00
8e64601291 fix(deps): drop orphan luxfi/age (cold-cache checksum SECURITY ERROR) (#19)
luxfi/age was a stale indirect require that nothing in the module graph
references (go mod why: "main module does not need module github.com/luxfi/age";
0 source imports). Its v1.5.0 tag was upstream-retagged, so any cold-cache
resolution of the recorded h1 hash fails with a checksum SECURITY ERROR.
go mod tidy would remove it; done surgically here (require + go.sum lines).
go mod verify clean.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-05 12:21:39 -07:00
hanzo-devandGitHub 767533f8ef Merge pull request #21 from hanzoai/feat/community-builder-embed-accessor
feat(community): shared builder for standalone + cloud embed; add Server.PublicHandler
2026-07-04 22:35:53 -07:00
hanzo-dev 0a13ef6982 feat(community): shared builder for standalone + cloud embed; add Server.PublicHandler
Factor the whole SigNoz-community construction (config resolution + the provider
set + the app server) into pkg/community, used by BOTH cmd/community and the
hanzoai/cloud in-process embed. One construction, one way: the embed cannot drift
from the running pod's identity (pkg/identn/iamidentn gateway-header auth) or
wiring because it is the same code.

Add app.Server.PublicHandler() — the built middleware+routes handler WITHOUT
binding the standalone listener — so the cloud binary installs it via
o11y.SetHandler and serves /v1/o11y/* in-process while SigNoz.Start runs
background evaluation.

cmd.NewSigNozConfig now delegates to community.NewConfig (one config path).
2026-07-04 22:35:01 -07:00
zandGitHub e9bcd48f5f Merge pull request #20 from hanzoai/embed/cloud-public-handler
feat(app): export Server.PublicHandler for in-process cloud embedding
2026-07-04 22:30:04 -07:00
18 changed files with 1930 additions and 980 deletions
+28
View File
@@ -157,3 +157,31 @@ telemetrystore provider registration all stay (implementation, not surface).
`O11Y_OTEL_COLLECTOR_DATASTORE_*` keys in `deploy/` are consumed by the
`hanzoai/signoz-otel-collector` fork — renamed here for consistency; that fork must
accept the `_DATASTORE_` segment (coordinated cross-repo rename).
## Native datastore metrics driver (`pkg/datastoremetrics`) — the fork unblock
- **What**: the o11y-native write path for metrics. `Writer.WriteMetrics` satisfies
`zapmetricreceiver.Handler`, decoding a ZAP `MetricBatch` straight into the datastore
`time_series_v4` + `samples_v4` tables over **upstream** `clickhouse-go` v2 (via
`telemetrystore.TelemetryStore.ClickhouseDB()`), reusing the `telemetrymetrics`
table-name constants so a series written here is immediately queryable.
- **Why it exists**: the stock `signozclickhousemetrics` exporter serialises OTLP
exponential histograms as a DDSketch into `exp_hist.sketch`, which needs the FORKED
ch-go (`proto.DD/.Store/.IndexMapping`). That fork conflicts with the upstream ch-go
the query plane pins — the reason metrics ingest could not move in-process. This driver
sidesteps the fork: the ZAP wire carries CLASSIC Prometheus shapes, so histograms
decompose into `<name>.bucket{le=…}` / `.count` / `.sum` samples and summaries into
`.quantile{quantile=…}` — no sketch, no `exp_hist`, NO fork. Only the two tables the
query plane already reads.
- **Fingerprint parity**: the labels→fingerprint hash (FNV-1a, hierarchical
resource→scope→point, salted with `__name__`) is ported verbatim from the fork's
`internal/common/fingerprint` (which is import-walled), so join keys are byte-identical
and the existing reader joins samples to series unchanged. Column strings match too:
temporality `Cumulative`/`Unspecified`, type `Sum`/`Gauge`/`Histogram`/`Summary`.
- **Wire-in**: `datastoremetrics.NewWriter(store.ClickhouseDB())` +
`zapmetricreceiver.New(Config{OnBatch: w.WriteMetrics})`. Consumed by `hanzoai/cloud`'s
embedded o11y runtime (opt-in `O11Y_METRICS_ZAP_LISTEN`, fail-soft) so the standalone
`signoz-otel-collector` metrics path can later repoint to cloud (verify-then-cutover).
- **Boundary unchanged**: the physical `signoz_metrics.*_v4` names still live in
`telemetrymetrics` (the source of truth this driver reuses) — renaming them is a
datastore migration, out of scope here.
+2 -1
View File
@@ -4,6 +4,7 @@ import (
"log/slog"
"github.com/hanzoai/o11y/cmd"
"github.com/hanzoai/o11y/pkg/community"
"github.com/hanzoai/o11y/pkg/instrumentation"
)
@@ -14,7 +15,7 @@ func main() {
// register a list of commands to the root command
registerServer(cmd.RootCmd, logger)
cmd.RegisterGenerate(cmd.RootCmd, logger)
cmd.RegisterMetastore(cmd.RootCmd, logger, sqlstoreProviderFactories, sqlschemaProviderFactories)
cmd.RegisterMetastore(cmd.RootCmd, logger, community.SQLStoreProviderFactories, community.SQLSchemaProviderFactories)
cmd.Execute(logger)
}
-16
View File
@@ -1,16 +0,0 @@
package main
import (
"github.com/hanzoai/o11y/pkg/factory"
"github.com/hanzoai/o11y/pkg/signoz"
"github.com/hanzoai/o11y/pkg/sqlschema"
"github.com/hanzoai/o11y/pkg/sqlstore"
)
func sqlstoreProviderFactories() factory.NamedMap[factory.ProviderFactory[sqlstore.SQLStore, sqlstore.Config]] {
return signoz.NewSQLStoreProviderFactories()
}
func sqlschemaProviderFactories(sqlstore sqlstore.SQLStore) factory.NamedMap[factory.ProviderFactory[sqlschema.SQLSchema, sqlschema.Config]] {
return signoz.NewSQLSchemaProviderFactories(sqlstore)
}
+9 -97
View File
@@ -7,47 +7,10 @@ import (
"github.com/spf13/cobra"
"github.com/hanzoai/o11y/cmd"
"github.com/hanzoai/o11y/pkg/alertmanager"
"github.com/hanzoai/o11y/pkg/analytics"
"github.com/hanzoai/o11y/pkg/auditor"
"github.com/hanzoai/o11y/pkg/authn"
"github.com/hanzoai/o11y/pkg/authz"
"github.com/hanzoai/o11y/pkg/authz/iamauthz"
"github.com/hanzoai/o11y/pkg/cache"
"github.com/hanzoai/o11y/pkg/community"
"github.com/hanzoai/o11y/pkg/errors"
"github.com/hanzoai/o11y/pkg/factory"
"github.com/hanzoai/o11y/pkg/flagger"
"github.com/hanzoai/o11y/pkg/gateway"
"github.com/hanzoai/o11y/pkg/gateway/noopgateway"
"github.com/hanzoai/o11y/pkg/global"
"github.com/hanzoai/o11y/pkg/licensing"
"github.com/hanzoai/o11y/pkg/licensing/nooplicensing"
"github.com/hanzoai/o11y/pkg/meterreporter"
"github.com/hanzoai/o11y/pkg/modules/cloudintegration"
"github.com/hanzoai/o11y/pkg/modules/cloudintegration/implcloudintegration"
"github.com/hanzoai/o11y/pkg/modules/dashboard"
"github.com/hanzoai/o11y/pkg/modules/dashboard/impldashboard"
"github.com/hanzoai/o11y/pkg/modules/metricreductionrule"
"github.com/hanzoai/o11y/pkg/modules/metricreductionrule/implmetricreductionrule"
"github.com/hanzoai/o11y/pkg/modules/organization"
"github.com/hanzoai/o11y/pkg/modules/retention"
"github.com/hanzoai/o11y/pkg/modules/rulestatehistory"
"github.com/hanzoai/o11y/pkg/modules/serviceaccount"
"github.com/hanzoai/o11y/pkg/modules/tag"
"github.com/hanzoai/o11y/pkg/prometheus"
"github.com/hanzoai/o11y/pkg/querier"
"github.com/hanzoai/o11y/pkg/query-service/app"
"github.com/hanzoai/o11y/pkg/queryparser"
"github.com/hanzoai/o11y/pkg/ruler"
"github.com/hanzoai/o11y/pkg/ruler/signozruler"
"github.com/hanzoai/o11y/pkg/signoz"
"github.com/hanzoai/o11y/pkg/sqlstore"
"github.com/hanzoai/o11y/pkg/telemetrystore"
"github.com/hanzoai/o11y/pkg/types/authtypes"
"github.com/hanzoai/o11y/pkg/types/telemetrytypes"
"github.com/hanzoai/o11y/pkg/version"
"github.com/hanzoai/o11y/pkg/zeus"
"github.com/hanzoai/o11y/pkg/zeus/noopzeus"
)
func registerServer(parentCmd *cobra.Command, logger *slog.Logger) {
@@ -75,62 +38,13 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
// print the version
version.Info.PrettyPrint(config.Version)
signoz, err := signoz.New(
ctx,
config,
zeus.Config{},
noopzeus.NewProviderFactory(),
licensing.Config{},
func(_ sqlstore.SQLStore, _ zeus.Zeus, _ organization.Getter, _ analytics.Analytics) factory.ProviderFactory[licensing.Licensing, licensing.Config] {
return nooplicensing.NewFactory()
},
signoz.NewEmailingProviderFactories(),
signoz.NewCacheProviderFactories(),
signoz.NewWebProviderFactories(config.Global),
sqlschemaProviderFactories,
sqlstoreProviderFactories(),
signoz.NewTelemetryStoreProviderFactories(),
func(ctx context.Context, providerSettings factory.ProviderSettings, store authtypes.AuthNStore, licensing licensing.Licensing) (map[authtypes.AuthNProvider]authn.AuthN, error) {
return signoz.NewAuthNs(ctx, providerSettings, store, licensing, config.Global)
},
func(_ context.Context, sqlstore sqlstore.SQLStore, _ authz.Config, _ licensing.Licensing, _ []authz.OnBeforeRoleDelete) (factory.ProviderFactory[authz.AuthZ, authz.Config], error) {
// Hanzo IAM is the sole authorization provider — every decision is
// delegated to IAM's Casbin enforce endpoint. No OpenFGA, no fallback.
return iamauthz.NewProviderFactory(sqlstore), nil
},
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, _ querier.Querier, _ licensing.Licensing, tagModule tag.Module) dashboard.Module {
return impldashboard.NewModule(impldashboard.NewStore(store), settings, analytics, orgGetter, queryParser, tagModule)
},
func(_ licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config] {
return noopgateway.NewProviderFactory()
},
func(_ licensing.Licensing) factory.NamedMap[factory.ProviderFactory[auditor.Auditor, auditor.Config]] {
return signoz.NewAuditorProviderFactories()
},
func(_ context.Context, _ factory.ProviderSettings, _ flagger.Flagger, _ licensing.Licensing, _ telemetrystore.TelemetryStore, _ retention.Getter, _ organization.Getter, _ zeus.Zeus) (factory.NamedMap[factory.ProviderFactory[meterreporter.Reporter, meterreporter.Config]], string) {
return signoz.NewMeterReporterProviderFactories(), "noop"
},
func(ps factory.ProviderSettings, q querier.Querier, a analytics.Analytics) querier.Handler {
return querier.NewHandler(ps, q, a)
},
func(_ sqlstore.SQLStore, _ dashboard.Module, _ global.Global, _ zeus.Zeus, _ gateway.Gateway, _ licensing.Licensing, _ serviceaccount.Module, _ cloudintegration.Config) (cloudintegration.Module, error) {
return implcloudintegration.NewModule(), nil
},
func(_ sqlstore.SQLStore, _ telemetrystore.TelemetryStore, _ dashboard.Module, _ queryparser.QueryParser, _ licensing.Licensing, _ flagger.Flagger, _ telemetrytypes.MetadataStore, _ factory.ProviderSettings, _ int) metricreductionrule.Module {
return implmetricreductionrule.NewModule()
},
func(c cache.Cache, am alertmanager.Alertmanager, ss sqlstore.SQLStore, ts telemetrystore.TelemetryStore, ms telemetrytypes.MetadataStore, p prometheus.Prometheus, og organization.Getter, rsh rulestatehistory.Module, q querier.Querier, qp queryparser.QueryParser) factory.NamedMap[factory.ProviderFactory[ruler.Ruler, ruler.Config]] {
return factory.MustNewNamedMap(signozruler.NewFactory(c, am, ss, ts, ms, p, og, rsh, q, qp, nil, nil))
},
)
// community.NewServer is the ONE construction shared with the hanzoai/cloud
// embed — same providers, same identity (iamidentn gateway-header auth), same
// wiring. Standalone owns the process: bind listeners, run background
// evaluation, block until shutdown.
server, signoz, err := community.NewServer(ctx, config)
if err != nil {
logger.ErrorContext(ctx, "failed to create signoz", errors.Attr(err))
return err
}
server, err := app.NewServer(config, signoz)
if err != nil {
logger.ErrorContext(ctx, "failed to create server", errors.Attr(err))
logger.ErrorContext(ctx, "failed to create signoz server", errors.Attr(err))
return err
}
@@ -146,14 +60,12 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
return err
}
err = server.Stop(ctx)
if err != nil {
if err := server.Stop(ctx); err != nil {
logger.ErrorContext(ctx, "failed to stop server", errors.Attr(err))
return err
}
err = signoz.Stop(ctx)
if err != nil {
if err := signoz.Stop(ctx); err != nil {
logger.ErrorContext(ctx, "failed to stop signoz", errors.Attr(err))
return err
}
+7 -25
View File
@@ -4,33 +4,15 @@ import (
"context"
"log/slog"
"github.com/hanzoai/o11y/pkg/config"
"github.com/hanzoai/o11y/pkg/config/envprovider"
"github.com/hanzoai/o11y/pkg/config/fileprovider"
"github.com/hanzoai/o11y/pkg/community"
"github.com/hanzoai/o11y/pkg/signoz"
)
// NewSigNozConfig resolves the SigNoz config from the given YAML files plus the
// process environment. It delegates to community.NewConfig — the ONE config path
// shared by the standalone binary and the hanzoai/cloud embed — so both read
// configuration (and the Hanzo operator-facing aliases like O11Y_DATASTORE_DSN)
// identically.
func NewSigNozConfig(ctx context.Context, logger *slog.Logger, configFiles []string) (signoz.Config, error) {
uris := make([]string, 0, len(configFiles)+1)
for _, f := range configFiles {
uris = append(uris, "file:"+f)
}
uris = append(uris, "env:")
config, err := signoz.NewConfig(
ctx,
logger,
config.ResolverConfig{
Uris: uris,
ProviderFactories: []config.ProviderFactory{
envprovider.NewFactory(),
fileprovider.NewFactory(),
},
},
)
if err != nil {
return signoz.Config{}, err
}
return config, nil
return community.NewConfig(ctx, logger, configFiles)
}
+196 -26
View File
@@ -31,6 +31,7 @@ require (
github.com/hanzoai/clickhouse-go-mock v0.14.1
github.com/hanzoai/govaluate v0.1.0
github.com/hanzoai/signoz-otel-collector v0.144.6
github.com/hanzoai/sqlite v0.2.2
github.com/huandu/go-sqlbuilder v1.39.1
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12
github.com/knadh/koanf v1.5.0
@@ -39,7 +40,7 @@ require (
github.com/mailru/easyjson v0.9.0
github.com/open-telemetry/opamp-go v0.22.0
github.com/open-telemetry/opentelemetry-collector-contrib/pkg/stanza v0.144.0
github.com/opentracing/opentracing-go v1.2.0
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b
github.com/perses/spec v0.1.2
github.com/pkg/errors v0.9.1
github.com/prometheus/alertmanager v0.31.1
@@ -64,6 +65,7 @@ require (
github.com/uptrace/bun/dialect/sqlitedialect v1.2.9
github.com/uptrace/bun/extra/bunotel v1.2.9
github.com/yuin/goldmark v1.7.16
github.com/zap-proto/zap2pb v0.2.0
go.opentelemetry.io/collector/confmap v1.54.0
go.opentelemetry.io/collector/otelcol v0.144.0
go.opentelemetry.io/collector/pdata v1.54.0
@@ -77,14 +79,13 @@ require (
go.uber.org/multierr v1.11.0
go.uber.org/zap v1.27.1
golang.org/x/crypto v0.53.0
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f
golang.org/x/net v0.55.0
golang.org/x/exp v0.0.0-20260529124908-c761662dc8c9
golang.org/x/net v0.56.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sync v0.21.0
golang.org/x/text v0.38.0
gonum.org/v1/gonum v0.17.0
google.golang.org/api v0.275.0
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v2 v2.4.0
gopkg.in/yaml.v3 v3.0.1
k8s.io/apimachinery v0.35.3
@@ -92,35 +93,97 @@ require (
)
require (
cloud.google.com/go v0.123.0 // indirect
filippo.io/edwards25519 v1.2.0 // indirect
filippo.io/hpke v0.4.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute v1.0.0 // indirect
github.com/ProtonMail/go-crypto v1.1.6 // indirect
github.com/ThinkInAIXYZ/go-mcp v0.2.24 // indirect
github.com/WqyJh/go-cosyvoice v0.1.0 // indirect
github.com/WqyJh/go-openai-realtime v0.5.1-0.20250210083616-024eddd5a481 // indirect
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 // indirect
github.com/alibabacloud-go/darabonba-openapi v0.1.18 // indirect
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.16 // indirect
github.com/alibabacloud-go/debug v1.0.1 // indirect
github.com/alibabacloud-go/ecs-20140526/v4 v4.26.10 // indirect
github.com/alibabacloud-go/endpoint-util v1.1.0 // indirect
github.com/alibabacloud-go/openapi-util v0.1.2 // indirect
github.com/alibabacloud-go/resourcecenter-20221201 v1.5.1 // indirect
github.com/alibabacloud-go/tea v1.4.0 // indirect
github.com/alibabacloud-go/tea-utils v1.4.5 // indirect
github.com/alibabacloud-go/tea-utils/v2 v2.0.9 // indirect
github.com/alibabacloud-go/tea-xml v1.1.3 // indirect
github.com/alibabacloud-go/vod-20170321/v2 v2.16.10 // indirect
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.3.0 // indirect
github.com/aliyun/credentials-go v1.4.7 // indirect
github.com/anthropics/anthropic-sdk-go v1.4.0 // indirect
github.com/aws/aws-sdk-go-v2 v1.41.5 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
github.com/aws/aws-sdk-go-v2/config v1.32.14 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.14 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 // indirect
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.8.0 // indirect
github.com/aws/aws-sdk-go-v2/service/ec2 v1.296.2 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 // indirect
github.com/aws/aws-sdk-go-v2/service/sns v1.39.11 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect
github.com/aws/smithy-go v1.24.2 // indirect
github.com/baidubce/bce-qianfan-sdk/go/qianfan v0.0.14 // indirect
github.com/baidubce/bce-sdk-go v0.9.264 // indirect
github.com/beego/beego v1.12.14 // indirect
github.com/bits-and-blooms/bitset v1.24.4 // indirect
github.com/blang/semver/v4 v4.0.0 // indirect
github.com/buger/goterm v1.0.4 // indirect
github.com/bytedance/gopkg v0.1.4 // indirect
github.com/bytedance/sonic/loader v0.5.1 // indirect
github.com/carapace-sh/carapace-shlex v1.0.1 // indirect
github.com/carmel/gooxml v0.0.0-20220216072414-40ff56130850 // indirect
github.com/cenkalti/backoff v2.2.1+incompatible // indirect
github.com/clbanning/mxj/v2 v2.7.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/coder/websocket v1.8.14 // indirect
github.com/cohere-ai/cohere-go/v2 v2.5.2 // indirect
github.com/consensys/gnark-crypto v0.20.1 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/crate-crypto/go-eth-kzg v1.5.0 // indirect
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
github.com/deckarep/golang-set/v2 v2.8.0 // indirect
github.com/dgrijalva/jwt-go v3.2.0+incompatible // indirect
github.com/digitalocean/go-libvirt v0.0.0-20260217163227-273eaa321819 // indirect
github.com/diskfs/go-diskfs v1.9.1 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/djherbis/times v1.6.0 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/dlclark/regexp2/v2 v2.2.1 // indirect
github.com/docker/docker v28.5.2+incompatible // indirect
github.com/docker/go-connections v0.6.0 // indirect
github.com/dop251/goja v0.0.0-20260627200808-0b76000cabdb // indirect
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/evanw/esbuild v0.28.1 // indirect
github.com/fasthttp/websocket v1.5.12 // indirect
github.com/fxamacker/cbor/v2 v2.9.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/gage-technologies/mistral-go v1.1.0 // indirect
github.com/go-errors/errors v1.4.2 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.9.0 // indirect
github.com/go-git/go-git/v5 v5.19.1 // indirect
github.com/go-ini/ini v1.67.0 // indirect
github.com/go-openapi/swag/cmdutils v0.25.5 // indirect
github.com/go-openapi/swag/conv v0.25.5 // indirect
@@ -136,49 +199,144 @@ require (
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
github.com/go-sql-driver/mysql v1.10.0 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/gofiber/fiber/v3 v3.2.0 // indirect
github.com/gofiber/schema v1.7.1 // indirect
github.com/gofiber/utils/v2 v2.0.4 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
github.com/golang-sql/sqlexp v0.1.0 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/flatbuffers v25.12.19+incompatible // indirect
github.com/google/gnostic-models v0.7.1 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect
github.com/gorilla/rpc v1.2.1 // indirect
github.com/grandcat/zeroconf v1.0.0 // indirect
github.com/hanzoai/commerce/metering v0.1.2 // indirect
github.com/hanzoai/ai v1.802.1-0.20260708185316-0321c35877f0 // indirect
github.com/hanzoai/beego/v2 v2.3.10 // indirect
github.com/hanzoai/commerce/metering v0.1.4 // indirect
github.com/hanzoai/dashscope-go-sdk v0.0.2 // indirect
github.com/hanzoai/dashscopego v0.6.0 // indirect
github.com/hanzoai/dbx v1.16.0 // indirect
github.com/hanzoai/go-openrouter v1.0.0 // indirect
github.com/hanzoai/iam v1.31.18 // indirect
github.com/hanzoai/pdf v1.2.0 // indirect
github.com/hanzoai/search-go v0.36.0 // indirect
github.com/hanzoai/tasks v1.49.0 // indirect
github.com/hashicorp/go-metrics v0.5.4 // indirect
github.com/henomis/lingoose v0.1.0 // indirect
github.com/hhrutter/lzw v1.0.0 // indirect
github.com/hhrutter/pkcs7 v0.2.0 // indirect
github.com/hhrutter/tiff v1.0.2 // indirect
github.com/holiman/uint256 v1.3.2 // indirect
github.com/huandu/go-clone v1.7.3 // indirect
github.com/hupe1980/go-huggingface v0.0.15 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/jinzhu/copier v0.4.0 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect
github.com/klauspost/crc32 v1.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/leverly/ChatGLM v1.2.0 // indirect
github.com/lib/pq v1.12.3 // indirect
github.com/lithammer/shortuuid v3.0.0+incompatible // indirect
github.com/luthermonson/go-proxmox v0.4.0 // indirect
github.com/luxfi/accel v1.2.4 // indirect
github.com/luxfi/age v1.5.0 // indirect
github.com/luxfi/crypto v1.19.20 // indirect
github.com/luxfi/kms v1.11.6 // indirect
github.com/luxfi/age v1.6.0 // indirect
github.com/luxfi/bft v0.1.5 // indirect
github.com/luxfi/cache v1.2.1 // indirect
github.com/luxfi/compress v0.0.5 // indirect
github.com/luxfi/concurrent v0.0.3 // indirect
github.com/luxfi/consensus v1.35.32 // indirect
github.com/luxfi/constants v1.5.8 // indirect
github.com/luxfi/container v0.0.4 // indirect
github.com/luxfi/crypto v1.19.26 // indirect
github.com/luxfi/crypto/ipa v1.2.4 // indirect
github.com/luxfi/database v1.19.3 // indirect
github.com/luxfi/geth v1.17.11 // indirect
github.com/luxfi/ids v1.3.0 // indirect
github.com/luxfi/kms v1.11.8 // indirect
github.com/luxfi/math v1.4.1 // indirect
github.com/luxfi/math/big v0.1.0 // indirect
github.com/luxfi/mdns v0.1.1 // indirect
github.com/luxfi/metric v1.5.8 // indirect
github.com/luxfi/mock v0.1.1 // indirect
github.com/luxfi/p2p v1.21.1 // indirect
github.com/luxfi/pq v1.1.0 // indirect
github.com/luxfi/sampler v1.1.0 // indirect
github.com/luxfi/validators v1.2.0 // indirect
github.com/luxfi/version v1.0.1 // indirect
github.com/luxfi/warp v1.24.0 // indirect
github.com/luxfi/zapdb v1.10.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mattn/go-runewidth v0.0.21 // indirect
github.com/mattn/go-sqlite3 v1.14.47 // indirect
github.com/microsoft/go-mssqldb v1.9.5 // indirect
github.com/minio/crc64nvme v1.1.1 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/minio/minio-go/v7 v7.0.100 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect
github.com/mr-tron/base58 v1.3.0 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/nxadm/tail v1.4.11 // indirect
github.com/openai/openai-go/v2 v2.1.1 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/orcaman/concurrent-map/v2 v2.0.1 // indirect
github.com/oschwald/geoip2-golang v1.11.0 // indirect
github.com/oschwald/maxminddb-golang v1.13.0 // indirect
github.com/pdfcpu/pdfcpu v0.11.0 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pjbgf/sha1cd v0.6.0 // indirect
github.com/pkoukk/tiktoken-go v0.1.7 // indirect
github.com/prometheus/client_golang/exp v0.0.0-20260325093428-d8591d0db856 // indirect
github.com/puzpuzpuz/xsync/v4 v4.4.0 // indirect
github.com/redis/go-redis/extra/rediscmd/v9 v9.15.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/sagikazarmark/locafero v0.9.0 // indirect
github.com/sashabaranov/go-openai v1.32.0 // indirect
github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/shiena/ansicolor v0.0.0-20230509054315-a9deabde6e02 // indirect
github.com/shirou/gopsutil v3.21.11+incompatible // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/skeema/knownhosts v1.3.1 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/viper v1.20.1 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/supranational/blst v0.3.16 // indirect
github.com/swaggest/refl v1.4.0 // indirect
github.com/swaggest/usecase v1.3.1 // indirect
github.com/tealeg/xlsx v1.0.5 // indirect
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.77 // indirect
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cvm v1.0.1116 // indirect
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/hunyuan v1.3.48 // indirect
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/tbaas v1.1.13 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/tjfoc/gmsm v1.4.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ua-parser/uap-go v0.0.0-20251207011819-db9adb27a0b8 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/uptrace/opentelemetry-go-extra/otelsql v0.3.2 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.70.0 // indirect
github.com/volcengine/volc-sdk-golang v1.0.241 // indirect
github.com/volcengine/volcengine-go-sdk v1.0.141 // indirect
github.com/wangbin/jiebago v0.3.2 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/xlab/treeprint v1.2.0 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
github.com/zap-proto/go v1.3.0 // indirect
github.com/zap-proto/http v0.2.0 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
@@ -191,16 +349,26 @@ require (
go.uber.org/goleak v1.3.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
golang.org/x/arch v0.25.0 // indirect
golang.org/x/image v0.41.0 // indirect
golang.org/x/term v0.44.0 // indirect
google.golang.org/genai v1.10.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/ini.v1 v1.67.1 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
k8s.io/api v0.35.3 // indirect
k8s.io/kube-openapi v0.0.0-20260330154417-16be699c7b31 // indirect
k8s.io/metrics v0.30.0 // indirect
modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
sigs.k8s.io/kustomize/api v0.20.0 // indirect
sigs.k8s.io/kustomize/kyaml v0.20.0 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect
sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 // indirect
sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
)
@@ -211,7 +379,7 @@ require (
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.7.0 // indirect
github.com/ClickHouse/ch-go v0.71.0
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b
github.com/andybalholm/brotli v1.2.1 // indirect
@@ -225,7 +393,7 @@ require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dennwc/varint v1.0.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/ebitengine/purego v0.9.1 // indirect
github.com/ebitengine/purego v0.10.0 // indirect
github.com/edsrzf/mmap-go v1.2.0 // indirect
github.com/elastic/lunes v0.2.0 // indirect
github.com/expr-lang/expr v1.17.7
@@ -241,7 +409,7 @@ require (
github.com/go-openapi/analysis v0.24.2 // indirect
github.com/go-openapi/errors v0.22.7 // indirect
github.com/go-openapi/jsonpointer v0.22.5 // indirect
github.com/go-openapi/jsonreference v0.21.4 // indirect
github.com/go-openapi/jsonreference v0.21.5 // indirect
github.com/go-openapi/loads v0.23.2 // indirect
github.com/go-openapi/spec v0.22.3 // indirect
github.com/go-openapi/swag v0.25.5 // indirect
@@ -258,8 +426,8 @@ require (
github.com/googleapis/gax-go/v2 v2.21.0 // indirect
github.com/gopherjs/gopherjs v1.17.2 // indirect
github.com/grafana/regexp v0.0.0-20250905093917-f7b3be9d1853 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
github.com/hanzoai/cloud v0.0.0-20260519044114-66d5d2a6312c
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
github.com/hanzoai/cloud v1.786.112
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
github.com/hashicorp/go-msgpack/v2 v2.1.5 // indirect
@@ -281,9 +449,9 @@ require (
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/leodido/go-syslog/v4 v4.3.0 // indirect
github.com/leodido/ragel-machinery v0.0.0-20190525184631-5f46317e436b // indirect
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 // indirect
github.com/luxfi/log v1.4.3
github.com/magefile/mage v1.15.1-0.20241126214340-bdc92f694516 // indirect
github.com/magefile/mage v1.17.1 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
github.com/mdlayher/socket v0.5.1 // indirect
github.com/mdlayher/vsock v1.2.1 // indirect
@@ -317,7 +485,7 @@ require (
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect
github.com/segmentio/asm v1.2.1 // indirect
github.com/segmentio/backo-go v1.0.1 // indirect
github.com/shirou/gopsutil/v4 v4.25.12 // indirect
github.com/shirou/gopsutil/v4 v4.26.2 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/shurcooL/httpfs v0.0.0-20230704072500-f1e31cf0ba5c // indirect
github.com/shurcooL/vfsgen v0.0.0-20230704071429-0000e147ea92 // indirect
@@ -325,7 +493,7 @@ require (
github.com/spf13/pflag v1.0.10 // indirect
github.com/stretchr/objx v0.5.3 // indirect
github.com/swaggest/openapi-go v0.2.60
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/match v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect
@@ -335,7 +503,7 @@ require (
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
github.com/zap-proto/zip v1.2.0
github.com/zap-proto/zip v1.2.1
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/collector/component v1.54.0 // indirect
go.opentelemetry.io/collector/component/componentstatus v0.148.0 // indirect
@@ -389,8 +557,8 @@ require (
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/prometheus v0.60.0
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.14.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.39.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.39.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0 // indirect
go.opentelemetry.io/otel/log v0.19.0 // indirect
go.opentelemetry.io/otel/sdk/log v0.19.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.44.0
@@ -402,15 +570,17 @@ require (
golang.org/x/sys v0.46.0 // indirect
golang.org/x/time v0.15.0 // indirect
golang.org/x/tools v0.45.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260615183401-62b3387ff324 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d // indirect
google.golang.org/grpc v1.81.1 // indirect
gopkg.in/telebot.v3 v3.3.8 // indirect
k8s.io/client-go v0.35.3 // indirect
k8s.io/klog/v2 v2.140.0 // indirect
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect
k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 // indirect
)
replace github.com/expr-lang/expr => github.com/hanzoai/expr v1.17.8
replace github.com/hanzoai/cloud => ../cloud
exclude github.com/mattn/go-sqlite3 v2.0.3+incompatible
+537 -775
View File
File diff suppressed because it is too large Load Diff
+54 -4
View File
@@ -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
View File
@@ -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)
}
})
}
}
+178
View File
@@ -0,0 +1,178 @@
// Package community constructs the ONE Hanzo o11y (SigNoz-community) runtime and
// HTTP server used by BOTH the standalone `cmd/community` binary AND the unified
// hanzoai/cloud binary's in-process embed (via app.Server.PublicHandler).
//
// Keeping the whole construction — config resolution, the SigNoz provider set,
// and the app server — behind a single exported builder guarantees the two
// deployments run byte-identical middleware, identity (pkg/identn/iamidentn,
// i.e. Hanzo IAM gateway-header auth), authz (iamauthz), telemetry stores, rule
// manager, dashboards and alerts. One construction, one way: the embed cannot
// drift from the standalone pod's auth or wiring, because they are the same code.
package community
import (
"context"
"log/slog"
"github.com/hanzoai/o11y/pkg/alertmanager"
"github.com/hanzoai/o11y/pkg/analytics"
"github.com/hanzoai/o11y/pkg/auditor"
"github.com/hanzoai/o11y/pkg/authn"
"github.com/hanzoai/o11y/pkg/authz"
"github.com/hanzoai/o11y/pkg/authz/iamauthz"
"github.com/hanzoai/o11y/pkg/cache"
"github.com/hanzoai/o11y/pkg/config"
"github.com/hanzoai/o11y/pkg/config/envprovider"
"github.com/hanzoai/o11y/pkg/config/fileprovider"
"github.com/hanzoai/o11y/pkg/factory"
"github.com/hanzoai/o11y/pkg/flagger"
"github.com/hanzoai/o11y/pkg/gateway"
"github.com/hanzoai/o11y/pkg/gateway/noopgateway"
"github.com/hanzoai/o11y/pkg/global"
"github.com/hanzoai/o11y/pkg/licensing"
"github.com/hanzoai/o11y/pkg/licensing/nooplicensing"
"github.com/hanzoai/o11y/pkg/meterreporter"
"github.com/hanzoai/o11y/pkg/modules/cloudintegration"
"github.com/hanzoai/o11y/pkg/modules/cloudintegration/implcloudintegration"
"github.com/hanzoai/o11y/pkg/modules/dashboard"
"github.com/hanzoai/o11y/pkg/modules/dashboard/impldashboard"
"github.com/hanzoai/o11y/pkg/modules/metricreductionrule"
"github.com/hanzoai/o11y/pkg/modules/metricreductionrule/implmetricreductionrule"
"github.com/hanzoai/o11y/pkg/modules/organization"
"github.com/hanzoai/o11y/pkg/modules/retention"
"github.com/hanzoai/o11y/pkg/modules/rulestatehistory"
"github.com/hanzoai/o11y/pkg/modules/serviceaccount"
"github.com/hanzoai/o11y/pkg/modules/tag"
"github.com/hanzoai/o11y/pkg/prometheus"
"github.com/hanzoai/o11y/pkg/querier"
"github.com/hanzoai/o11y/pkg/query-service/app"
"github.com/hanzoai/o11y/pkg/queryparser"
"github.com/hanzoai/o11y/pkg/ruler"
"github.com/hanzoai/o11y/pkg/ruler/signozruler"
"github.com/hanzoai/o11y/pkg/signoz"
"github.com/hanzoai/o11y/pkg/sqlschema"
"github.com/hanzoai/o11y/pkg/sqlstore"
"github.com/hanzoai/o11y/pkg/telemetrystore"
"github.com/hanzoai/o11y/pkg/types/authtypes"
"github.com/hanzoai/o11y/pkg/types/telemetrytypes"
"github.com/hanzoai/o11y/pkg/zeus"
"github.com/hanzoai/o11y/pkg/zeus/noopzeus"
)
// NewConfig resolves the SigNoz config from the given YAML files (if any) plus
// the process environment (env:), applying the Hanzo operator-facing aliases
// (e.g. the flat O11Y_DATASTORE_DSN → telemetrystore.datastore.dsn) inside
// signoz.NewConfig. This is THE config path; cmd.NewSigNozConfig delegates here
// so standalone and embed read configuration identically.
func NewConfig(ctx context.Context, logger *slog.Logger, configFiles []string) (signoz.Config, error) {
uris := make([]string, 0, len(configFiles)+1)
for _, f := range configFiles {
uris = append(uris, "file:"+f)
}
uris = append(uris, "env:")
return signoz.NewConfig(
ctx,
logger,
config.ResolverConfig{
Uris: uris,
ProviderFactories: []config.ProviderFactory{
envprovider.NewFactory(),
fileprovider.NewFactory(),
},
},
)
}
// SQLStoreProviderFactories is the community SQL store provider set (the
// control-plane metadata store — sqlite by default).
func SQLStoreProviderFactories() factory.NamedMap[factory.ProviderFactory[sqlstore.SQLStore, sqlstore.Config]] {
return signoz.NewSQLStoreProviderFactories()
}
// SQLSchemaProviderFactories is the community SQL schema provider set.
func SQLSchemaProviderFactories(sqlstore sqlstore.SQLStore) factory.NamedMap[factory.ProviderFactory[sqlschema.SQLSchema, sqlschema.Config]] {
return signoz.NewSQLSchemaProviderFactories(sqlstore)
}
// NewSigNoz constructs the SigNoz runtime with the community provider set: noop
// zeus/licensing/gateway, Hanzo IAM authz (iamauthz — the sole authorizer), the
// ClickHouse (Hanzo Datastore) telemetry store, sqlite control-plane store, the
// full dashboard/cloudintegration/metricreductionrule/ruler modules, and (wired
// internally by signoz.New) the identN provider set including iamidentn — the
// gateway-header human identity the running pod trusts. The provider list is the
// single source of truth for how o11y boots.
func NewSigNoz(ctx context.Context, config signoz.Config) (*signoz.SigNoz, error) {
return signoz.New(
ctx,
config,
zeus.Config{},
noopzeus.NewProviderFactory(),
licensing.Config{},
func(_ sqlstore.SQLStore, _ zeus.Zeus, _ organization.Getter, _ analytics.Analytics) factory.ProviderFactory[licensing.Licensing, licensing.Config] {
return nooplicensing.NewFactory()
},
signoz.NewEmailingProviderFactories(),
signoz.NewCacheProviderFactories(),
signoz.NewWebProviderFactories(config.Global),
SQLSchemaProviderFactories,
SQLStoreProviderFactories(),
signoz.NewTelemetryStoreProviderFactories(),
func(ctx context.Context, providerSettings factory.ProviderSettings, store authtypes.AuthNStore, licensing licensing.Licensing) (map[authtypes.AuthNProvider]authn.AuthN, error) {
return signoz.NewAuthNs(ctx, providerSettings, store, licensing, config.Global)
},
func(_ context.Context, sqlstore sqlstore.SQLStore, _ authz.Config, _ licensing.Licensing, _ []authz.OnBeforeRoleDelete) (factory.ProviderFactory[authz.AuthZ, authz.Config], error) {
// Hanzo IAM is the sole authorization provider — every decision is
// delegated to IAM's Casbin enforce endpoint. No OpenFGA, no fallback.
return iamauthz.NewProviderFactory(sqlstore), nil
},
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, _ querier.Querier, _ licensing.Licensing, tagModule tag.Module) dashboard.Module {
return impldashboard.NewModule(impldashboard.NewStore(store), settings, analytics, orgGetter, queryParser, tagModule)
},
func(_ licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config] {
return noopgateway.NewProviderFactory()
},
func(_ licensing.Licensing) factory.NamedMap[factory.ProviderFactory[auditor.Auditor, auditor.Config]] {
return signoz.NewAuditorProviderFactories()
},
func(_ context.Context, _ factory.ProviderSettings, _ flagger.Flagger, _ licensing.Licensing, _ telemetrystore.TelemetryStore, _ retention.Getter, _ organization.Getter, _ zeus.Zeus) (factory.NamedMap[factory.ProviderFactory[meterreporter.Reporter, meterreporter.Config]], string) {
return signoz.NewMeterReporterProviderFactories(), "noop"
},
func(ps factory.ProviderSettings, q querier.Querier, a analytics.Analytics) querier.Handler {
return querier.NewHandler(ps, q, a)
},
func(_ sqlstore.SQLStore, _ dashboard.Module, _ global.Global, _ zeus.Zeus, _ gateway.Gateway, _ licensing.Licensing, _ serviceaccount.Module, _ cloudintegration.Config) (cloudintegration.Module, error) {
return implcloudintegration.NewModule(), nil
},
func(_ sqlstore.SQLStore, _ telemetrystore.TelemetryStore, _ dashboard.Module, _ queryparser.QueryParser, _ licensing.Licensing, _ flagger.Flagger, _ telemetrytypes.MetadataStore, _ factory.ProviderSettings, _ int) metricreductionrule.Module {
return implmetricreductionrule.NewModule()
},
func(c cache.Cache, am alertmanager.Alertmanager, ss sqlstore.SQLStore, ts telemetrystore.TelemetryStore, ms telemetrytypes.MetadataStore, p prometheus.Prometheus, og organization.Getter, rsh rulestatehistory.Module, q querier.Querier, qp queryparser.QueryParser) factory.NamedMap[factory.ProviderFactory[ruler.Ruler, ruler.Config]] {
return factory.MustNewNamedMap(signozruler.NewFactory(c, am, ss, ts, ms, p, og, rsh, q, qp, nil, nil))
},
)
}
// NewServer constructs the SigNoz runtime and its HTTP server together. Callers
// choose the serving mode:
//
// - standalone (cmd/community): server.Start binds the listeners; SigNoz.Start
// runs background evaluation; SigNoz.Wait blocks.
// - embedded (hanzoai/cloud): SigNoz.Start runs background evaluation, and
// server.PublicHandler() is installed via o11y.SetHandler — cloud's own HTTP
// stack serves /v1/o11y/*; the listeners are never bound.
//
// Both paths share this ONE construction, so identity and authz are identical.
func NewServer(ctx context.Context, config signoz.Config) (*app.Server, *signoz.SigNoz, error) {
sn, err := NewSigNoz(ctx, config)
if err != nil {
return nil, nil, err
}
server, err := app.NewServer(config, sn)
if err != nil {
return nil, nil, err
}
return server, sn, nil
}
+30
View File
@@ -0,0 +1,30 @@
package community
import (
"context"
"log/slog"
"testing"
)
// The hanzoai/cloud embed and the standalone binary both resolve config through
// NewConfig. The flat operator knob O11Y_DATASTORE_DSN MUST win and land on the
// telemetry store DSN — this is the ONE var the cloud CR sets to point the
// in-process runtime at the shared ClickHouse (Hanzo Datastore). If this alias
// regresses, the embed silently talks to localhost and serves no telemetry.
func TestNewConfigAppliesDatastoreDSNAlias(t *testing.T) {
const dsn = "tcp://datastore.hanzo.svc:9000?username=u&password=p"
t.Setenv("O11Y_DATASTORE_DSN", dsn)
t.Setenv("O11Y_TELEMETRYSTORE_DATASTORE_CLUSTER", "insights")
config, err := NewConfig(context.Background(), slog.Default(), nil)
if err != nil {
t.Fatalf("NewConfig: %v", err)
}
if got := config.TelemetryStore.Clickhouse.DSN; got != dsn {
t.Fatalf("telemetrystore DSN = %q, want the O11Y_DATASTORE_DSN value %q", got, dsn)
}
if got := config.TelemetryStore.Clickhouse.Cluster; got != "insights" {
t.Fatalf("telemetrystore cluster = %q, want %q", got, "insights")
}
}
+133
View File
@@ -0,0 +1,133 @@
// Copyright (C) 2025-2026, Hanzo AI Inc. All rights reserved.
// SPDX-License-Identifier: BSD-3-Clause
package datastoremetrics
import "sort"
// Metric-series fingerprinting — the native port of the labels-hash the
// datastore metrics schema uses to join a sample (samples_v4) to its series
// metadata (time_series_v4). It is a plain FNV-1a walk over the sorted
// (key, value) attribute pairs with a 0xFF separator, seeded by a parent
// offset so the hierarchy resource → scope → point composes: the scope hash
// seeds the point hash, which is finally salted with the metric __name__.
//
// This is the minimal, dependency-free equivalent of the histogram-fork's
// fingerprint package (which lives behind an internal/ import wall and pulls
// OTLP pdata). Reproducing the exact constants here keeps the native writer on
// upstream ch-go with NO fork dependency while still producing byte-identical
// join keys, so the existing query plane reads what this writer writes.
const (
// initialOffset is the FNV-1a 64-bit offset basis — the seed for the
// top-level (resource) fingerprint.
initialOffset uint64 = 14695981039346656037
prime64 uint64 = 1099511628211
// separatorByte delimits key from value and pair from pair, so
// {a=bc} and {ab=c} hash differently.
separatorByte byte = 255
)
func hashAdd(h uint64, s string) uint64 {
for i := 0; i < len(s); i++ {
h ^= uint64(s[i])
h *= prime64
}
return h
}
func hashAddByte(h uint64, b byte) uint64 {
h ^= uint64(b)
h *= prime64
return h
}
// fingerprint folds attrs into a hash seeded by offset. attrs is walked in
// sorted key order so the result is independent of map iteration order.
func fingerprint(offset uint64, attrs map[string]string) uint64 {
keys := make([]string, 0, len(attrs))
for k := range attrs {
keys = append(keys, k)
}
sort.Strings(keys)
h := offset
for _, k := range keys {
h = hashAdd(h, k)
h = hashAddByte(h, separatorByte)
h = hashAdd(h, attrs[k])
h = hashAddByte(h, separatorByte)
}
return h
}
// hashWithName salts a series fingerprint with the metric name, matching the
// schema's __name__ dimension. This is the value stored in the `fingerprint`
// column of both samples_v4 and time_series_v4.
func hashWithName(h uint64, name string) uint64 {
sum := hashAdd(h, "__name__")
sum = hashAddByte(sum, separatorByte)
sum = hashAdd(sum, name)
return sum
}
// mergeAttrs overlays maps left-to-right (later wins) into a fresh map. Used
// to compose point + scope + resource attributes for the `labels` JSON with
// the same precedence the schema expects (resource overrides point on clash).
func mergeAttrs(maps ...map[string]string) map[string]string {
out := make(map[string]string)
for _, m := range maps {
for k, v := range m {
out[k] = v
}
}
return out
}
// marshalLabels renders attrs as the compact, key-sorted JSON object the
// datastore's JSON functions consume for the `labels` column. It is the native
// port of the schema's label marshaller: sorted keys, minimal escaping, no
// reflection. __name__ is expected to already be present in attrs.
func marshalLabels(attrs map[string]string) string {
keys := make([]string, 0, len(attrs))
for k := range attrs {
keys = append(keys, k)
}
sort.Strings(keys)
if len(keys) == 0 {
return "{}"
}
b := make([]byte, 0, 128)
b = append(b, '{')
for _, name := range keys {
b = append(b, '"')
b = append(b, name...)
b = append(b, '"', ':', '"')
for _, c := range []byte(attrs[name]) {
switch c {
case '\\', '"':
b = append(b, '\\', c)
case '\n':
b = append(b, '\\', 'n')
case '\r':
b = append(b, '\\', 'r')
case '\t':
b = append(b, '\\', 't')
default:
b = append(b, c)
}
}
b = append(b, '"', ',')
}
b[len(b)-1] = '}' // replace trailing comma
return string(b)
}
// labelsJSON builds the `labels` column value for a series: the merged
// point/scope/resource attributes plus __name__, key-sorted and JSON-encoded.
func labelsJSON(name string, point, scope, resource map[string]string) string {
merged := mergeAttrs(point, scope, resource)
merged["__name__"] = name
return marshalLabels(merged)
}
+358
View File
@@ -0,0 +1,358 @@
// Copyright (C) 2025-2026, Hanzo AI Inc. All rights reserved.
// SPDX-License-Identifier: BSD-3-Clause
// Package datastoremetrics is the o11y-native datastore metrics driver: it
// writes metric samples + series metadata to the datastore (the columnar
// telemetry backend) over UPSTREAM ch-go / clickhouse-go v2, with NO
// histogram-fork dependency.
//
// It is the write-side companion to pkg/zapmetricreceiver: WriteMetrics
// satisfies zapmetricreceiver.Handler, so a receiver dispatches each decoded
// batch straight into the datastore:
//
// w := datastoremetrics.NewWriter(store.ClickhouseDB())
// rcv, _ := zapmetricreceiver.New(zapmetricreceiver.Config{OnBatch: w.WriteMetrics})
//
// WHY THIS EXISTS — the unblock. The stock histogram exporter serialises OTLP
// exponential histograms as a DDSketch into the `exp_hist.sketch` column, which
// needs a FORKED ch-go exposing proto.DD / proto.Store / proto.IndexMapping.
// That fork conflicts with the upstream ch-go the query plane pins, which is why
// metrics ingest could not move in-process. This driver sidesteps the fork
// entirely: the ZAP wire (luxfi/metric.MetricBatch) already carries CLASSIC
// Prometheus shapes — explicit histogram buckets, summary quantiles — so every
// series decomposes into plain samples_v4 rows (`<name>.bucket{le=…}`,
// `<name>.count`, `<name>.sum`, `<name>.quantile{quantile=…}`). No sketch, no
// exp_hist, no fork — just the two tables the query plane already reads
// (time_series_v4 + samples_v4), keyed by the identical labels fingerprint.
package datastoremetrics
import (
"context"
"fmt"
"strconv"
"time"
"github.com/ClickHouse/clickhouse-go/v2"
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
"github.com/hanzoai/o11y/pkg/telemetrymetrics"
"github.com/hanzoai/o11y/pkg/zapmetricreceiver"
)
// Temporality + type column values — the exact strings the query plane filters
// on (metrictypes.Temporality.Value() / OTLP MetricType.String()).
const (
temporalityCumulative = "Cumulative"
temporalityUnspecified = "Unspecified"
typeSum = "Sum"
typeGauge = "Gauge"
typeHistogram = "Histogram"
typeSummary = "Summary"
// Series-name suffixes for decomposed complex metrics — the datastore's
// dot-suffix convention (NOT Prometheus `_bucket`/`_count`).
suffixCount = ".count"
suffixSum = ".sum"
suffixBucket = ".bucket"
suffixQuantile = ".quantile"
// unitCount is the unit stamped on synthesised `.count` series.
unitCount = "1"
)
// INSERT templates — identical column order to the datastore metrics schema.
// exp_hist is intentionally absent: the native path never writes a sketch.
const (
samplesSQLTmpl = "INSERT INTO %s.%s (env, temporality, metric_name, fingerprint, unix_milli, value, flags, inserted_at_unix_milli) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
timeSeriesSQLTmpl = "INSERT INTO %s.%s (env, temporality, metric_name, description, unit, type, is_monotonic, fingerprint, unix_milli, labels, attrs, scope_attrs, resource_attrs, __normalized, inserted_at_unix_milli) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
)
// sampleRow maps 1:1 to a samples_v4 row (minus the write-time inserted_at).
type sampleRow struct {
env string
temporality string
metricName string
fingerprint uint64
unixMilli int64
value float64
flags uint32
}
// tsRow maps 1:1 to a time_series_v4 row (minus __normalized / inserted_at).
type tsRow struct {
env string
temporality string
metricName string
description string
unit string
typ string
isMonotonic bool
fingerprint uint64
unixMilli int64 // hour-floored, matching the schema's series cadence
labels string
attrs map[string]string
scopeAttrs map[string]string
resourceAttrs map[string]string
}
// Writer ingests decoded metric batches into the datastore over upstream
// clickhouse-go v2. It is safe for concurrent use (clickhouse.Conn is).
type Writer struct {
conn clickhouse.Conn
db string
tsTable string
samplesTable string
nowMilli func() int64
}
// Option configures a Writer.
type Option func(*Writer)
// WithDatabase overrides the target datastore database (default: the query
// plane's canonical metrics DB).
func WithDatabase(db string) Option { return func(w *Writer) { w.db = db } }
// WithTables overrides the distributed time-series / samples table names.
func WithTables(timeSeries, samples string) Option {
return func(w *Writer) { w.tsTable, w.samplesTable = timeSeries, samples }
}
// WithNow injects the inserted-at clock (tests).
func WithNow(now func() int64) Option { return func(w *Writer) { w.nowMilli = now } }
// NewWriter builds a Writer over an existing datastore connection. Defaults
// target the SAME database + distributed tables the query plane reads, so a
// series written here is immediately queryable.
func NewWriter(conn clickhouse.Conn, opts ...Option) *Writer {
w := &Writer{
conn: conn,
db: telemetrymetrics.DBName,
tsTable: telemetrymetrics.TimeseriesV4TableName,
samplesTable: telemetrymetrics.SamplesV4TableName,
nowMilli: func() int64 { return time.Now().UnixMilli() },
}
for _, o := range opts {
o(w)
}
return w
}
// WriteMetrics is the zapmetricreceiver.Handler: it decomposes a batch into
// series + samples and writes both tables. A nil / empty batch is a no-op.
func (w *Writer) WriteMetrics(ctx context.Context, batch *zapmetricreceiver.MetricBatch) error {
if batch == nil || len(batch.Families) == 0 {
return nil
}
if batch.TimestampNs == 0 {
batch.TimestampNs = w.nowMilli() * 1e6
}
ts, samples := buildRows(batch)
if len(ts) == 0 && len(samples) == 0 {
return nil
}
if err := w.writeTimeSeries(ctx, ts); err != nil {
return fmt.Errorf("datastoremetrics: write time_series: %w", err)
}
if err := w.writeSamples(ctx, samples); err != nil {
return fmt.Errorf("datastoremetrics: write samples: %w", err)
}
return nil
}
// buildRows is the pure batch → rows transformation — all fingerprinting and
// classic-shape decomposition, no IO. Kept free of the connection so it is
// exhaustively unit-testable.
func buildRows(batch *zapmetricreceiver.MetricBatch) (tsRows []tsRow, sampleRows []sampleRow) {
resourceAttrs := normalizedResource(batch)
env := resourceAttrs["deployment.environment"]
resourceHash := fingerprint(initialOffset, resourceAttrs)
// The ZAP wire carries no instrumentation scope; the scope layer is empty,
// so its hash is just the resource hash carried forward.
scopeAttrs := map[string]string{}
scopeHash := fingerprint(resourceHash, scopeAttrs)
unixMilli := batch.TimestampNs / 1e6
tsUnixMilli := unixMilli / 3600000 * 3600000 // floor to the hour
seenTS := make(map[[2]uint64]bool)
emit := func(name, desc, unit, typ, temporality string, isMonotonic bool, baseLabels, extras map[string]string, value float64) {
point := mergeAttrs(baseLabels, extras)
fp := hashWithName(fingerprint(scopeHash, point), name)
sampleRows = append(sampleRows, sampleRow{
env: env, temporality: temporality, metricName: name,
fingerprint: fp, unixMilli: unixMilli, value: value,
})
key := [2]uint64{fp, uint64(tsUnixMilli)}
if seenTS[key] {
return // same series already described this hour — one metadata row suffices
}
seenTS[key] = true
tsRows = append(tsRows, tsRow{
env: env, temporality: temporality, metricName: name, description: desc, unit: unit,
typ: typ, isMonotonic: isMonotonic, fingerprint: fp, unixMilli: tsUnixMilli,
labels: labelsJSON(name, point, scopeAttrs, resourceAttrs), attrs: point,
scopeAttrs: scopeAttrs, resourceAttrs: resourceAttrs,
})
}
for i := range batch.Families {
fam := &batch.Families[i]
desc := fam.Help
for j := range fam.Metrics {
m := &fam.Metrics[j]
labels := m.Labels
switch fam.Type {
case "counter":
if m.Value == nil {
continue
}
emit(fam.Name, desc, "", typeSum, temporalityCumulative, true,
labels, tempExtras(temporalityCumulative), *m.Value)
case "gauge":
if m.Value == nil {
continue
}
emit(fam.Name, desc, "", typeGauge, temporalityUnspecified, false,
labels, tempExtras(temporalityUnspecified), *m.Value)
case "histogram":
emitHistogram(emit, fam.Name, desc, labels, m)
case "summary":
emitSummary(emit, fam.Name, desc, labels, m)
default:
// unknown family type: skip rather than write a malformed row
}
}
}
return tsRows, sampleRows
}
// emitFunc is the closure buildRows hands to the per-type decomposers.
type emitFunc func(name, desc, unit, typ, temporality string, isMonotonic bool, baseLabels, extras map[string]string, value float64)
// emitHistogram decomposes a classic histogram into `.count`, `.sum` and
// cumulative `.bucket{le=…}` samples (including le=+Inf) — the exact series the
// query plane's histogram_quantile expects.
func emitHistogram(emit emitFunc, name, desc string, labels map[string]string, m *zapmetricreceiver.Metric) {
if m.SampleCount != nil {
emit(name+suffixCount, desc, unitCount, typeSum, temporalityCumulative, true,
labels, tempExtras(temporalityCumulative), float64(*m.SampleCount))
}
if m.SampleSum != nil {
emit(name+suffixSum, desc, "", typeSum, temporalityCumulative, true,
labels, tempExtras(temporalityCumulative), *m.SampleSum)
}
var lastCumulative float64
for _, b := range m.Buckets {
lastCumulative = float64(b.CumulativeCount)
emit(name+suffixBucket, desc, "", typeHistogram, temporalityCumulative, true,
labels, leExtras(strconv.FormatFloat(b.UpperBound, 'f', -1, 64), temporalityCumulative),
float64(b.CumulativeCount))
}
// le=+Inf carries the total count. Prefer SampleCount; fall back to the last
// cumulative bucket when the sender omitted it.
infValue := lastCumulative
if m.SampleCount != nil {
infValue = float64(*m.SampleCount)
} else if len(m.Buckets) == 0 {
return // nothing to anchor +Inf to
}
emit(name+suffixBucket, desc, "", typeHistogram, temporalityCumulative, true,
labels, leExtras("+Inf", temporalityCumulative), infValue)
}
// emitSummary decomposes a summary into `.count`, `.sum` and
// `.quantile{quantile=…}` samples.
func emitSummary(emit emitFunc, name, desc string, labels map[string]string, m *zapmetricreceiver.Metric) {
if m.SampleCount != nil {
emit(name+suffixCount, desc, unitCount, typeSum, temporalityCumulative, true,
labels, tempExtras(temporalityCumulative), float64(*m.SampleCount))
}
if m.SampleSum != nil {
emit(name+suffixSum, desc, "", typeSum, temporalityCumulative, true,
labels, tempExtras(temporalityCumulative), *m.SampleSum)
}
for _, q := range m.Quantiles {
emit(name+suffixQuantile, desc, "", typeSummary, temporalityCumulative, true,
labels, quantileExtras(strconv.FormatFloat(q.Quantile, 'f', -1, 64), temporalityCumulative),
q.Value)
}
}
// normalizedResource copies the batch resource map and lifts AppName / Version
// into the OTLP service.* attributes when absent, so dashboards can group by
// service.name.
func normalizedResource(batch *zapmetricreceiver.MetricBatch) map[string]string {
r := make(map[string]string, len(batch.Resource)+2)
for k, v := range batch.Resource {
r[k] = v
}
if batch.AppName != "" {
if _, ok := r["service.name"]; !ok {
r["service.name"] = batch.AppName
}
}
if batch.Version != "" {
if _, ok := r["service.version"]; !ok {
r["service.version"] = batch.Version
}
}
return r
}
func tempExtras(t string) map[string]string { return map[string]string{"__temporality__": t} }
func leExtras(le, t string) map[string]string {
return map[string]string{"le": le, "__temporality__": t}
}
func quantileExtras(q, t string) map[string]string {
return map[string]string{"quantile": q, "__temporality__": t}
}
func (w *Writer) writeTimeSeries(ctx context.Context, rows []tsRow) error {
if len(rows) == 0 {
return nil
}
stmt, err := w.conn.PrepareBatch(ctx,
fmt.Sprintf(timeSeriesSQLTmpl, w.db, w.tsTable), driver.WithReleaseConnection())
if err != nil {
return err
}
defer stmt.Close()
now := w.nowMilli()
for _, r := range rows {
if err := stmt.Append(
r.env, r.temporality, r.metricName, r.description, r.unit, r.typ,
r.isMonotonic, r.fingerprint, r.unixMilli, r.labels,
r.attrs, r.scopeAttrs, r.resourceAttrs, false, now,
); err != nil {
return err
}
}
return stmt.Send()
}
func (w *Writer) writeSamples(ctx context.Context, rows []sampleRow) error {
if len(rows) == 0 {
return nil
}
stmt, err := w.conn.PrepareBatch(ctx,
fmt.Sprintf(samplesSQLTmpl, w.db, w.samplesTable), driver.WithReleaseConnection())
if err != nil {
return err
}
defer stmt.Close()
now := w.nowMilli()
for _, r := range rows {
if err := stmt.Append(
r.env, r.temporality, r.metricName, r.fingerprint, r.unixMilli, r.value, r.flags, now,
); err != nil {
return err
}
}
return stmt.Send()
}
+280
View File
@@ -0,0 +1,280 @@
// Copyright (C) 2025-2026, Hanzo AI Inc. All rights reserved.
// SPDX-License-Identifier: BSD-3-Clause
package datastoremetrics
import (
"strings"
"testing"
"github.com/hanzoai/o11y/pkg/zapmetricreceiver"
)
func f64(v float64) *float64 { return &v }
func u64(v uint64) *uint64 { return &v }
// refFNV is an independent, byte-stream expression of FNV-1a (the ground-truth
// definition) used to cross-check the fingerprint primitive without importing
// the forked internal package.
func refFNV(seed uint64, data []byte) uint64 {
const prime = 1099511628211
h := seed
for _, b := range data {
h ^= uint64(b)
h *= prime
}
return h
}
func TestFingerprintMatchesFNVReference(t *testing.T) {
attrs := map[string]string{"b": "2", "a": "1"}
// sorted key order: a,b — stream is key 0xFF value 0xFF per pair.
stream := []byte("a")
stream = append(stream, 255)
stream = append(stream, []byte("1")...)
stream = append(stream, 255)
stream = append(stream, []byte("b")...)
stream = append(stream, 255)
stream = append(stream, []byte("2")...)
stream = append(stream, 255)
got := fingerprint(initialOffset, attrs)
want := refFNV(initialOffset, stream)
if got != want {
t.Fatalf("fingerprint=%d want=%d", got, want)
}
// hashWithName appends __name__ 0xFF name (no trailing separator).
name := "http_requests"
nameStream := append([]byte("__name__"), 255)
nameStream = append(nameStream, []byte(name)...)
if hashWithName(got, name) != refFNV(got, nameStream) {
t.Fatalf("hashWithName mismatch")
}
}
func TestFingerprintOffsetPassthroughAndOrderIndependent(t *testing.T) {
if fingerprint(initialOffset, map[string]string{}) != initialOffset {
t.Fatalf("empty attrs must pass the offset through unchanged")
}
a := fingerprint(initialOffset, map[string]string{"x": "1", "y": "2", "z": "3"})
b := fingerprint(initialOffset, map[string]string{"z": "3", "y": "2", "x": "1"})
if a != b {
t.Fatalf("fingerprint must be independent of map order: %d != %d", a, b)
}
if fingerprint(initialOffset, map[string]string{"x": "1"}) == fingerprint(initialOffset, map[string]string{"x": "2"}) {
t.Fatalf("distinct values must hash differently")
}
}
func indexTS(rows []tsRow) map[uint64]tsRow {
m := make(map[uint64]tsRow, len(rows))
for _, r := range rows {
m[r.fingerprint] = r
}
return m
}
func TestBuildRowsCounter(t *testing.T) {
batch := &zapmetricreceiver.MetricBatch{
AppName: "gateway",
Version: "1.2.3",
Resource: map[string]string{"deployment.environment": "prod"},
TimestampNs: 1_700_000_000_000 * 1e6,
Families: []zapmetricreceiver.MetricFamily{{
Name: "http_requests_total", Help: "total reqs", Type: "counter",
Metrics: []zapmetricreceiver.Metric{{
Labels: map[string]string{"method": "GET"}, Value: f64(42),
}},
}},
}
ts, samples := buildRows(batch)
if len(samples) != 1 || len(ts) != 1 {
t.Fatalf("counter: got %d samples %d ts, want 1/1", len(samples), len(ts))
}
s := samples[0]
if s.metricName != "http_requests_total" || s.value != 42 || s.temporality != temporalityCumulative {
t.Fatalf("counter sample wrong: %+v", s)
}
if s.env != "prod" {
t.Fatalf("env must come from deployment.environment: %q", s.env)
}
tr := ts[0]
if tr.fingerprint != s.fingerprint {
t.Fatalf("join key broken: ts fp %d != sample fp %d", tr.fingerprint, s.fingerprint)
}
if tr.typ != typeSum || !tr.isMonotonic {
t.Fatalf("counter must be monotonic Sum: %+v", tr)
}
if tr.resourceAttrs["service.name"] != "gateway" || tr.resourceAttrs["service.version"] != "1.2.3" {
t.Fatalf("AppName/Version must lift into service.*: %+v", tr.resourceAttrs)
}
for _, sub := range []string{`"__name__":"http_requests_total"`, `"method":"GET"`, `"service.name":"gateway"`, `"__temporality__":"Cumulative"`} {
if !strings.Contains(tr.labels, sub) {
t.Fatalf("labels %q missing %q", tr.labels, sub)
}
}
}
func TestBuildRowsGauge(t *testing.T) {
batch := &zapmetricreceiver.MetricBatch{
TimestampNs: 1_700_000_000_000 * 1e6,
Families: []zapmetricreceiver.MetricFamily{{
Name: "queue_depth", Type: "gauge",
Metrics: []zapmetricreceiver.Metric{{Value: f64(7)}},
}},
}
ts, samples := buildRows(batch)
if len(samples) != 1 || samples[0].temporality != temporalityUnspecified {
t.Fatalf("gauge temporality must be Unspecified: %+v", samples)
}
if ts[0].typ != typeGauge || ts[0].isMonotonic {
t.Fatalf("gauge must be non-monotonic Gauge: %+v", ts[0])
}
}
func TestBuildRowsHistogram(t *testing.T) {
batch := &zapmetricreceiver.MetricBatch{
TimestampNs: 1_700_000_000_000 * 1e6,
Families: []zapmetricreceiver.MetricFamily{{
Name: "req_duration", Type: "histogram",
Metrics: []zapmetricreceiver.Metric{{
Labels: map[string]string{"route": "/v1/chat"},
SampleCount: u64(10), SampleSum: f64(3.5),
Buckets: []zapmetricreceiver.Bucket{
{UpperBound: 0.1, CumulativeCount: 3},
{UpperBound: 0.5, CumulativeCount: 8},
},
}},
}},
}
ts, samples := buildRows(batch)
tsByFP := indexTS(ts)
// Expect .count, .sum, and 3 .bucket samples (0.1, 0.5, +Inf).
var count, sum int
les := map[string]float64{} // le -> sample value
for _, s := range samples {
tr := tsByFP[s.fingerprint]
switch s.metricName {
case "req_duration.count":
count++
if s.value != 10 {
t.Fatalf(".count value=%v want 10", s.value)
}
if tr.unit != unitCount || tr.typ != typeSum {
t.Fatalf(".count series wrong: %+v", tr)
}
case "req_duration.sum":
sum++
if s.value != 3.5 {
t.Fatalf(".sum value=%v want 3.5", s.value)
}
case "req_duration.bucket":
if tr.typ != typeHistogram {
t.Fatalf(".bucket type must be Histogram: %+v", tr)
}
les[tr.attrs["le"]] = s.value
default:
t.Fatalf("unexpected series %q", s.metricName)
}
}
if count != 1 || sum != 1 {
t.Fatalf("want one .count and one .sum, got %d/%d", count, sum)
}
if les["0.1"] != 3 || les["0.5"] != 8 || les["+Inf"] != 10 {
t.Fatalf("cumulative bucket values wrong: %+v", les)
}
// Every bucket le must produce a distinct fingerprint.
seen := map[uint64]bool{}
for _, s := range samples {
if s.metricName == "req_duration.bucket" {
if seen[s.fingerprint] {
t.Fatalf("duplicate bucket fingerprint %d", s.fingerprint)
}
seen[s.fingerprint] = true
}
}
}
func TestBuildRowsSummary(t *testing.T) {
batch := &zapmetricreceiver.MetricBatch{
TimestampNs: 1_700_000_000_000 * 1e6,
Families: []zapmetricreceiver.MetricFamily{{
Name: "rpc_latency", Type: "summary",
Metrics: []zapmetricreceiver.Metric{{
SampleCount: u64(5), SampleSum: f64(1.2),
Quantiles: []zapmetricreceiver.Quantile{
{Quantile: 0.5, Value: 0.2},
{Quantile: 0.99, Value: 0.9},
},
}},
}},
}
ts, samples := buildRows(batch)
tsByFP := indexTS(ts)
quantiles := map[string]float64{}
for _, s := range samples {
tr := tsByFP[s.fingerprint]
if s.metricName == "rpc_latency.quantile" {
if tr.typ != typeSummary {
t.Fatalf(".quantile type must be Summary: %+v", tr)
}
quantiles[tr.attrs["quantile"]] = s.value
}
}
if quantiles["0.5"] != 0.2 || quantiles["0.99"] != 0.9 {
t.Fatalf("quantile samples wrong: %+v", quantiles)
}
}
func TestTimeSeriesHourFloor(t *testing.T) {
// 1_700_000_123_456 ms is mid-hour; series unix_milli must floor to the hour.
batch := &zapmetricreceiver.MetricBatch{
TimestampNs: 1_700_000_123_456 * 1e6,
Families: []zapmetricreceiver.MetricFamily{{
Name: "c", Type: "counter",
Metrics: []zapmetricreceiver.Metric{{Value: f64(1)}},
}},
}
ts, samples := buildRows(batch)
if samples[0].unixMilli != 1_700_000_123_456 {
t.Fatalf("sample keeps exact ms: %d", samples[0].unixMilli)
}
if ts[0].unixMilli != 1_700_000_123_456/3600000*3600000 {
t.Fatalf("series must floor to hour: %d", ts[0].unixMilli)
}
}
func TestNilValueSkipped(t *testing.T) {
batch := &zapmetricreceiver.MetricBatch{
TimestampNs: 1,
Families: []zapmetricreceiver.MetricFamily{{
Name: "c", Type: "counter",
Metrics: []zapmetricreceiver.Metric{{Value: nil}},
}},
}
ts, samples := buildRows(batch)
if len(ts) != 0 || len(samples) != 0 {
t.Fatalf("nil counter value must be skipped, got %d/%d", len(ts), len(samples))
}
}
func TestDeterministicAcrossCalls(t *testing.T) {
mk := func() *zapmetricreceiver.MetricBatch {
return &zapmetricreceiver.MetricBatch{
AppName: "svc", TimestampNs: 1_700_000_000_000 * 1e6,
Families: []zapmetricreceiver.MetricFamily{{
Name: "m", Type: "counter",
Metrics: []zapmetricreceiver.Metric{{
Labels: map[string]string{"a": "1", "b": "2", "c": "3"}, Value: f64(1),
}},
}},
}
}
_, s1 := buildRows(mk())
_, s2 := buildRows(mk())
if s1[0].fingerprint != s2[0].fingerprint {
t.Fatalf("fingerprints must be stable across calls: %d != %d", s1[0].fingerprint, s2[0].fingerprint)
}
}
+7 -1
View File
@@ -38,7 +38,13 @@ import (
"github.com/gorilla/mux"
"github.com/gorilla/websocket"
jsoniter "github.com/json-iterator/go"
_ "modernc.org/sqlite"
// The "sqlite" database/sql driver is registered exactly once by the sqlstore
// provider (pkg/sqlstore/sqlitesqlstore, which this package transitively
// imports and which must import modernc for the *sqlite.Error type). A second
// blank _ "modernc.org/sqlite" here was redundant, and re-pointing it at
// github.com/hanzoai/sqlite would double-register "sqlite" under CGO_ENABLED=1
// (the fork's cgo backend Register()s mattn while modernc's init Register()s
// modernc) — the exact "Register called twice" panic. One registration site.
"github.com/hanzoai/o11y/pkg/contextlinks"
traceFunnelsModule "github.com/hanzoai/o11y/pkg/modules/tracefunnel"
+3 -4
View File
@@ -8,8 +8,6 @@ import (
"sync"
"time"
"google.golang.org/protobuf/proto"
"github.com/hanzoai/o11y/pkg/errors"
"github.com/hanzoai/o11y/pkg/sqlstore"
"github.com/hanzoai/o11y/pkg/types/opamptypes"
@@ -17,6 +15,7 @@ import (
"github.com/open-telemetry/opamp-go/protobufs"
opampTypes "github.com/open-telemetry/opamp-go/server/types"
"github.com/zap-proto/zap2pb"
)
type Agent struct {
@@ -125,7 +124,7 @@ func (agent *Agent) agentDescriptionChanged(newStatus *protobufs.AgentToServer)
if newStatus.AgentDescription == nil {
return false
}
if proto.Equal(agent.Status.AgentDescription, newStatus.AgentDescription) {
if zap2pb.Equal(agent.Status.AgentDescription, newStatus.AgentDescription) {
return false
}
agent.CanLB = ExtractLbFlag(newStatus.AgentDescription)
@@ -136,7 +135,7 @@ func (agent *Agent) agentDescriptionChanged(newStatus *protobufs.AgentToServer)
// subscribers if the status has changed relative to what we have stored.
func (agent *Agent) updateRemoteConfigStatus(newStatus *protobufs.AgentToServer) {
if newStatus.RemoteConfigStatus == nil ||
proto.Equal(agent.Status.RemoteConfigStatus, newStatus.RemoteConfigStatus) {
zap2pb.Equal(agent.Status.RemoteConfigStatus, newStatus.RemoteConfigStatus) {
return
}
@@ -0,0 +1,53 @@
package sqlitesqlstore
import (
"context"
"path/filepath"
"testing"
"time"
"github.com/hanzoai/o11y/pkg/factory/factorytest"
"github.com/hanzoai/o11y/pkg/sqlstore"
"github.com/stretchr/testify/require"
)
// TestDriverRegisteredOnce proves the "sqlite" database/sql driver is registered
// exactly once — via this provider's modernc import — and opens cleanly. It is the
// guard for removing the now-redundant blank _ "modernc.org/sqlite" from
// pkg/query-service/app/http_handler.go: registration must survive that removal,
// and pointing that blank import at github.com/hanzoai/sqlite instead would
// double-register "sqlite" under CGO_ENABLED=1 (the fork's cgo backend Register()s
// mattn while modernc's init Register()s modernc) and panic. Opening the provider
// here — under whatever CGO mode the test runs — fails loudly if either regression
// returns. It also asserts the DSN pragmas take effect (journal_mode=wal,
// busy_timeout>0) through the driver.
func TestDriverRegisteredOnce(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "o11y.db")
store, err := New(context.Background(), factorytest.NewSettings(), sqlstore.Config{
Provider: "sqlite",
Connection: sqlstore.ConnectionConfig{
MaxOpenConns: 1,
MaxConnLifetime: 0,
},
Sqlite: sqlstore.SqliteConfig{
Path: dbPath,
Mode: "wal",
BusyTimeout: 5 * time.Second,
TransactionMode: "deferred",
},
})
require.NoError(t, err)
sqldb := store.SQLDB()
require.NoError(t, sqldb.Ping())
var journalMode string
require.NoError(t, sqldb.QueryRow("PRAGMA journal_mode").Scan(&journalMode))
require.Equal(t, "wal", journalMode, "journal_mode not applied — modernc _pragma DSN form not honored")
var busyTimeout int
require.NoError(t, sqldb.QueryRow("PRAGMA busy_timeout").Scan(&busyTimeout))
require.Greater(t, busyTimeout, 0, "busy_timeout not applied")
t.Logf("journal_mode=%s busy_timeout=%d", journalMode, busyTimeout)
}
+23 -14
View File
@@ -3,9 +3,9 @@ package sqlitesqlstore
import (
"context"
"database/sql"
"fmt"
"log/slog"
"net/url"
"strconv"
"github.com/hanzoai/o11y/pkg/errors"
"github.com/hanzoai/o11y/pkg/factory"
@@ -13,8 +13,12 @@ import (
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect/sqlitedialect"
"modernc.org/sqlite"
sqlite3 "modernc.org/sqlite/lib"
// hanzoai/sqlite is the one dual-backend driver: importing it registers the
// "sqlite" database/sql driver (mattn/SQLCipher under cgo, modernc pure-Go
// otherwise) AND exposes backend-neutral constraint-error classification, so
// this package no longer imports modernc directly (which would double-register
// "sqlite" in the cgo cloud binary).
"github.com/hanzoai/sqlite"
)
type provider struct {
@@ -44,13 +48,20 @@ func NewFactory(hookFactories ...factory.ProviderFactory[sqlstore.SQLStoreHook,
func New(ctx context.Context, providerSettings factory.ProviderSettings, config sqlstore.Config, hooks ...sqlstore.SQLStoreHook) (sqlstore.SQLStore, error) {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/hanzoai/o11y/pkg/sqlitesqlstore")
connectionParams := url.Values{}
// do not update the order of the connection params as busy_timeout doesn't work if it's not the first parameter
connectionParams.Add("_pragma", fmt.Sprintf("busy_timeout(%d)", config.Sqlite.BusyTimeout.Milliseconds()))
connectionParams.Add("_pragma", fmt.Sprintf("journal_mode(%s)", config.Sqlite.Mode))
connectionParams.Add("_pragma", "foreign_keys(1)")
connectionParams.Set("_txlock", config.Sqlite.TransactionMode)
sqldb, err := sql.Open("sqlite", "file:"+config.Sqlite.Path+"?"+connectionParams.Encode())
// Build the DSN with backend-correct pragma syntax. hanzoai/sqlite.PragmaDSN
// emits `_pragma=journal_mode(wal)` under the modernc backend and
// `_journal_mode=wal` under the mattn/SQLCipher backend, so the pragmas
// actually apply whichever backend the binary links — this package is built
// CGO=0 standalone but CGO=1 inside the cloud binary, and a single hardcoded
// syntax is silently dropped by the other backend. busy_timeout MUST lead
// (WAL cannot be enabled while another connection holds the db). _txlock is a
// driver connection param (not a pragma) that both backends honor; append it.
dsn := sqlite.PragmaDSN(config.Sqlite.Path, []sqlite.Pragma{
{Name: "busy_timeout", Value: strconv.FormatInt(config.Sqlite.BusyTimeout.Milliseconds(), 10)},
{Name: "journal_mode", Value: config.Sqlite.Mode},
{Name: "foreign_keys", Value: "1"},
}) + "&_txlock=" + url.QueryEscape(config.Sqlite.TransactionMode)
sqldb, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
}
@@ -102,10 +113,8 @@ func (provider *provider) WrapNotFoundErrf(err error, code errors.Code, format s
}
func (provider *provider) WrapAlreadyExistsErrf(err error, code errors.Code, format string, args ...any) error {
if sqlite3Err, ok := err.(*sqlite.Error); ok {
if sqlite3Err.Code() == sqlite3.SQLITE_CONSTRAINT_UNIQUE || sqlite3Err.Code() == sqlite3.SQLITE_CONSTRAINT_PRIMARYKEY || sqlite3Err.Code() == sqlite3.SQLITE_CONSTRAINT_FOREIGNKEY {
return errors.Wrapf(err, errors.TypeAlreadyExists, code, format, args...)
}
if sqlite.IsConstraintUnique(err) || sqlite.IsConstraintPrimaryKey(err) || sqlite.IsConstraintForeignKey(err) {
return errors.Wrapf(err, errors.TypeAlreadyExists, code, format, args...)
}
return err