Compare commits

..

1 Commits

Author SHA1 Message Date
Naman Verma
67878a01b2 feat: add ai builder query plugin kind 2026-09-10 12:08:52 +05:30
25 changed files with 270 additions and 434 deletions

View File

@@ -1,12 +0,0 @@
---
paths:
- "**/*_test.go"
---
# Go tests
- **testify + table-driven.** Use `assert` / `require`; prefer table-driven cases. Tests live next to the source file.
- **`require` vs `assert`.** `require` for anything the rest of the test cannot proceed without — setup, `require.NoError(t, err)`, nil/length checks before indexing or dereferencing. `assert` for the actual expectations, so one failed check still reports the rest.
- **Mock with mockery.** When an interface needs mocking, list it in `.mockery.yml` and run `mockery`; never hand-write mocks. Generated mocks live in the source package's `<pkg>test` sibling (e.g. `resourcestest.NewMockAdapter(t)`).
- **Table format.** Declare cases as `testCases := []struct{ name string; ... }` and iterate with `for _, testCase := range testCases { t.Run(testCase.name, ...) }` — the variables are named `testCases` / `testCase`. Case names are PascalCase segments joined by `_`, one segment per aspect (scenario, condition, expectation): `TimestampNotNullNoDefault`, `DropPrimaryKeyConstraint_AlterColumnNullable`, `ForeignKeyConstraint_DoesNotExist_SCreateAndDropConstraintTrue`.
- **No hoisted test constants.** When goconst flags a repeated literal in a test, vary the fixture strings across cases instead of hoisting a constant — never introduce a shared const for test data.

View File

@@ -144,12 +144,6 @@ apiserver:
read_timeout: 60s
# Keep at 0; any value cuts off streaming endpoints (livetail, SSE, export_raw_data).
write_timeout: 0
# tls:
# enabled: true
# cert_file: /path/to/server.crt
# key_file: /path/to/server.key
# # Minimum TLS version: "1.2" or "1.3". Defaults to "1.2".
# min_version: "1.2"
timeout:
# Default request timeout.
default: 60s

View File

@@ -3137,6 +3137,67 @@ components:
repeatVariable:
type: string
type: object
DashboardtypesAIBuilderQuerySpec:
properties:
aggregations:
items:
$ref: '#/components/schemas/Querybuildertypesv5TraceAggregation'
nullable: true
type: array
cursor:
type: string
disabled:
type: boolean
filter:
$ref: '#/components/schemas/Querybuildertypesv5Filter'
functions:
items:
$ref: '#/components/schemas/Querybuildertypesv5Function'
nullable: true
type: array
groupBy:
items:
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
nullable: true
type: array
having:
$ref: '#/components/schemas/Querybuildertypesv5Having'
legend:
type: string
limit:
type: integer
limitBy:
$ref: '#/components/schemas/Querybuildertypesv5LimitBy'
name:
type: string
offset:
type: integer
order:
items:
$ref: '#/components/schemas/Querybuildertypesv5OrderBy'
nullable: true
type: array
secondaryAggregations:
items:
$ref: '#/components/schemas/Querybuildertypesv5SecondaryAggregation'
nullable: true
type: array
selectFields:
items:
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
nullable: true
type: array
signal:
enum:
- traces
type: string
source:
$ref: '#/components/schemas/TelemetrytypesSource'
stepInterval:
$ref: '#/components/schemas/Querybuildertypesv5Step'
required:
- signal
type: object
DashboardtypesAxes:
properties:
isLogScale:
@@ -4040,6 +4101,7 @@ components:
DashboardtypesQueryPlugin:
discriminator:
mapping:
signoz/AIBuilderQuery: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpec'
signoz/BuilderQuery: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpec'
signoz/ClickHouseSQL: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5ClickHouseQuery'
signoz/CompositeQuery: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQuery'
@@ -4049,6 +4111,7 @@ components:
propertyName: kind
oneOf:
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpec'
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpec'
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQuery'
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5QueryBuilderFormula'
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5PromQuery'
@@ -4058,12 +4121,25 @@ components:
DashboardtypesQueryPluginKind:
enum:
- signoz/BuilderQuery
- signoz/AIBuilderQuery
- signoz/CompositeQuery
- signoz/Formula
- signoz/PromQLQuery
- signoz/ClickHouseSQL
- signoz/TraceOperator
type: string
DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpec:
properties:
kind:
enum:
- signoz/AIBuilderQuery
type: string
spec:
$ref: '#/components/schemas/DashboardtypesAIBuilderQuerySpec'
required:
- kind
- spec
type: object
DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpec:
properties:
kind:

View File

@@ -191,7 +191,7 @@ A standalone service only has the `factory.Service` lifecycle i.e it does not se
// ... dependencies ...
) user.Service {
return &service{
settings: factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/user"),
settings: factory.NewScopedProviderSettings(providerSettings, "go.signoz.io/pkg/modules/user"),
// ... dependencies ...
stopC: make(chan struct{}),
}

View File

@@ -614,6 +614,18 @@ export const listViewInitialLogQuery: Query = {
},
};
export const PANEL_TYPES_INITIAL_QUERY: Record<PANEL_TYPES, Query> = {
[PANEL_TYPES.TIME_SERIES]: initialQueriesMap.metrics,
[PANEL_TYPES.VALUE]: initialQueriesMap.metrics,
[PANEL_TYPES.TABLE]: initialQueriesMap.metrics,
[PANEL_TYPES.LIST]: listViewInitialLogQuery,
[PANEL_TYPES.TRACE]: initialQueriesMap.traces,
[PANEL_TYPES.BAR]: initialQueriesMap.metrics,
[PANEL_TYPES.PIE]: initialQueriesMap.metrics,
[PANEL_TYPES.HISTOGRAM]: initialQueriesMap.metrics,
[PANEL_TYPES.EMPTY_WIDGET]: initialQueriesMap.metrics,
};
export const listViewInitialTraceQuery: Query = {
// it should be the above commented query
...initialQueriesMap.traces,

View File

@@ -426,30 +426,6 @@ describe('resolvePanelContextLinks', () => {
expect(resolved[0].url).toBe('https://wiki/{{_service.name}}');
});
it('carries targetBlank through, defaulting to true when unset', () => {
const resolved = resolvePanelContextLinks(
[
{ name: 'Same tab', url: 'https://wiki/a', targetBlank: false },
{ name: 'New tab', url: 'https://wiki/b', targetBlank: true },
{ name: 'Unset', url: 'https://wiki/c' },
{
name: 'Literal',
url: 'https://wiki/d',
targetBlank: false,
renderVariables: false,
},
],
{},
);
expect(resolved.map((link) => link.targetBlank)).toStrictEqual([
false,
true,
true,
false,
]);
});
});
describe('stepClickTimeRange', () => {

View File

@@ -8,8 +8,6 @@ export interface ResolvedDrilldownLink {
id: string;
label: string;
url: string;
/** Opens in a new tab; links saved before the toggle existed default to true. */
targetBlank: boolean;
}
/**
@@ -28,16 +26,14 @@ export function resolvePanelContextLinks(
return usable.map((link, index) => {
const rawLabel = link.name || link.url || '';
const rawUrl = link.url ?? '';
const targetBlank = link.targetBlank ?? true;
// Only an explicit `false` opts out; undefined defaults to substitution on.
if (link.renderVariables === false) {
return { id: String(index), label: rawLabel, url: rawUrl, targetBlank };
return { id: String(index), label: rawLabel, url: rawUrl };
}
return {
id: String(index),
label: resolveTexts({ texts: [rawLabel], processedVariables }).fullTexts[0],
url: resolveContextLinkUrl(rawUrl, processedVariables),
targetBlank,
};
});
}

View File

@@ -173,7 +173,7 @@ function DrilldownAggregateMenu({
void logEvent(DashboardDetailEvents.DrilldownAction, {
action: 'contextLink',
});
openInNewTab(link.url, !!link.targetBlank);
openInNewTab(link.url);
onClose();
}}
>

View File

@@ -767,15 +767,10 @@ export function QueryBuilderProvider({
queryItem.dataSource
].builder.queryData;
// `dataSource` travels with the panel type's fields, but is appended to a
// copy: `propsRequired` is the list held in
// `panelTypeDataSourceFormValuesMap`, and pushing onto it grew that
// module-level array by one entry on every call.
if (propsRequired) {
[...propsRequired, 'dataSource'].forEach((p: any) => {
set(queryItem, p, get(newQueryItem, p));
});
}
propsRequired?.push('dataSource');
propsRequired?.forEach((p: any) => {
set(queryItem, p, get(newQueryItem, p));
});
return queryItem;
}

View File

@@ -211,11 +211,13 @@ export enum QueryFunctionsTypes {
FILL_ZERO = 'fillZero',
}
/**
* Key names of {@link PANEL_TYPES}. Derived rather than listed: the hand-written
* version had fallen behind the enum by three members (`BAR`, `PIE`, `HISTOGRAM`).
*/
export type PanelTypeKeys = keyof typeof PANEL_TYPES;
export type PanelTypeKeys =
| 'TIME_SERIES'
| 'VALUE'
| 'TABLE'
| 'LIST'
| 'TRACE'
| 'EMPTY_WIDGET';
export enum ReduceOperators {
LAST = 'last',

View File

@@ -1,9 +1,5 @@
import { withBasePath } from 'utils/basePath';
export const openInNewTab = (path: string, newTab = true): void => {
if (newTab) {
window.open(withBasePath(path), '_blank');
} else {
window.location.assign(withBasePath(path));
}
export const openInNewTab = (path: string): void => {
window.open(withBasePath(path), '_blank');
};

View File

@@ -59,10 +59,6 @@ func newConfig() factory.Config {
}
func (c Config) Validate() error {
if err := c.Config.Validate(); err != nil {
return err
}
if c.Address == "" {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "apiserver.address is required")
}

View File

@@ -16,10 +16,6 @@ import (
func TestNewWithEnvProvider(t *testing.T) {
t.Setenv("SIGNOZ_APISERVER_ADDRESS", "0.0.0.0:9090")
t.Setenv("SIGNOZ_APISERVER_READ__TIMEOUT", "80s")
t.Setenv("SIGNOZ_APISERVER_TLS_ENABLED", "true")
t.Setenv("SIGNOZ_APISERVER_TLS_CERT__FILE", "/etc/signoz/server.crt")
t.Setenv("SIGNOZ_APISERVER_TLS_KEY__FILE", "/etc/signoz/server.key")
t.Setenv("SIGNOZ_APISERVER_TLS_MIN__VERSION", "1.3")
t.Setenv("SIGNOZ_APISERVER_TIMEOUT_DEFAULT", "70s")
t.Setenv("SIGNOZ_APISERVER_TIMEOUT_MAX", "700s")
t.Setenv("SIGNOZ_APISERVER_TIMEOUT_EXCLUDED__ROUTES", "/excluded1,/excluded2")
@@ -48,12 +44,6 @@ func TestNewWithEnvProvider(t *testing.T) {
Config: httpserver.Config{
Address: "0.0.0.0:9090",
ReadTimeout: 80 * time.Second,
TLS: httpserver.TLS{
Enabled: true,
CertFile: "/etc/signoz/server.crt",
KeyFile: "/etc/signoz/server.key",
MinVersion: "1.3",
},
},
Timeout: Timeout{
Default: 70 * time.Second,

View File

@@ -1,19 +1,10 @@
package server
import (
"crypto/tls"
"time"
"github.com/SigNoz/signoz/pkg/errors"
)
var tlsVersions = map[string]uint16{
"1.2": tls.VersionTLS12,
"1.3": tls.VersionTLS13,
}
import "time"
// Config holds the configuration for http.
type Config struct {
// Address specifies the TCP address for the server to listen on, in the form "host:port".
//Address specifies the TCP address for the server to listen on, in the form "host:port".
// If empty, ":http" (port 80) is used. The service names are defined in RFC 6335 and assigned by IANA.
// See net.Dial for details of the address format.
Address string `mapstructure:"address"`
@@ -24,66 +15,4 @@ type Config struct {
// WriteTimeout bounds writing the response. Zero means no timeout, required for
// streaming endpoints that hold the connection open.
WriteTimeout time.Duration `mapstructure:"write_timeout"`
TLS TLS `mapstructure:"tls"`
}
type TLS struct {
Enabled bool `mapstructure:"enabled"`
// The full path to the certificate file.
CertFile string `mapstructure:"cert_file"`
// The full path to the key file.
KeyFile string `mapstructure:"key_file"`
// MinVersion is the minimum acceptable TLS version, "1.2" or "1.3". Empty uses the Go default.
MinVersion string `mapstructure:"min_version"`
}
func (c Config) Validate() error {
if !c.TLS.Enabled {
return nil
}
if c.TLS.CertFile == "" || c.TLS.KeyFile == "" {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "tls::cert_file and tls::key_file are required when tls is enabled")
}
_, err := tlsVersion(c.TLS.MinVersion)
if err != nil {
return err
}
return nil
}
func (tlsConfig TLS) Config() (*tls.Config, error) {
cert, err := tls.LoadX509KeyPair(tlsConfig.CertFile, tlsConfig.KeyFile)
if err != nil {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "cannot load tls::cert_file and tls::key_file: %v", err)
}
minVersion, err := tlsVersion(tlsConfig.MinVersion)
if err != nil {
return nil, err
}
return &tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: minVersion,
}, nil
}
func tlsVersion(name string) (uint16, error) {
if name == "" {
return 0, nil
}
version, ok := tlsVersions[name]
if !ok {
return 0, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid tls version %q, must be \"1.2\" or \"1.3\"", name)
}
return version, nil
}

View File

@@ -28,10 +28,6 @@ func New(logger *slog.Logger, cfg Config, handler http.Handler) (*Server, error)
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "cannot build http server, logger is required")
}
if err := cfg.Validate(); err != nil {
return nil, err
}
srv := &http.Server{
Addr: cfg.Address,
Handler: handler,
@@ -40,18 +36,9 @@ func New(logger *slog.Logger, cfg Config, handler http.Handler) (*Server, error)
MaxHeaderBytes: 1 << 20,
}
if cfg.TLS.Enabled {
tlsConfig, err := cfg.TLS.Config()
if err != nil {
return nil, err
}
srv.TLSConfig = tlsConfig
}
return &Server{
srv: srv,
logger: logger.With(slog.String("pkg", "github.com/SigNoz/signoz/pkg/http/server")),
logger: logger.With(slog.String("pkg", "go.signoz.io/pkg/http/server")),
handler: handler,
cfg: cfg,
}, nil
@@ -59,18 +46,11 @@ func New(logger *slog.Logger, cfg Config, handler http.Handler) (*Server, error)
func (server *Server) Start(ctx context.Context) error {
server.logger.InfoContext(ctx, "starting http server", slog.String("address", server.srv.Addr))
var err error
if server.cfg.TLS.Enabled {
// The certificate is already loaded in TLSConfig, so ListenAndServeTLS needs no file paths.
err = server.srv.ListenAndServeTLS("", "")
} else {
err = server.srv.ListenAndServe()
}
if err != nil && err != http.ErrServerClosed {
server.logger.ErrorContext(ctx, "failed to start server", errors.Attr(err))
return err
if err := server.srv.ListenAndServe(); err != nil {
if err != http.ErrServerClosed {
server.logger.ErrorContext(ctx, "failed to start server", errors.Attr(err))
return err
}
}
return nil
}

View File

@@ -1,243 +0,0 @@
package server
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"io"
"log/slog"
"math/big"
"net"
"net/http"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNew(t *testing.T) {
logger := slog.New(slog.DiscardHandler)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
certFile, keyFile := writeSelfSignedCert(t)
corruptFile := filepath.Join(t.TempDir(), "corrupt.crt")
require.NoError(t, os.WriteFile(corruptFile, []byte("not a pem"), 0o644))
testCases := []struct {
name string
config Config
err bool
minVersion uint16
}{
{
name: "TLSDisabled",
config: Config{},
},
{
name: "TLSDisabled_WithCertAndKey",
config: Config{TLS: TLS{CertFile: "ignored.crt", KeyFile: "ignored.key"}},
},
{
name: "TLSEnabled_WithoutCertAndKey",
config: Config{TLS: TLS{Enabled: true}},
err: true,
},
{
name: "TLSEnabled_WithoutKey",
config: Config{TLS: TLS{Enabled: true, CertFile: "server.crt"}},
err: true,
},
{
name: "TLSEnabled_WithoutCert",
config: Config{TLS: TLS{Enabled: true, KeyFile: "server.key"}},
err: true,
},
{
name: "TLSEnabled_InvalidMinVersion",
config: Config{TLS: TLS{Enabled: true, CertFile: "tls.crt", KeyFile: "tls.key", MinVersion: "1.1"}},
err: true,
},
{
name: "TLSEnabled_MissingFiles",
config: Config{TLS: TLS{Enabled: true, CertFile: "missing.crt", KeyFile: "missing.key"}},
err: true,
},
{
name: "TLSEnabled_CorruptCertFile",
config: Config{TLS: TLS{Enabled: true, CertFile: corruptFile, KeyFile: keyFile}},
err: true,
},
{
name: "TLSEnabled_DefaultVersions",
config: Config{TLS: TLS{Enabled: true, CertFile: certFile, KeyFile: keyFile}},
},
{
name: "TLSEnabled_WithMin",
config: Config{TLS: TLS{Enabled: true, CertFile: certFile, KeyFile: keyFile, MinVersion: "1.3"}},
minVersion: tls.VersionTLS13,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
server, err := New(logger, testCase.config, handler)
if testCase.err {
assert.Error(t, err)
return
}
require.NoError(t, err)
if !testCase.config.TLS.Enabled {
assert.Nil(t, server.srv.TLSConfig)
return
}
require.NotNil(t, server.srv.TLSConfig)
assert.Len(t, server.srv.TLSConfig.Certificates, 1)
assert.Equal(t, testCase.minVersion, server.srv.TLSConfig.MinVersion)
})
}
}
func TestStartWithTLS(t *testing.T) {
certFile, keyFile := writeSelfSignedCert(t)
addr := freeAddr(t)
server, err := New(
slog.New(slog.DiscardHandler),
Config{Address: addr, TLS: TLS{Enabled: true, CertFile: certFile, KeyFile: keyFile}},
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("ok")) }),
)
require.NoError(t, err)
errC := make(chan error, 1)
go func() { errC <- server.Start(context.Background()) }()
certPEM, err := os.ReadFile(certFile)
require.NoError(t, err)
pool := x509.NewCertPool()
require.True(t, pool.AppendCertsFromPEM(certPEM))
client := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{RootCAs: pool}}}
var (
statusCode int
body []byte
tlsVersion uint16
)
require.Eventually(t, func() bool {
resp, err := client.Get("https://" + addr)
if err != nil {
return false
}
defer func() { _ = resp.Body.Close() }()
body, err = io.ReadAll(resp.Body)
if err != nil {
return false
}
statusCode = resp.StatusCode
if resp.TLS != nil {
tlsVersion = resp.TLS.Version
}
return true
}, 5*time.Second, 25*time.Millisecond)
assert.Equal(t, http.StatusOK, statusCode)
assert.Equal(t, "ok", string(body))
assert.GreaterOrEqual(t, tlsVersion, uint16(tls.VersionTLS12))
plainResp, err := http.Get("http://" + addr)
require.NoError(t, err)
_ = plainResp.Body.Close()
assert.Equal(t, http.StatusBadRequest, plainResp.StatusCode)
require.NoError(t, server.Stop(context.Background()))
require.NoError(t, <-errC)
}
func TestStartWithoutTLS(t *testing.T) {
addr := freeAddr(t)
server, err := New(
slog.New(slog.DiscardHandler),
Config{Address: addr},
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("pong")) }),
)
require.NoError(t, err)
errC := make(chan error, 1)
go func() { errC <- server.Start(context.Background()) }()
var (
statusCode int
tlsNegotiated bool
)
require.Eventually(t, func() bool {
resp, err := http.Get("http://" + addr)
if err != nil {
return false
}
defer func() { _ = resp.Body.Close() }()
statusCode = resp.StatusCode
tlsNegotiated = resp.TLS != nil
return true
}, 5*time.Second, 25*time.Millisecond)
assert.Equal(t, http.StatusOK, statusCode)
assert.False(t, tlsNegotiated)
require.NoError(t, server.Stop(context.Background()))
require.NoError(t, <-errC)
}
func freeAddr(t *testing.T) string {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
defer func() { _ = listener.Close() }()
return listener.Addr().String()
}
func writeSelfSignedCert(t *testing.T) (string, string) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "localhost"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
}
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
require.NoError(t, err)
keyDER, err := x509.MarshalECPrivateKey(key)
require.NoError(t, err)
dir := t.TempDir()
certFile := filepath.Join(dir, "server.crt")
keyFile := filepath.Join(dir, "server.key")
require.NoError(t, os.WriteFile(certFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o644))
require.NoError(t, os.WriteFile(keyFile, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}), 0o600))
return certFile, keyFile
}

View File

@@ -141,6 +141,7 @@ func (d *DashboardSpec) validateQuery(qi int, q Query, panelKind PanelPluginKind
func validateQueryAllowedForPanel(plugin QueryPlugin, allowed []QueryPluginKind, panelKind PanelPluginKind, path string) error {
compositeSubQueryTypeToPluginKind := map[qb.QueryType]QueryPluginKind{
qb.QueryTypeBuilder: QueryKindBuilder,
qb.QueryTypeBuilderAI: QueryKindAIBuilder,
qb.QueryTypeFormula: QueryKindFormula,
qb.QueryTypeTraceOperator: QueryKindTraceOperator,
qb.QueryTypePromQL: QueryKindPromQL,

View File

@@ -117,6 +117,22 @@ func TestNewStatsFromStorableDashboardsCountsCompositeSubQueries(t *testing.T) {
assert.Equal(t, int64(1), stats[statKeyPanelLogsCount])
}
// An AI builder query is always a traces query, so it counts towards traces.
func TestNewStatsFromStorableDashboardsCountsAIBuilderQueries(t *testing.T) {
aiBuilder := `{
"kind": "time_series",
"spec": {"plugin": {"kind": "signoz/AIBuilderQuery", "spec": {"name": "A", "aggregations": [{"expression": "count()"}]}}}
}`
dashboard := newStatsStorableV2(t, `"p1": `+statsPanel(aiBuilder))
stats := NewStatsFromStorableDashboards([]*StorableDashboard{dashboard})
assert.Equal(t, int64(1), stats[statKeyPanelCount])
assert.Equal(t, int64(1), stats[statKeyPanelTracesCount])
assert.Equal(t, int64(0), stats[statKeyPanelMetricsCount])
assert.Equal(t, int64(0), stats[statKeyPanelLogsCount])
}
// promql and clickhouse queries carry no signal, so they land in the panel total
// and nowhere else.
func TestNewStatsFromStorableDashboardsIgnoresSignallessQueries(t *testing.T) {

View File

@@ -1638,6 +1638,8 @@ func TestPanelTypeQueryTypeCompatibility(t *testing.T) {
{"TimeSeries+PromQL", mkQuery("signoz/TimeSeriesPanel", "signoz/PromQLQuery", `{"name":"A","query":"up"}`), false},
{"Table+ClickHouse", mkQuery("signoz/TablePanel", "signoz/ClickHouseSQL", `{"name":"A","query":"SELECT 1"}`), false},
{"List+Builder", mkQuery("signoz/ListPanel", "signoz/BuilderQuery", `{"name":"A","signal":"logs"}`), false},
{"TimeSeries+AIBuilder", mkQuery("signoz/TimeSeriesPanel", "signoz/AIBuilderQuery", `{"name":"A","aggregations":[{"expression":"count()"}]}`), false},
{"List+AIBuilder", mkQuery("signoz/ListPanel", "signoz/AIBuilderQuery", `{"name":"A"}`), false},
// Top-level: rejected
{"Table+PromQL", mkQuery("signoz/TablePanel", "signoz/PromQLQuery", `{"name":"A","query":"up"}`), true},
{"List+ClickHouse", mkQuery("signoz/ListPanel", "signoz/ClickHouseSQL", `{"name":"A","query":"SELECT 1"}`), true},
@@ -1647,6 +1649,7 @@ func TestPanelTypeQueryTypeCompatibility(t *testing.T) {
// Composite sub-queries
{"Table+Composite(promql)", mkComposite("signoz/TablePanel", "promql", `{"name":"A","query":"up"}`), true},
{"Table+Composite(clickhouse)", mkComposite("signoz/TablePanel", "clickhouse_sql", `{"name":"A","query":"SELECT 1"}`), false},
{"Table+Composite(builder_ai)", mkComposite("signoz/TablePanel", "builder_ai_query", `{"name":"A","aggregations":[{"expression":"count()"}]}`), false},
}
for _, tc := range cases {

View File

@@ -89,6 +89,7 @@ type QueryPlugin struct {
func (QueryPlugin) PrepareJSONSchema(s *jsonschema.Schema) error {
return markDiscriminator(s, "kind", map[string]string{
string(QueryKindBuilder): schemaRef("DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpec"),
string(QueryKindAIBuilder): schemaRef("DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpec"),
string(QueryKindComposite): schemaRef("DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQuery"),
string(QueryKindFormula): schemaRef("DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5QueryBuilderFormula"),
string(QueryKindPromQL): schemaRef("DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5PromQuery"),
@@ -118,6 +119,7 @@ func (p *QueryPlugin) UnmarshalJSON(data []byte) error {
func (QueryPlugin) JSONSchemaOneOf() []any {
return []any{
QueryPluginVariant[BuilderQuerySpec]{Kind: string(QueryKindBuilder)},
QueryPluginVariant[AIBuilderQuerySpec]{Kind: string(QueryKindAIBuilder)},
QueryPluginVariant[CompositeQuerySpec]{Kind: string(QueryKindComposite)},
QueryPluginVariant[FormulaSpec]{Kind: string(QueryKindFormula)},
QueryPluginVariant[PromQLQuerySpec]{Kind: string(QueryKindPromQL)},
@@ -138,6 +140,11 @@ func (plugin QueryPlugin) buildV5CompositeQueryFromPlugin() (qb.CompositeQuery,
return qb.CompositeQuery{}, errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardInvalidWidgetQuery, "builder query is empty")
}
return wrapEnvelope(qb.QueryTypeBuilder, spec.Spec), nil
case *AIBuilderQuerySpec:
if spec == nil {
return qb.CompositeQuery{}, errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardInvalidWidgetQuery, "AI builder query is empty")
}
return wrapEnvelope(qb.QueryTypeBuilderAI, qb.QueryBuilderQuery[qb.TraceAggregation](*spec)), nil
case *qb.PromQuery:
return wrapEnvelope(qb.QueryTypePromQL, *spec), nil
case *qb.ClickHouseQuery:
@@ -231,6 +238,7 @@ var (
}
queryPluginSpecs = map[QueryPluginKind]func() any{
QueryKindBuilder: func() any { return new(BuilderQuerySpec) },
QueryKindAIBuilder: func() any { return new(AIBuilderQuerySpec) },
QueryKindComposite: func() any { return new(CompositeQuerySpec) },
QueryKindFormula: func() any { return new(FormulaSpec) },
QueryKindPromQL: func() any { return new(PromQLQuerySpec) },
@@ -243,13 +251,13 @@ var (
VariableKindCustom: func() any { return new(CustomVariableSpec) },
}
allowedQueryKinds = map[PanelPluginKind][]QueryPluginKind{
PanelKindTimeSeries: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindBarChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindNumber: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindHistogram: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindPieChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
PanelKindTable: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
PanelKindList: {QueryKindBuilder},
PanelKindTimeSeries: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindBarChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindNumber: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindHistogram: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindPieChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
PanelKindTable: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
PanelKindList: {QueryKindBuilder, QueryKindAIBuilder},
}
)

View File

@@ -106,6 +106,13 @@ func redactQuery(spec any) any {
return spec
}
return &BuilderQuerySpec{Spec: redactLeafQuery(s.Spec)}
case *AIBuilderQuerySpec:
if s == nil {
return spec
}
redacted := redactLeafQuery(qb.QueryBuilderQuery[qb.TraceAggregation](*s)).(qb.QueryBuilderQuery[qb.TraceAggregation])
out := AIBuilderQuerySpec(redacted)
return &out
case *qb.PromQuery:
return redactQueryPtr(s)
case *qb.ClickHouseQuery:

View File

@@ -159,6 +159,11 @@ func TestDashboardV2GetPanelQuery(t *testing.T) {
plugin QueryPlugin
expectedType qb.QueryType
}{
{
description: "AI builder query",
plugin: QueryPlugin{Kind: QueryKindAIBuilder, Spec: &AIBuilderQuerySpec{Name: "A"}},
expectedType: qb.QueryTypeBuilderAI,
},
{
description: "promql",
plugin: QueryPlugin{Kind: QueryKindPromQL, Spec: &qb.PromQuery{Name: "A", Query: "up"}},

View File

@@ -133,6 +133,19 @@ func TestRedactQueryPluginWrappers(t *testing.T) {
assert.Equal(t, "A", builder.Name)
})
t.Run("AI builder plugin pointer is redacted and stays a pointer", func(t *testing.T) {
plugin := &AIBuilderQuerySpec{
Name: "A",
Filter: &qb.Filter{Expression: "body contains 'secret'"},
}
result, ok := redactQuery(plugin).(*AIBuilderQuerySpec)
require.True(t, ok)
assert.Nil(t, result.Filter)
assert.Equal(t, "A", result.Name)
})
t.Run("composite plugin redacts every sub-query envelope", func(t *testing.T) {
composite := &qb.CompositeQuery{Queries: []qb.QueryEnvelope{
{Type: qb.QueryTypeBuilder, Spec: qb.QueryBuilderQuery[qb.MetricAggregation]{Name: "A", Filter: &qb.Filter{Expression: "x = 1"}}},

View File

@@ -93,6 +93,7 @@ type QueryPluginKind string
const (
QueryKindBuilder QueryPluginKind = "signoz/BuilderQuery"
QueryKindAIBuilder QueryPluginKind = "signoz/AIBuilderQuery"
QueryKindComposite QueryPluginKind = "signoz/CompositeQuery"
QueryKindFormula QueryPluginKind = "signoz/Formula"
QueryKindPromQL QueryPluginKind = "signoz/PromQLQuery"
@@ -101,7 +102,7 @@ const (
)
func (QueryPluginKind) Enum() []any {
return []any{QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindPromQL, QueryKindClickHouseSQL, QueryKindTraceOperator}
return []any{QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindPromQL, QueryKindClickHouseSQL, QueryKindTraceOperator}
}
type (
@@ -159,6 +160,26 @@ func (BuilderQuerySpec) JSONSchemaOneOf() []any {
}
}
// AIBuilderQuerySpec is the spec of a signoz/AIBuilderQuery plugin: a gen_ai-scoped
// (AI observability) traces builder query, executed as qb.QueryTypeBuilderAI. The
// signal is implied by the kind and pinned to traces, mirroring the builder_ai_query
// QueryEnvelope decode.
type AIBuilderQuerySpec qb.QueryBuilderQuery[qb.TraceAggregation]
func (b *AIBuilderQuerySpec) UnmarshalJSON(data []byte) error {
var spec qb.QueryBuilderQuery[qb.TraceAggregation]
if err := json.Unmarshal(data, &spec); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid AI builder query spec")
}
spec.Signal = telemetrytypes.SignalTraces
*b = AIBuilderQuerySpec(spec)
return nil
}
func (AIBuilderQuerySpec) PrepareJSONSchema(s *jsonschema.Schema) error {
return (qb.QueryBuilderQuery[qb.TraceAggregation]{}).PrepareJSONSchema(s)
}
// ══════════════════════════════════════════════
// SigNoz panel plugin specs
// ══════════════════════════════════════════════

View File

@@ -0,0 +1,75 @@
package dashboardtypes
import (
"encoding/json"
"testing"
"github.com/SigNoz/signoz/pkg/errors"
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// An AI builder query is a gen_ai-scoped traces builder query: the signal is
// implied by the plugin kind and pinned to traces on decode, mirroring the
// builder_ai_query QueryEnvelope.
func TestAIBuilderQueryPluginRoundTrip(t *testing.T) {
data := []byte(`{
"variables": [],
"panels": {"p1": {"kind": "Panel", "spec": {
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/AIBuilderQuery", "spec": {
"name": "A", "aggregations": [{"expression": "count()"}]
}}}}]
}}},
"links": [],
"layouts": []
}`)
spec, err := unmarshalDashboard(data)
require.NoError(t, err)
plugin := spec.Panels["p1"].Spec.Queries[0].Spec.Plugin
assert.Equal(t, QueryKindAIBuilder, plugin.Kind)
aiSpec, ok := plugin.Spec.(*AIBuilderQuerySpec)
require.True(t, ok, "expected *AIBuilderQuerySpec, got %T", plugin.Spec)
assert.Equal(t, "A", aiSpec.Name)
assert.Equal(t, telemetrytypes.SignalTraces, aiSpec.Signal)
// Marshal emits the pinned signal and decodes back to the same plugin.
out, err := json.Marshal(plugin)
require.NoError(t, err)
assert.Contains(t, string(out), `"kind":"signoz/AIBuilderQuery"`)
assert.Contains(t, string(out), `"signal":"traces"`)
var roundTripped QueryPlugin
require.NoError(t, json.Unmarshal(out, &roundTripped))
assert.Equal(t, plugin, roundTripped)
}
// At query-range time an AI builder query wraps into a single builder_ai_query
// envelope so the querier routes it to the gen_ai statement builder.
func TestAIBuilderQueryPluginBuildsBuilderAIEnvelope(t *testing.T) {
plugin := QueryPlugin{Kind: QueryKindAIBuilder, Spec: &AIBuilderQuerySpec{Name: "A", Signal: telemetrytypes.SignalTraces}}
composite, err := plugin.buildV5CompositeQueryFromPlugin()
require.NoError(t, err)
require.Len(t, composite.Queries, 1)
assert.Equal(t, qb.QueryTypeBuilderAI, composite.Queries[0].Type)
spec, ok := composite.Queries[0].Spec.(qb.QueryBuilderQuery[qb.TraceAggregation])
require.True(t, ok, "expected traces builder query, got %T", composite.Queries[0].Spec)
assert.Equal(t, "A", spec.Name)
assert.Equal(t, telemetrytypes.SignalTraces, spec.Signal)
}
func TestAIBuilderQueryPluginNilSpec(t *testing.T) {
plugin := QueryPlugin{Kind: QueryKindAIBuilder, Spec: (*AIBuilderQuerySpec)(nil)}
_, err := plugin.buildV5CompositeQueryFromPlugin()
require.Error(t, err)
assert.True(t, errors.Ast(err, errors.TypeInvalidInput))
}