mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-10 13:30:42 +01:00
Compare commits
1 Commits
main
...
nv/dashboa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67878a01b2 |
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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{}),
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -173,7 +173,7 @@ function DrilldownAggregateMenu({
|
||||
void logEvent(DashboardDetailEvents.DrilldownAction, {
|
||||
action: 'contextLink',
|
||||
});
|
||||
openInNewTab(link.url, !!link.targetBlank);
|
||||
openInNewTab(link.url);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
};
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"}},
|
||||
|
||||
@@ -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"}}},
|
||||
|
||||
@@ -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
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
75
pkg/types/dashboardtypes/perses_signoz_plugins_test.go
Normal file
75
pkg/types/dashboardtypes/perses_signoz_plugins_test.go
Normal 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))
|
||||
}
|
||||
Reference in New Issue
Block a user