Compare commits

..

5 Commits

Author SHA1 Message Date
nityanandagohain
435471a18d fix: address comments 2026-08-19 19:17:04 +05:30
nityanandagohain
816905f4cf fix: remove root user 2026-08-19 14:48:38 +05:30
nityanandagohain
691f724480 fix: more cleanup 2026-08-19 14:12:09 +05:30
nityanandagohain
e04f26f5b7 fix: remove update endpoint 2026-08-19 12:33:21 +05:30
nityanandagohain
4a72aab47c feat: system dashboards 2026-08-19 12:19:10 +05:30
47 changed files with 1461 additions and 647 deletions

View File

@@ -23055,6 +23055,73 @@ paths:
summary: Rotate session
tags:
- sessions
/api/v2/system/dashboards/{name}:
get:
deprecated: false
description: Returns a dashboard SigNoz ships and owns, addressed by its stable
definition name (e.g. `ai-o11y-overview`) rather than its id. System dashboards
are read-only and upgraded through releases. The dashboard's own `name` field
carries a reserved prefix that the path segment must not include.
operationId: GetSystemDashboard
parameters:
- in: path
name: name
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/DashboardtypesGettableDashboardV2'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- dashboard:read
- tokenizer:
- dashboard:read
summary: Get system dashboard
tags:
- dashboard
/api/v2/user_roles:
post:
deprecated: false

View File

@@ -276,6 +276,10 @@ func (module *module) GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UU
return module.pkgDashboardModule.GetV2(ctx, orgID, id)
}
func (module *module) GetByNameV2(ctx context.Context, orgID valuer.UUID, name string) (*dashboardtypes.DashboardV2, error) {
return module.pkgDashboardModule.GetByNameV2(ctx, orgID, name)
}
func (module *module) MigrateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error) {
return module.pkgDashboardModule.MigrateV2(ctx, orgID, id)
}
@@ -284,6 +288,10 @@ func (module *module) UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer
return module.pkgDashboardModule.UpdateV2(ctx, orgID, id, updatedBy, updatable)
}
func (module *module) UpdateUnsafeV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updatable dashboardtypes.UpdatableDashboardV2) (*dashboardtypes.DashboardV2, error) {
return module.pkgDashboardModule.UpdateUnsafeV2(ctx, orgID, id, updatedBy, updatable)
}
func (module *module) PatchV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, patch dashboardtypes.PatchableDashboardV2) (*dashboardtypes.DashboardV2, error) {
return module.pkgDashboardModule.PatchV2(ctx, orgID, id, updatedBy, patch)
}

View File

@@ -46,6 +46,8 @@ import type {
GetPublicDashboardPathParameters,
GetPublicDashboardWidgetQueryRange200,
GetPublicDashboardWidgetQueryRangePathParameters,
GetSystemDashboard200,
GetSystemDashboardPathParameters,
ListDashboardViews200,
ListDashboardsForUserV2200,
ListDashboardsForUserV2Params,
@@ -2111,6 +2113,108 @@ export const invalidateGetPublicDashboardPanelQueryRangeV2 = async (
return queryClient;
};
/**
* Returns a dashboard SigNoz ships and owns, addressed by its stable definition name (e.g. `ai-o11y-overview`) rather than its id. System dashboards are read-only and upgraded through releases. The dashboard's own `name` field carries a reserved prefix that the path segment must not include.
* @summary Get system dashboard
*/
export const getSystemDashboard = (
{ name }: GetSystemDashboardPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetSystemDashboard200>({
url: `/api/v2/system/dashboards/${name}`,
method: 'GET',
signal,
});
};
export const getGetSystemDashboardQueryKey = ({
name,
}: GetSystemDashboardPathParameters) => {
return [`/api/v2/system/dashboards/${name}`] as const;
};
export const getGetSystemDashboardQueryOptions = <
TData = Awaited<ReturnType<typeof getSystemDashboard>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ name }: GetSystemDashboardPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getSystemDashboard>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetSystemDashboardQueryKey({ name });
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getSystemDashboard>>
> = ({ signal }) => getSystemDashboard({ name }, signal);
return {
queryKey,
queryFn,
enabled: !!name,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getSystemDashboard>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetSystemDashboardQueryResult = NonNullable<
Awaited<ReturnType<typeof getSystemDashboard>>
>;
export type GetSystemDashboardQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get system dashboard
*/
export function useGetSystemDashboard<
TData = Awaited<ReturnType<typeof getSystemDashboard>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ name }: GetSystemDashboardPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getSystemDashboard>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetSystemDashboardQueryOptions({ name }, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get system dashboard
*/
export const invalidateGetSystemDashboard = async (
queryClient: QueryClient,
{ name }: GetSystemDashboardPathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetSystemDashboardQueryKey({ name }) },
options,
);
return queryClient;
};
/**
* Same as ListDashboardsV2 but personalized for the calling user: each dashboard carries the caller's `pinned` state, and pinned dashboards float to the top of the requested ordering. Supports the same filter DSL, sort, order, and pagination.
* @summary List dashboards for the current user (v2)

View File

@@ -12271,6 +12271,17 @@ export type RotateSession200 = {
status: string;
};
export type GetSystemDashboardPathParameters = {
name: string;
};
export type GetSystemDashboard200 = {
data: DashboardtypesGettableDashboardV2DTO;
/**
* @type string
*/
status: string;
};
export type CreateUserRole201 = {
data: TypesIdentifiableDTO;
/**

View File

@@ -30,6 +30,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
"github.com/SigNoz/signoz/pkg/modules/session"
"github.com/SigNoz/signoz/pkg/modules/spanmapper"
"github.com/SigNoz/signoz/pkg/modules/systemdashboard"
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
"github.com/SigNoz/signoz/pkg/modules/user"
"github.com/SigNoz/signoz/pkg/querier"
@@ -79,6 +80,8 @@ type provider struct {
llmPricingRuleHandler llmpricingrule.Handler
statsHandler statsreporter.Handler
savedViewHandler savedview.Handler
systemDashboardModule systemdashboard.Module
systemDashboardHandler systemdashboard.Handler
}
func NewFactory(
@@ -116,6 +119,8 @@ func NewFactory(
rulerHandler ruler.Handler,
statsHandler statsreporter.Handler,
savedViewHandler savedview.Handler,
systemDashboardModule systemdashboard.Module,
systemDashboardHandler systemdashboard.Handler,
) factory.ProviderFactory[apiserver.APIServer, apiserver.Config] {
return factory.NewProviderFactory(factory.MustNewName("signoz"), func(ctx context.Context, providerSettings factory.ProviderSettings, config apiserver.Config) (apiserver.APIServer, error) {
return newProvider(
@@ -156,6 +161,8 @@ func NewFactory(
rulerHandler,
statsHandler,
savedViewHandler,
systemDashboardModule,
systemDashboardHandler,
)
})
}
@@ -198,6 +205,8 @@ func newProvider(
rulerHandler ruler.Handler,
statsHandler statsreporter.Handler,
savedViewHandler savedview.Handler,
systemDashboardModule systemdashboard.Module,
systemDashboardHandler systemdashboard.Handler,
) (apiserver.APIServer, error) {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/apiserver/signozapiserver")
router := mux.NewRouter().UseEncodedPath()
@@ -239,6 +248,8 @@ func newProvider(
llmPricingRuleHandler: llmPricingRuleHandler,
statsHandler: statsHandler,
savedViewHandler: savedViewHandler,
systemDashboardModule: systemDashboardModule,
systemDashboardHandler: systemDashboardHandler,
}
provider.authzMiddleware = middleware.NewAuthZ(settings.Logger(), orgGetter, authzService)
@@ -291,6 +302,10 @@ func (provider *provider) AddToRouter(router *mux.Router) error {
return err
}
if err := provider.addSystemDashboardRoutes(router); err != nil {
return err
}
if err := provider.addMetricsExplorerRoutes(router); err != nil {
return err
}

View File

@@ -0,0 +1,63 @@
package signozapiserver
import (
"net/http"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
)
func (provider *provider) addSystemDashboardRoutes(router *mux.Router) error {
if err := router.Handle("/api/v2/system/dashboards/{name}", handler.New(
provider.authzMiddleware.CheckResources(provider.systemDashboardHandler.Get, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "GetSystemDashboard",
Tags: []string{"dashboard"},
Summary: "Get system dashboard",
Description: "Returns a dashboard SigNoz ships and owns, addressed by its stable definition name (e.g. `ai-o11y-overview`) rather than its id. System dashboards are read-only and upgraded through releases. The dashboard's own `name` field carries a reserved prefix that the path segment must not include.",
Request: nil,
RequestContentType: "",
Response: new(dashboardtypes.GettableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbRead)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceDashboard,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
ID: provider.systemDashboardID(),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
return nil
}
// systemDashboardID resolves the {name} path param to the dashboard's id. Authz
// tuples and audit records are written against ids, so the name has to be
// resolved before either runs.
func (provider *provider) systemDashboardID() coretypes.ResourceIDExtractor {
return coretypes.NewResourceIDExtractor(coretypes.PhaseRequest, func(ec coretypes.ExtractorContext) (string, error) {
ctx := ec.Request.Context()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
return "", err
}
id, err := provider.systemDashboardModule.ResolveID(ctx, valuer.MustNewUUID(claims.OrgID), mux.Vars(ec.Request)["name"])
if err != nil {
return "", err
}
return id.StringValue(), nil
})
}

View File

@@ -63,6 +63,8 @@ type Module interface {
GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error)
GetByNameV2(ctx context.Context, orgID valuer.UUID, name string) (*dashboardtypes.DashboardV2, error)
// MigrateV2 retries the v1→v2 migration on a dashboard still stored in the v1 schema.
MigrateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error)
@@ -72,6 +74,9 @@ type Module interface {
UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updatable dashboardtypes.UpdatableDashboardV2) (*dashboardtypes.DashboardV2, error)
// UpdateUnsafeV2 updates a dashboard bypassing the guards. Intended for internal system callers.
UpdateUnsafeV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updatable dashboardtypes.UpdatableDashboardV2) (*dashboardtypes.DashboardV2, error)
LockUnlockV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error
PatchV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, patch dashboardtypes.PatchableDashboardV2) (*dashboardtypes.DashboardV2, error)

View File

@@ -64,6 +64,23 @@ func (store *store) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID)
return storableDashboard, nil
}
func (store *store) GetByName(ctx context.Context, orgID valuer.UUID, name string) (*dashboardtypes.StorableDashboard, error) {
storableDashboard := new(dashboardtypes.StorableDashboard)
err := store.
sqlstore.
BunDB().
NewSelect().
Model(storableDashboard).
Where("name = ?", name).
Where("org_id = ?", orgID).
Scan(ctx)
if err != nil {
return nil, store.sqlstore.WrapNotFoundErrf(err, errors.CodeNotFound, "dashboard with name %s doesn't exist", name)
}
return storableDashboard, nil
}
// ListForUser emits the joined dashboard ⨝ user_dashboard_preference query the
// spec calls for. Aliases:
//

View File

@@ -19,9 +19,12 @@ func (m *module) CreateV2(ctx context.Context, orgID valuer.UUID, createdBy stri
return nil, err
}
dashboard := postable.NewDashboardV2(orgID, createdBy, source)
dashboard, err := postable.NewDashboardV2(orgID, createdBy, source)
if err != nil {
return nil, err
}
err := m.store.RunInTx(ctx, func(ctx context.Context) error {
err = m.store.RunInTx(ctx, func(ctx context.Context) error {
resolvedTags, err := m.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, dashboard.ID, postable.Tags)
if err != nil {
return err
@@ -120,6 +123,20 @@ func (module *module) GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UU
return storable.ToDashboardV2(tags)
}
func (module *module) GetByNameV2(ctx context.Context, orgID valuer.UUID, name string) (*dashboardtypes.DashboardV2, error) {
storable, err := module.store.GetByName(ctx, orgID, name)
if err != nil {
return nil, err
}
tags, err := module.tagModule.ListForResource(ctx, orgID, coretypes.KindDashboard, storable.ID)
if err != nil {
return nil, err
}
return storable.ToDashboardV2(tags)
}
// MigrateV2 retries the v1→v2 migration on a dashboard still stored as v1 (one the
// bulk 103 migration skipped or failed). Idempotent: an already-v2 one is unchanged.
func (module *module) MigrateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error) {
@@ -179,13 +196,32 @@ func (module *module) UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer
return nil, err
}
err = module.store.RunInTx(ctx, func(ctx context.Context) error {
resolvedTags, err := module.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, id, updatable.Tags)
return module.updateV2(ctx, orgID, existing, updatedBy, updatable, existing.Update)
}
func (module *module) UpdateUnsafeV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updatable dashboardtypes.UpdatableDashboardV2) (*dashboardtypes.DashboardV2, error) {
if err := updatable.Validate(); err != nil {
return nil, err
}
existing, err := module.GetV2(ctx, orgID, id)
if err != nil {
return nil, err
}
return module.updateV2(ctx, orgID, existing, updatedBy, updatable, existing.UpdateUnsafe)
}
// apply is existing.Update or existing.UpdateUnsafe, so the gated path keeps its
// in-transaction checks and only UpdateUnsafeV2 skips them.
func (module *module) updateV2(ctx context.Context, orgID valuer.UUID, existing *dashboardtypes.DashboardV2, updatedBy string, updatable dashboardtypes.UpdatableDashboardV2, apply func(dashboardtypes.UpdatableDashboardV2, string, []*tagtypes.Tag) error) (*dashboardtypes.DashboardV2, error) {
err := module.store.RunInTx(ctx, func(ctx context.Context) error {
resolvedTags, err := module.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, existing.ID, updatable.Tags)
if err != nil {
return err
}
err = existing.Update(updatable, updatedBy, resolvedTags)
err = apply(updatable, updatedBy, resolvedTags)
if err != nil {
return err
}

View File

@@ -6,18 +6,20 @@ import (
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/quickfilter"
"github.com/SigNoz/signoz/pkg/modules/systemdashboard"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/valuer"
)
type setter struct {
store types.OrganizationStore
alertmanager alertmanager.Alertmanager
quickfilter quickfilter.Module
store types.OrganizationStore
alertmanager alertmanager.Alertmanager
quickfilter quickfilter.Module
systemDashboard systemdashboard.Module
}
func NewSetter(store types.OrganizationStore, alertmanager alertmanager.Alertmanager, quickfilter quickfilter.Module) organization.Setter {
return &setter{store: store, alertmanager: alertmanager, quickfilter: quickfilter}
func NewSetter(store types.OrganizationStore, alertmanager alertmanager.Alertmanager, quickfilter quickfilter.Module, systemDashboard systemdashboard.Module) organization.Setter {
return &setter{store: store, alertmanager: alertmanager, quickfilter: quickfilter, systemDashboard: systemDashboard}
}
func (module *setter) Create(ctx context.Context, organization *types.Organization, createManagedRoles func(context.Context, valuer.UUID) error) error {
@@ -37,6 +39,10 @@ func (module *setter) Create(ctx context.Context, organization *types.Organizati
return err
}
if err := module.systemDashboard.Reconcile(ctx, organization.ID); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,45 @@
package implsystemdashboard
import (
"embed"
"io/fs"
"path"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/systemdashboardtypes"
)
const definitionsRoot = "fs/definitions"
//go:embed fs/definitions/*.json
var definitionFiles embed.FS
// NewRegistry parses every embedded definition. Definitions are build-time assets
// validated by a test, so a failure here means the binary shipped broken JSON.
func NewRegistry() (systemdashboardtypes.Registry, error) {
entries, err := fs.ReadDir(definitionFiles, definitionsRoot)
if err != nil {
return systemdashboardtypes.Registry{}, errors.WrapInternalf(err, errors.CodeInternal, "couldn't read system dashboard definitions")
}
definitions := make([]systemdashboardtypes.Definition, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() {
continue
}
file := path.Join(definitionsRoot, entry.Name())
raw, err := definitionFiles.ReadFile(file)
if err != nil {
return systemdashboardtypes.Registry{}, errors.WrapInternalf(err, errors.CodeInternal, "couldn't read %s", file)
}
definition, err := systemdashboardtypes.NewDefinition(raw)
if err != nil {
return systemdashboardtypes.Registry{}, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "couldn't parse %s", file)
}
definitions = append(definitions, definition)
}
return systemdashboardtypes.NewRegistry(definitions)
}

View File

@@ -0,0 +1,20 @@
package implsystemdashboard
import (
"testing"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// A schema migration cannot ship without updating the definitions: parsing them
// runs the same validation a create goes through, at the current schemaVersion.
func TestEmbeddedDefinitionsParseAtCurrentSchemaVersion(t *testing.T) {
registry, err := NewRegistry()
require.NoError(t, err)
// The frontend addresses the overview dashboard by this name.
_, ok := registry.Get(dashboardtypes.SystemDashboardNamePrefix + "ai-o11y-overview")
assert.True(t, ok)
}

View File

@@ -0,0 +1,17 @@
{
"version": 1,
"definition": {
"schemaVersion": "v6",
"name": "signoz---ai-o11y-overview",
"tags": [],
"spec": {
"display": {
"name": "AI Observability Overview",
"description": "Overview of LLM traffic. Panels ship in an upcoming release."
},
"variables": [],
"panels": {},
"layouts": []
}
}
}

View File

@@ -0,0 +1,48 @@
package implsystemdashboard
import (
"context"
"net/http"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/modules/systemdashboard"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
)
type handler struct {
module systemdashboard.Module
}
func NewHandler(module systemdashboard.Module) systemdashboard.Handler {
return &handler{module: module}
}
func (handler *handler) Get(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
name := mux.Vars(r)["name"]
if name == "" {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "name is missing in the path"))
return
}
systemDashboard, err := handler.module.Get(ctx, valuer.MustNewUUID(claims.OrgID), name)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, systemDashboard.ToGettableDashboardV2())
}

View File

@@ -0,0 +1,150 @@
package implsystemdashboard
import (
"context"
"log/slog"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/modules/systemdashboard"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/types/systemdashboardtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type module struct {
settings factory.ScopedProviderSettings
store systemdashboardtypes.Store
registry systemdashboardtypes.Registry
dashboardModule dashboard.Module
}
func NewModule(
providerSettings factory.ProviderSettings,
store systemdashboardtypes.Store,
registry systemdashboardtypes.Registry,
dashboardModule dashboard.Module,
) systemdashboard.Module {
return &module{
settings: factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/systemdashboard/implsystemdashboard"),
store: store,
registry: registry,
dashboardModule: dashboardModule,
}
}
func (module *module) Reconcile(ctx context.Context, orgID valuer.UUID) error {
for _, definition := range module.registry.List() {
if err := module.reconcile(ctx, orgID, definition); err != nil {
return err
}
}
return nil
}
func (module *module) reconcile(ctx context.Context, orgID valuer.UUID, definition systemdashboardtypes.Definition) error {
existing, err := module.dashboardModule.GetByNameV2(ctx, orgID, definition.Name())
if err != nil {
if !errors.Ast(err, errors.TypeNotFound) {
return err
}
return module.provision(ctx, orgID, definition)
}
// Anything but the provisioner in updated_by means a foreign write. Leave the
// row alone — never overwriting is the safe direction.
if existing.UpdatedBy != systemdashboardtypes.ProvisionerIdentity {
return nil
}
state, err := module.store.Get(ctx, orgID, definition.Name())
if err != nil {
return err
}
// Only ever move forward: a downgrade must not rewrite the newer content.
if state.Version >= definition.Version {
return nil
}
return module.upgrade(ctx, orgID, existing.ID, definition)
}
// provision creates the dashboard and its state row in one transaction, so a
// system dashboard can never exist without the version it was provisioned at.
// A concurrent provisioner (another replica, or the org-creation hook racing the
// startup sweep) loses on the state row's unique (org_id, name) index and rolls back.
func (module *module) provision(ctx context.Context, orgID valuer.UUID, definition systemdashboardtypes.Definition) error {
err := module.store.RunInTx(ctx, func(ctx context.Context) error {
created, err := module.dashboardModule.CreateV2(
ctx,
orgID,
systemdashboardtypes.ProvisionerIdentity,
valuer.UUID{},
dashboardtypes.SourceSystem,
definition.Dashboard,
)
if err != nil {
return err
}
return module.store.Create(ctx, systemdashboardtypes.NewStorableSystemDashboard(orgID, created.ID, definition.Name(), definition.Version))
})
if err != nil {
if errors.Ast(err, errors.TypeAlreadyExists) {
module.settings.Logger().DebugContext(ctx, "system dashboard already provisioned concurrently", slog.String("name", definition.Name()), slog.String("org_id", orgID.StringValue()))
return nil
}
return err
}
module.settings.Logger().InfoContext(ctx, "provisioned system dashboard", slog.String("name", definition.Name()), slog.Int("version", definition.Version), slog.String("org_id", orgID.StringValue()))
return nil
}
func (module *module) upgrade(ctx context.Context, orgID valuer.UUID, id valuer.UUID, definition systemdashboardtypes.Definition) error {
err := module.store.RunInTx(ctx, func(ctx context.Context) error {
if _, err := module.dashboardModule.UpdateUnsafeV2(ctx, orgID, id, systemdashboardtypes.ProvisionerIdentity, definition.ToUpdatable()); err != nil {
return err
}
return module.store.UpdateVersion(ctx, orgID, definition.Name(), definition.Version)
})
if err != nil {
return err
}
module.settings.Logger().InfoContext(ctx, "upgraded system dashboard", slog.String("name", definition.Name()), slog.Int("version", definition.Version), slog.String("org_id", orgID.StringValue()))
return nil
}
func (module *module) Get(ctx context.Context, orgID valuer.UUID, name string) (*dashboardtypes.DashboardV2, error) {
return module.get(ctx, orgID, name)
}
func (module *module) ResolveID(ctx context.Context, orgID valuer.UUID, name string) (valuer.UUID, error) {
existing, err := module.get(ctx, orgID, name)
if err != nil {
return valuer.UUID{}, err
}
return existing.ID, nil
}
func (module *module) get(ctx context.Context, orgID valuer.UUID, name string) (*dashboardtypes.DashboardV2, error) {
if strings.HasPrefix(name, dashboardtypes.SystemDashboardNamePrefix) {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "name must not carry the %q prefix", dashboardtypes.SystemDashboardNamePrefix)
}
existing, err := module.dashboardModule.GetByNameV2(ctx, orgID, dashboardtypes.SystemDashboardNamePrefix+name)
if err != nil {
return nil, err
}
if err := existing.ErrIfNotSystem(); err != nil {
return nil, err
}
return existing, nil
}

View File

@@ -0,0 +1,209 @@
package implsystemdashboard
import (
"context"
"path/filepath"
"strconv"
"testing"
"time"
"github.com/SigNoz/signoz/pkg/analytics/analyticstest"
"github.com/SigNoz/signoz/pkg/factory/factorytest"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/tag/impltag"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/sqlstore/sqlitesqlstore"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/types/systemdashboardtypes"
"github.com/SigNoz/signoz/pkg/types/tagtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const testDashboardName = "test-overview"
func newTestSQLStore(t *testing.T) sqlstore.SQLStore {
t.Helper()
store, err := sqlitesqlstore.New(context.Background(), factorytest.NewSettings(), sqlstore.Config{
Provider: "sqlite",
Connection: sqlstore.ConnectionConfig{MaxOpenConns: 10},
Sqlite: sqlstore.SqliteConfig{
Path: filepath.Join(t.TempDir(), "test.db"),
Mode: "wal",
BusyTimeout: 5 * time.Second,
TransactionMode: "deferred",
},
})
require.NoError(t, err)
for _, model := range []any{
(*dashboardtypes.StorableDashboard)(nil),
(*tagtypes.Tag)(nil),
(*tagtypes.TagRelation)(nil),
(*systemdashboardtypes.StorableSystemDashboard)(nil),
} {
_, err := store.BunDB().NewCreateTable().Model(model).IfNotExists().Exec(context.Background())
require.NoError(t, err)
}
_, err = store.BunDB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS uq_system_dashboard_org_name ON system_dashboard (org_id, name)`)
require.NoError(t, err)
return store
}
func newTestModule(t *testing.T, sqlStore sqlstore.SQLStore, definitions ...systemdashboardtypes.Definition) (*module, dashboard.Module) {
t.Helper()
providerSettings := factorytest.NewSettings()
dashboardModule := impldashboard.NewModule(
impldashboard.NewStore(sqlStore),
providerSettings,
analyticstest.New(),
nil,
queryparser.New(providerSettings),
impltag.NewModule(impltag.NewStore(sqlStore)),
)
registry, err := systemdashboardtypes.NewRegistry(definitions)
require.NoError(t, err)
return NewModule(providerSettings, NewStore(sqlStore), registry, dashboardModule).(*module), dashboardModule
}
func newTestDefinition(t *testing.T, version int, displayName string) systemdashboardtypes.Definition {
t.Helper()
raw := `{
"version": ` + strconv.Itoa(version) + `,
"definition": {
"schemaVersion": "` + dashboardtypes.SchemaVersion + `",
"name": "` + dashboardtypes.SystemDashboardNamePrefix + testDashboardName + `",
"tags": [],
"spec": {"display": {"name": "` + displayName + `"}, "variables": [], "panels": {}, "layouts": []}
}
}`
definition, err := systemdashboardtypes.NewDefinition([]byte(raw))
require.NoError(t, err)
return definition
}
func TestReconcileProvisionsThenUpgradesUntilTheRowIsModified(t *testing.T) {
ctx := context.Background()
sqlStore := newTestSQLStore(t)
orgID := valuer.GenerateUUID()
systemDashboardModule, _ := newTestModule(t, sqlStore, newTestDefinition(t, 1, "v1"))
require.NoError(t, systemDashboardModule.Reconcile(ctx, orgID))
provisioned, err := systemDashboardModule.Get(ctx, orgID, testDashboardName)
require.NoError(t, err)
assert.Equal(t, dashboardtypes.SourceSystem, provisioned.Source)
assert.Equal(t, systemdashboardtypes.ProvisionerIdentity, provisioned.CreatedBy)
assert.Equal(t, "v1", provisioned.Spec.Display.Name)
assert.Equal(t, 1, stateVersion(t, systemDashboardModule, ctx, orgID))
// Reconciling the same version again is a no-op.
require.NoError(t, systemDashboardModule.Reconcile(ctx, orgID))
unchanged, err := systemDashboardModule.Get(ctx, orgID, testDashboardName)
require.NoError(t, err)
assert.Equal(t, provisioned.UpdatedAt, unchanged.UpdatedAt)
// An unmodified copy is upgraded in place, keeping its id.
upgradingModule, dashboardModule := newTestModule(t, sqlStore, newTestDefinition(t, 2, "v2"))
require.NoError(t, upgradingModule.Reconcile(ctx, orgID))
upgraded, err := upgradingModule.Get(ctx, orgID, testDashboardName)
require.NoError(t, err)
assert.Equal(t, provisioned.ID, upgraded.ID)
assert.Equal(t, "v2", upgraded.Spec.Display.Name)
assert.Equal(t, 2, stateVersion(t, upgradingModule, ctx, orgID))
// Once anything but the provisioner writes the row, later releases leave it alone.
updatable := newTestDefinition(t, 2, "edited out of band").ToUpdatable()
_, err = dashboardModule.UpdateUnsafeV2(ctx, orgID, upgraded.ID, "user@signoz.io", updatable)
require.NoError(t, err)
shippingModule, _ := newTestModule(t, sqlStore, newTestDefinition(t, 3, "v3"))
require.NoError(t, shippingModule.Reconcile(ctx, orgID))
untouched, err := shippingModule.Get(ctx, orgID, testDashboardName)
require.NoError(t, err)
assert.Equal(t, "user@signoz.io", untouched.UpdatedBy)
assert.Equal(t, "edited out of band", untouched.Spec.Display.Name)
assert.Equal(t, 2, stateVersion(t, shippingModule, ctx, orgID))
}
func stateVersion(t *testing.T, module *module, ctx context.Context, orgID valuer.UUID) int {
t.Helper()
state, err := module.store.Get(ctx, orgID, dashboardtypes.SystemDashboardNamePrefix+testDashboardName)
require.NoError(t, err)
return state.Version
}
func TestSystemDashboardsAreImmutableToUsers(t *testing.T) {
ctx := context.Background()
sqlStore := newTestSQLStore(t)
orgID := valuer.GenerateUUID()
systemDashboardModule, dashboardModule := newTestModule(t, sqlStore, newTestDefinition(t, 1, "v1"))
require.NoError(t, systemDashboardModule.Reconcile(ctx, orgID))
provisioned, err := systemDashboardModule.Get(ctx, orgID, testDashboardName)
require.NoError(t, err)
_, err = dashboardModule.UpdateV2(ctx, orgID, provisioned.ID, "user@signoz.io", newTestDefinition(t, 1, "edited").ToUpdatable())
require.Error(t, err)
assert.Contains(t, err.Error(), "cannot be modified")
}
func TestReconcileDoesNotDowngrade(t *testing.T) {
ctx := context.Background()
sqlStore := newTestSQLStore(t)
orgID := valuer.GenerateUUID()
newerModule, _ := newTestModule(t, sqlStore, newTestDefinition(t, 3, "v3"))
require.NoError(t, newerModule.Reconcile(ctx, orgID))
olderModule, _ := newTestModule(t, sqlStore, newTestDefinition(t, 2, "v2"))
require.NoError(t, olderModule.Reconcile(ctx, orgID))
got, err := newerModule.Get(ctx, orgID, testDashboardName)
require.NoError(t, err)
assert.Equal(t, "v3", got.Spec.Display.Name)
assert.Equal(t, 3, stateVersion(t, newerModule, ctx, orgID))
}
func TestGetRejectsANonSystemDashboard(t *testing.T) {
ctx := context.Background()
sqlStore := newTestSQLStore(t)
orgID := valuer.GenerateUUID()
systemDashboardModule, dashboardModule := newTestModule(t, sqlStore)
var postable dashboardtypes.PostableDashboardV2
require.NoError(t, postable.UnmarshalJSON([]byte(`{
"schemaVersion": "`+dashboardtypes.SchemaVersion+`",
"name": "a-user-dashboard",
"tags": [],
"spec": {"display": {"name": "user"}, "variables": [], "panels": {}, "layouts": []}
}`)))
_, err := dashboardModule.CreateV2(ctx, orgID, "user@signoz.io", valuer.GenerateUUID(), dashboardtypes.SourceUser, postable)
require.NoError(t, err)
// The server-side prefix makes user names structurally unreachable here.
_, err = systemDashboardModule.Get(ctx, orgID, "a-user-dashboard")
require.Error(t, err)
_, err = systemDashboardModule.Get(ctx, orgID, dashboardtypes.SystemDashboardNamePrefix+testDashboardName)
require.Error(t, err)
assert.Contains(t, err.Error(), "must not carry")
}

View File

@@ -0,0 +1,81 @@
package implsystemdashboard
import (
"context"
"log/slog"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/systemdashboard"
)
const reconcileRetryInterval = 30 * time.Second
type service struct {
settings factory.ScopedProviderSettings
module systemdashboard.Module
orgGetter organization.Getter
stopC chan struct{}
healthyC chan struct{}
}
// NewService reconciles every org's system dashboards once at startup. Orgs
// created later are reconciled by the organization setter instead.
func NewService(providerSettings factory.ProviderSettings, module systemdashboard.Module, orgGetter organization.Getter) factory.Service {
return &service{
settings: factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/systemdashboard/implsystemdashboard"),
module: module,
orgGetter: orgGetter,
stopC: make(chan struct{}),
healthyC: make(chan struct{}),
}
}
func (service *service) Start(ctx context.Context) error {
ticker := time.NewTicker(reconcileRetryInterval)
defer ticker.Stop()
for {
err := service.reconcile(ctx)
if err == nil {
close(service.healthyC)
<-service.stopC
return nil
}
service.settings.Logger().WarnContext(ctx, "system dashboard reconciliation failed, retrying", errors.Attr(err))
select {
case <-service.stopC:
return nil
case <-ticker.C:
}
}
}
func (service *service) Healthy() <-chan struct{} {
return service.healthyC
}
func (service *service) Stop(_ context.Context) error {
close(service.stopC)
return nil
}
func (service *service) reconcile(ctx context.Context) error {
orgs, err := service.orgGetter.ListByOwnedKeyRange(ctx)
if err != nil {
return err
}
for _, org := range orgs {
if err := service.module.Reconcile(ctx, org.ID); err != nil {
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "couldn't reconcile system dashboards for org %s", org.ID.StringValue())
}
}
service.settings.Logger().InfoContext(ctx, "system dashboard reconciliation completed", slog.Int("orgs", len(orgs)))
return nil
}

View File

@@ -0,0 +1,80 @@
package implsystemdashboard
import (
"context"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/systemdashboardtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type store struct {
sqlstore sqlstore.SQLStore
}
func NewStore(sqlstore sqlstore.SQLStore) systemdashboardtypes.Store {
return &store{sqlstore: sqlstore}
}
func (store *store) Create(ctx context.Context, storable *systemdashboardtypes.StorableSystemDashboard) error {
_, err := store.
sqlstore.
BunDBCtx(ctx).
NewInsert().
Model(storable).
Exec(ctx)
if err != nil {
return store.sqlstore.WrapAlreadyExistsErrf(err, systemdashboardtypes.ErrCodeSystemDashboardAlreadyProvisioned, "system dashboard %s is already provisioned", storable.Name)
}
return nil
}
func (store *store) Get(ctx context.Context, orgID valuer.UUID, name string) (*systemdashboardtypes.StorableSystemDashboard, error) {
storable := new(systemdashboardtypes.StorableSystemDashboard)
err := store.
sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(storable).
Where("org_id = ?", orgID).
Where("name = ?", name).
Scan(ctx)
if err != nil {
return nil, store.sqlstore.WrapNotFoundErrf(err, systemdashboardtypes.ErrCodeSystemDashboardNotFound, "system dashboard %s is not provisioned", name)
}
return storable, nil
}
func (store *store) UpdateVersion(ctx context.Context, orgID valuer.UUID, name string, version int) error {
result, err := store.
sqlstore.
BunDBCtx(ctx).
NewUpdate().
Model(new(systemdashboardtypes.StorableSystemDashboard)).
Set("version = ?", version).
Set("updated_at = ?", time.Now()).
Where("org_id = ?", orgID).
Where("name = ?", name).
Exec(ctx)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return errors.Newf(errors.TypeNotFound, systemdashboardtypes.ErrCodeSystemDashboardNotFound, "system dashboard %s is not provisioned", name)
}
return nil
}
func (store *store) RunInTx(ctx context.Context, cb func(ctx context.Context) error) error {
return store.sqlstore.RunInTxCtx(ctx, nil, cb)
}

View File

@@ -0,0 +1,28 @@
package systemdashboard
import (
"context"
"net/http"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type Module interface {
// Reconcile provisions the org's missing system dashboards and upgrades the
// unmodified ones to the shipped version. It never touches a dashboard whose
// row carries a foreign write and it never deletes.
Reconcile(ctx context.Context, orgID valuer.UUID) error
// Get addresses the dashboard by its bare definition name; the reserved
// prefix is a storage concern the API never exposes.
Get(ctx context.Context, orgID valuer.UUID, name string) (*dashboardtypes.DashboardV2, error)
// ResolveID maps a system dashboard's name to its id, so routes addressed by
// name can be authz-checked and audited against the id tuples carry.
ResolveID(ctx context.Context, orgID valuer.UUID, name string) (valuer.UUID, error)
}
type Handler interface {
Get(http.ResponseWriter, *http.Request)
}

View File

@@ -147,11 +147,6 @@ func AdjustKey(key *telemetrytypes.TelemetryFieldKey, keys map[string][]*telemet
// So we can safely override the context and data type
actions = append(actions, fmt.Sprintf("Overriding key: %s to %s", key, intrinsicOrCalculatedField))
// Adopt the canonical name of the field it resolved to, the same way the metadata
// path below does. This is a no-op when the caller looked the field up by the key's
// own name, and carries the qualified name for fields registered under one
// (`name` with scope context -> `scope.name`).
key.Name = intrinsicOrCalculatedField.Name
key.OverrideMetadataFrom(intrinsicOrCalculatedField)
return actions

View File

@@ -56,21 +56,6 @@ func QueryStringToKeysSelectors(query string) []*telemetrytypes.FieldKeySelector
FieldDataType: key.FieldDataType,
})
}
// A scope attribute whose flattened name begins with `scope.` (e.g. an OTel
// scope attribute nested under `scope`) is indistinguishable from the `scope.`
// context prefix after Normalize strips it. Also fetch the metadata key under
// its full `scope.`-prefixed name so resolution can find it.
// todo(tushar): consider reverting changes done to this method in below PR to avoid scope specific checks
// https://github.com/SigNoz/signoz/issues/11374
if key.FieldContext == telemetrytypes.FieldContextScope {
keys = append(keys, &telemetrytypes.FieldKeySelector{
Name: key.FieldContext.StringValue() + "." + key.Name,
Signal: key.Signal,
FieldContext: telemetrytypes.FieldContextUnspecified, // this allows 'scope.' prefix for keys with other context as well
FieldDataType: key.FieldDataType,
})
}
}
}

View File

@@ -72,26 +72,6 @@ func TestQueryToKeys(t *testing.T) {
},
},
},
{
// A scope reference also fetches its full `scope.`-prefixed name so a scope
// attribute whose flattened name begins with `scope.` (e.g. `scope.prefixed`)
// is discoverable after Normalize strips the prefix.
query: `scope.prefixed = 'local'`,
expectedKeys: []telemetrytypes.FieldKeySelector{
{
Name: "prefixed",
Signal: telemetrytypes.SignalUnspecified,
FieldContext: telemetrytypes.FieldContextScope,
FieldDataType: telemetrytypes.FieldDataTypeUnspecified,
},
{
Name: "scope.prefixed",
Signal: telemetrytypes.SignalUnspecified,
FieldContext: telemetrytypes.FieldContextUnspecified,
FieldDataType: telemetrytypes.FieldDataTypeUnspecified,
},
},
},
}
for _, testCase := range testCases {

View File

@@ -46,6 +46,8 @@ import (
"github.com/SigNoz/signoz/pkg/modules/spanmapper/implspanmapper"
"github.com/SigNoz/signoz/pkg/modules/spanpercentile"
"github.com/SigNoz/signoz/pkg/modules/spanpercentile/implspanpercentile"
"github.com/SigNoz/signoz/pkg/modules/systemdashboard"
"github.com/SigNoz/signoz/pkg/modules/systemdashboard/implsystemdashboard"
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
"github.com/SigNoz/signoz/pkg/modules/tracedetail/impltracedetail"
"github.com/SigNoz/signoz/pkg/modules/tracefunnel"
@@ -88,6 +90,7 @@ type Handlers struct {
RulerHandler ruler.Handler
LLMPricingRuleHandler llmpricingrule.Handler
StatsHandler statsreporter.Handler
SystemDashboard systemdashboard.Handler
}
func NewHandlers(
@@ -137,5 +140,6 @@ func NewHandlers(
RulerHandler: signozruler.NewHandler(rulerService),
LLMPricingRuleHandler: impllmpricingrule.NewHandler(modules.LLMPricingRule),
StatsHandler: statsreporter.NewHandler(statsAggregator),
SystemDashboard: implsystemdashboard.NewHandler(modules.SystemDashboard),
}
}

View File

@@ -59,7 +59,7 @@ func TestNewHandlers(t *testing.T) {
userGetter := impluser.NewGetter(impluser.NewStore(sqlstore, providerSettings), userRoleStore, flagger)
retentionGetter := implretention.NewGetter(implretention.NewStore(sqlstore))
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, nil, nil, nil, retentionGetter, flagger, tagModule, nil)
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, nil, nil, nil, retentionGetter, flagger, tagModule, nil, nil)
querierHandler := querier.NewHandler(providerSettings, nil, nil)
registryHandler := factory.NewHandler(nil)

View File

@@ -48,6 +48,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/spanmapper/implspanmapper"
"github.com/SigNoz/signoz/pkg/modules/spanpercentile"
"github.com/SigNoz/signoz/pkg/modules/spanpercentile/implspanpercentile"
"github.com/SigNoz/signoz/pkg/modules/systemdashboard"
"github.com/SigNoz/signoz/pkg/modules/tag"
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
"github.com/SigNoz/signoz/pkg/modules/tracedetail/impltracedetail"
@@ -67,35 +68,36 @@ import (
)
type Modules struct {
OrgGetter organization.Getter
OrgSetter organization.Setter
Preference preference.Module
UserSetter user.Setter
UserGetter user.Getter
RetentionGetter retention.Getter
SavedView savedview.Module
Apdex apdex.Module
Dashboard dashboard.Module
QuickFilter quickfilter.Module
TraceFunnel tracefunnel.Module
RawDataExport rawdataexport.Module
AuthDomain authdomain.Module
Session session.Module
Services services.Module
SpanPercentile spanpercentile.Module
MetricsExplorer metricsexplorer.Module
MetricReductionRule metricreductionrule.Module
InfraMonitoring inframonitoring.Module
OrgGetter organization.Getter
OrgSetter organization.Setter
Preference preference.Module
UserSetter user.Setter
UserGetter user.Getter
RetentionGetter retention.Getter
SavedView savedview.Module
Apdex apdex.Module
Dashboard dashboard.Module
QuickFilter quickfilter.Module
TraceFunnel tracefunnel.Module
RawDataExport rawdataexport.Module
AuthDomain authdomain.Module
Session session.Module
Services services.Module
SpanPercentile spanpercentile.Module
MetricsExplorer metricsexplorer.Module
MetricReductionRule metricreductionrule.Module
InfraMonitoring inframonitoring.Module
Promote promote.Module
ServiceAccount serviceaccount.Module
ServiceAccountGetter serviceaccount.Getter
CloudIntegration cloudintegration.Module
LogsPipeline logspipeline.Module
RuleStateHistory rulestatehistory.Module
TraceDetail tracedetail.Module
SpanMapper spanmapper.Module
LLMPricingRule llmpricingrule.Module
Tag tag.Module
LogsPipeline logspipeline.Module
RuleStateHistory rulestatehistory.Module
TraceDetail tracedetail.Module
SpanMapper spanmapper.Module
LLMPricingRule llmpricingrule.Module
Tag tag.Module
SystemDashboard systemdashboard.Module
}
func NewModules(
@@ -124,9 +126,10 @@ func NewModules(
fl flagger.Flagger,
tagModule tag.Module,
metricReductionRule metricreductionrule.Module,
systemDashboard systemdashboard.Module,
) Modules {
quickfilter := implquickfilter.NewModule(implquickfilter.NewStore(sqlstore))
orgSetter := implorganization.NewSetter(implorganization.NewStore(sqlstore), alertmanager, quickfilter)
orgSetter := implorganization.NewSetter(implorganization.NewStore(sqlstore), alertmanager, quickfilter, systemDashboard)
// Cleanup callbacks from other modules, invoked when a user is deleted.
onDeleteUser := []user.OnDeleteUser{
dashboard.DeletePreferencesForUser,
@@ -136,34 +139,35 @@ func NewModules(
authDomainModule := implauthdomain.NewModule(implauthdomain.NewStore(sqlstore), authNs, authz)
return Modules{
OrgGetter: orgGetter,
OrgSetter: orgSetter,
Preference: implpreference.NewModule(implpreference.NewStore(sqlstore), preferencetypes.NewAvailablePreference()),
SavedView: implsavedview.NewModule(implsavedview.NewStore(sqlstore)),
Apdex: implapdex.NewModule(sqlstore),
Dashboard: dashboard,
UserSetter: userSetter,
UserGetter: userGetter,
RetentionGetter: retentionGetter,
QuickFilter: quickfilter,
TraceFunnel: impltracefunnel.NewModule(impltracefunnel.NewStore(sqlstore)),
RawDataExport: implrawdataexport.NewModule(querier),
AuthDomain: authDomainModule,
Session: implsession.NewModule(providerSettings, authNs, userSetter, userGetter, authDomainModule, tokenizer, orgGetter, authz, config.Global),
SpanPercentile: implspanpercentile.NewModule(querier, providerSettings),
Services: implservices.NewModule(querier, telemetryStore),
MetricsExplorer: implmetricsexplorer.NewModule(telemetryStore, telemetryMetadataStore, cache, ruleStore, dashboard, fl, providerSettings, config.MetricsExplorer),
MetricReductionRule: metricReductionRule,
InfraMonitoring: implinframonitoring.NewModule(telemetryStore, telemetryMetadataStore, querier, fl, providerSettings, config.InfraMonitoring),
Promote: implpromote.NewModule(telemetryMetadataStore, telemetryStore),
OrgGetter: orgGetter,
OrgSetter: orgSetter,
Preference: implpreference.NewModule(implpreference.NewStore(sqlstore), preferencetypes.NewAvailablePreference()),
SavedView: implsavedview.NewModule(implsavedview.NewStore(sqlstore)),
Apdex: implapdex.NewModule(sqlstore),
Dashboard: dashboard,
UserSetter: userSetter,
UserGetter: userGetter,
RetentionGetter: retentionGetter,
QuickFilter: quickfilter,
TraceFunnel: impltracefunnel.NewModule(impltracefunnel.NewStore(sqlstore)),
RawDataExport: implrawdataexport.NewModule(querier),
AuthDomain: authDomainModule,
Session: implsession.NewModule(providerSettings, authNs, userSetter, userGetter, authDomainModule, tokenizer, orgGetter, authz, config.Global),
SpanPercentile: implspanpercentile.NewModule(querier, providerSettings),
Services: implservices.NewModule(querier, telemetryStore),
MetricsExplorer: implmetricsexplorer.NewModule(telemetryStore, telemetryMetadataStore, cache, ruleStore, dashboard, fl, providerSettings, config.MetricsExplorer),
MetricReductionRule: metricReductionRule,
InfraMonitoring: implinframonitoring.NewModule(telemetryStore, telemetryMetadataStore, querier, fl, providerSettings, config.InfraMonitoring),
Promote: implpromote.NewModule(telemetryMetadataStore, telemetryStore),
ServiceAccount: serviceAccount,
ServiceAccountGetter: serviceAccountGetter,
LogsPipeline: impllogspipeline.NewModule(sqlstore),
RuleStateHistory: implrulestatehistory.NewModule(implrulestatehistory.NewStore(telemetryStore, telemetryMetadataStore, providerSettings.Logger), ruleStore),
CloudIntegration: cloudIntegrationModule,
TraceDetail: impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore), providerSettings, config.TraceDetail),
SpanMapper: implspanmapper.NewModule(implspanmapper.NewStore(sqlstore), fl),
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore), fl, querier),
Tag: tagModule,
LogsPipeline: impllogspipeline.NewModule(sqlstore),
RuleStateHistory: implrulestatehistory.NewModule(implrulestatehistory.NewStore(telemetryStore, telemetryMetadataStore, providerSettings.Logger), ruleStore),
CloudIntegration: cloudIntegrationModule,
TraceDetail: impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore), providerSettings, config.TraceDetail),
SpanMapper: implspanmapper.NewModule(implspanmapper.NewStore(sqlstore), fl),
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore), fl, querier),
Tag: tagModule,
SystemDashboard: systemDashboard,
}
}

View File

@@ -21,6 +21,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/retention/implretention"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount/implserviceaccount"
"github.com/SigNoz/signoz/pkg/modules/systemdashboard/implsystemdashboard"
"github.com/SigNoz/signoz/pkg/modules/tag/impltag"
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
"github.com/SigNoz/signoz/pkg/queryparser"
@@ -66,7 +67,12 @@ func TestNewModules(t *testing.T) {
retentionGetter := implretention.NewGetter(implretention.NewStore(sqlstore))
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, serviceAccount, serviceAccountGetter, implcloudintegration.NewModule(), retentionGetter, flagger, tagModule, implmetricreductionrule.NewModule())
systemDashboardRegistry, err := implsystemdashboard.NewRegistry()
require.NoError(t, err)
systemDashboard := implsystemdashboard.NewModule(providerSettings, implsystemdashboard.NewStore(sqlstore), systemDashboardRegistry, dashboardModule)
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, serviceAccount, serviceAccountGetter, implcloudintegration.NewModule(), retentionGetter, flagger, tagModule, implmetricreductionrule.NewModule(), systemDashboard)
reflectVal := reflect.ValueOf(modules)
for i := 0; i < reflectVal.NumField(); i++ {

View File

@@ -35,6 +35,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
"github.com/SigNoz/signoz/pkg/modules/session"
"github.com/SigNoz/signoz/pkg/modules/spanmapper"
"github.com/SigNoz/signoz/pkg/modules/systemdashboard"
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
"github.com/SigNoz/signoz/pkg/modules/user"
"github.com/SigNoz/signoz/pkg/querier"
@@ -92,6 +93,8 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta
struct{ ruler.Handler }{},
struct{ statsreporter.Handler }{},
struct{ savedview.Handler }{},
struct{ systemdashboard.Module }{},
struct{ systemdashboard.Handler }{},
).New(ctx, instrumentation.ToProviderSettings(), apiserver.Config{})
if err != nil {
return nil, err

View File

@@ -243,6 +243,7 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewFixSavedViewSelectFieldsFactory(sqlstore),
sqlmigration.NewDeleteOrphanUserRolesFactory(),
sqlmigration.NewMigrateLambdaDashboardsFactory(),
sqlmigration.NewAddSystemDashboardFactory(sqlstore, sqlschema),
)
}
@@ -345,6 +346,8 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
handlers.RulerHandler,
handlers.StatsHandler,
handlers.SavedView,
modules.SystemDashboard,
handlers.SystemDashboard,
),
)
}

View File

@@ -36,6 +36,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount/implserviceaccount"
"github.com/SigNoz/signoz/pkg/modules/systemdashboard/implsystemdashboard"
"github.com/SigNoz/signoz/pkg/modules/tag"
"github.com/SigNoz/signoz/pkg/modules/tag/impltag"
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
@@ -540,8 +541,16 @@ func New(
metricReductionRuleModule := metricReductionRuleModuleCallback(sqlstore, telemetrystore, dashboard, queryParser, licensing, flagger, telemetryMetadataStore, providerSettings, config.MetricsExplorer.TelemetryStore.Threads)
// Initialize the system dashboard module. The registry is parsed here so a
// malformed embedded definition fails startup instead of a request.
systemDashboardRegistry, err := implsystemdashboard.NewRegistry()
if err != nil {
return nil, err
}
systemDashboardModule := implsystemdashboard.NewModule(providerSettings, implsystemdashboard.NewStore(sqlstore), systemDashboardRegistry, dashboard)
// Initialize all modules
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, analytics, querier, telemetrystore, telemetryMetadataStore, authNs, authz, cache, queryParser, config, dashboard, userGetter, userRoleStore, serviceAccount, serviceAccountGetter, cloudIntegrationModule, retentionGetter, flagger, tagModule, metricReductionRuleModule)
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, analytics, querier, telemetrystore, telemetryMetadataStore, authNs, authz, cache, queryParser, config, dashboard, userGetter, userRoleStore, serviceAccount, serviceAccountGetter, cloudIntegrationModule, retentionGetter, flagger, tagModule, metricReductionRuleModule, systemDashboardModule)
// Initialize ruler from the variant-specific provider factories
rulerInstance, err := factory.NewProviderFromNamedMap(ctx, providerSettings, config.Ruler, rulerProviderFactories(cache, alertmanager, sqlstore, telemetrystore, telemetryMetadataStore, prometheus, orgGetter, modules.RuleStateHistory, querier, queryParser), "signoz")
@@ -610,6 +619,7 @@ func New(
factory.NewNamedService(factory.MustNewName("auditor"), auditor),
factory.NewNamedService(factory.MustNewName("meterreporter"), meterReporter, factory.MustNewName("licensing")),
factory.NewNamedService(factory.MustNewName("ruler"), rulerInstance),
factory.NewNamedService(factory.MustNewName("systemdashboard"), implsystemdashboard.NewService(providerSettings, systemDashboardModule, orgGetter)),
)
if err != nil {
return nil, err

View File

@@ -0,0 +1,93 @@
package sqlmigration
import (
"context"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type addSystemDashboard struct {
sqlstore sqlstore.SQLStore
sqlschema sqlschema.SQLSchema
}
func NewAddSystemDashboardFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(
factory.MustNewName("add_system_dashboard"),
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addSystemDashboard{sqlstore: sqlstore, sqlschema: sqlschema}, nil
},
)
}
func (migration *addSystemDashboard) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *addSystemDashboard) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
sqls := migration.sqlschema.Operator().CreateTable(&sqlschema.Table{
Name: "system_dashboard",
Columns: []*sqlschema.Column{
{Name: "id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "org_id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "dashboard_id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "name", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "version", DataType: sqlschema.DataTypeBigInt, Nullable: false},
{Name: "created_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
{Name: "updated_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
},
PrimaryKeyConstraint: &sqlschema.PrimaryKeyConstraint{
ColumnNames: []sqlschema.ColumnName{"id"},
},
ForeignKeyConstraints: []*sqlschema.ForeignKeyConstraint{
{
ReferencingColumnName: sqlschema.ColumnName("org_id"),
ReferencedTableName: sqlschema.TableName("organizations"),
ReferencedColumnName: sqlschema.ColumnName("id"),
},
{
ReferencingColumnName: sqlschema.ColumnName("dashboard_id"),
ReferencedTableName: sqlschema.TableName("dashboard"),
ReferencedColumnName: sqlschema.ColumnName("id"),
},
},
})
// (org_id, name) is what makes provisioning safe across replicas: the state
// row is written in the same transaction as the dashboard, so a losing racer
// rolls back its dashboard too.
sqls = append(sqls, migration.sqlschema.Operator().CreateIndex(
&sqlschema.UniqueIndex{
TableName: "system_dashboard",
ColumnNames: []sqlschema.ColumnName{"org_id", "name"},
},
)...)
sqls = append(sqls, migration.sqlschema.Operator().CreateIndex(
&sqlschema.UniqueIndex{
TableName: "system_dashboard",
ColumnNames: []sqlschema.ColumnName{"dashboard_id"},
},
)...)
for _, sql := range sqls {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
return tx.Commit()
}
func (migration *addSystemDashboard) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -259,23 +259,6 @@ func adjustTraceKeys(keys map[string][]*telemetrytypes.TelemetryFieldKey, query
return actions
}
// intrinsicLookupName returns the name under which a key is registered in the intrinsic and
// calculated field tables. Span-context intrinsics are registered bare (`name`, `duration_nano`)
// while scope intrinsics are registered fully qualified (`scope.name`, `scope.version`), so a
// scope key must be looked up qualified — bare lookup would match the span intrinsic of the same
// name. A scope key that misses stays qualified rather than falling back to the bare name: a
// scope attribute named `duration_nano` is not the span `duration_nano` column.
func intrinsicLookupName(key *telemetrytypes.TelemetryFieldKey) string {
if key.FieldContext != telemetrytypes.FieldContextScope {
return key.Name
}
prefix := telemetrytypes.FieldContextScope.StringValue() + "."
if strings.HasPrefix(key.Name, prefix) {
return key.Name
}
return prefix + key.Name
}
// adjustTraceKey resolves a single TelemetryFieldKey against the keys map.
func adjustTraceKey(key *telemetrytypes.TelemetryFieldKey, keys map[string][]*telemetrytypes.TelemetryFieldKey) []string {
@@ -286,22 +269,20 @@ func adjustTraceKey(key *telemetrytypes.TelemetryFieldKey, keys map[string][]*te
For example: trace_id (intrinsic), response_status_code (calculated).
*/
lookupName := intrinsicLookupName(key)
var isIntrinsicOrCalculatedField bool
var intrinsicOrCalculatedField telemetrytypes.TelemetryFieldKey
if _, ok := tracestelemetryschema.IntrinsicFields[lookupName]; ok {
if _, ok := tracestelemetryschema.IntrinsicFields[key.Name]; ok {
isIntrinsicOrCalculatedField = true
intrinsicOrCalculatedField = tracestelemetryschema.IntrinsicFields[lookupName]
} else if _, ok := tracestelemetryschema.CalculatedFields[lookupName]; ok {
intrinsicOrCalculatedField = tracestelemetryschema.IntrinsicFields[key.Name]
} else if _, ok := tracestelemetryschema.CalculatedFields[key.Name]; ok {
isIntrinsicOrCalculatedField = true
intrinsicOrCalculatedField = tracestelemetryschema.CalculatedFields[lookupName]
} else if _, ok := tracestelemetryschema.IntrinsicFieldsDeprecated[lookupName]; ok {
intrinsicOrCalculatedField = tracestelemetryschema.CalculatedFields[key.Name]
} else if _, ok := tracestelemetryschema.IntrinsicFieldsDeprecated[key.Name]; ok {
isIntrinsicOrCalculatedField = true
intrinsicOrCalculatedField = tracestelemetryschema.IntrinsicFieldsDeprecated[lookupName]
} else if _, ok := tracestelemetryschema.CalculatedFieldsDeprecated[lookupName]; ok {
intrinsicOrCalculatedField = tracestelemetryschema.IntrinsicFieldsDeprecated[key.Name]
} else if _, ok := tracestelemetryschema.CalculatedFieldsDeprecated[key.Name]; ok {
isIntrinsicOrCalculatedField = true
intrinsicOrCalculatedField = tracestelemetryschema.CalculatedFieldsDeprecated[lookupName]
intrinsicOrCalculatedField = tracestelemetryschema.CalculatedFieldsDeprecated[key.Name]
}
if isIntrinsicOrCalculatedField {

View File

@@ -675,87 +675,6 @@ func TestStatementBuilderListQuery(t *testing.T) {
},
expectedErr: nil,
},
{
name: "List query selecting and filtering scope fields",
requestType: qbtypes.RequestTypeRaw,
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Filter: &qbtypes.Filter{
Expression: "scope.name = 'otelcol'",
},
Limit: 10,
SelectFields: []telemetrytypes.TelemetryFieldKey{
{
Name: "scope.name",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextScope,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
{
Name: "telemetry.sdk.language",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextScope,
},
},
},
expected: qbtypes.Statement{
Query: "SELECT timestamp AS `__SELECT_KEY_0_timestamp`, trace_id AS `__SELECT_KEY_1_trace_id`, span_id AS `__SELECT_KEY_2_span_id`, multiIf(scope.name::String <> '', scope.name::String, NULL) AS `__SELECT_KEY_3_scope.name`, multiIf(scope.attributes.`telemetry.sdk.language` IS NOT NULL, scope.attributes.`telemetry.sdk.language`::String, NULL) AS `__SELECT_KEY_4_telemetry.sdk.language` FROM signoz_traces.distributed_signoz_index_v3 WHERE (scope.name::String = ? AND scope.name::String <> '') AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"otelcol", "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
},
expectedErr: nil,
},
{
// Short scope names (`name`/`version`) collide with span intrinsics; adjustTraceKeys
// must keep them in scope and resolve the declared paths, not the span `name` column.
name: "List query selecting short scope declared names",
requestType: qbtypes.RequestTypeRaw,
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Limit: 10,
SelectFields: []telemetrytypes.TelemetryFieldKey{
{
Name: "name",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextScope,
},
{
Name: "version",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextScope,
},
},
},
expected: qbtypes.Statement{
Query: "SELECT timestamp AS `__SELECT_KEY_0_timestamp`, trace_id AS `__SELECT_KEY_1_trace_id`, span_id AS `__SELECT_KEY_2_span_id`, multiIf(scope.name::String <> '', scope.name::String, NULL) AS `__SELECT_KEY_3_scope.name`, multiIf(scope.version::String <> '', scope.version::String, NULL) AS `__SELECT_KEY_4_scope.version` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
},
expectedErr: nil,
},
{
// A scope attribute may share its name with a span intrinsic. It must resolve to the
// scope attribute, never to the span column of that name.
name: "List query selecting scope attribute colliding with span intrinsic",
requestType: qbtypes.RequestTypeRaw,
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Limit: 10,
SelectFields: []telemetrytypes.TelemetryFieldKey{
{
Name: "duration_nano",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextScope,
},
},
},
expected: qbtypes.Statement{
Query: "SELECT timestamp AS `__SELECT_KEY_0_timestamp`, trace_id AS `__SELECT_KEY_1_trace_id`, span_id AS `__SELECT_KEY_2_span_id`, multiIf(scope.attributes.`duration_nano` IS NOT NULL, scope.attributes.`duration_nano`::String, NULL) AS `__SELECT_KEY_3_duration_nano` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
},
expectedErr: nil,
},
}
fl := flaggertest.New(t)

View File

@@ -180,7 +180,7 @@ func (t *telemetryMetaStore) getTracesKeys(ctx context.Context, fieldKeySelector
`CASE
// WHEN tagType = 'spanfield' THEN 1
WHEN tagType = 'resource' THEN 2
WHEN tagType = 'scope' THEN 3
// WHEN tagType = 'scope' THEN 3
WHEN tagType = 'tag' THEN 4
ELSE 5
END as priority`,

View File

@@ -585,97 +585,3 @@ func TestConditionForSynthesizedKeys(t *testing.T) {
assert.NotContains(t, sql, "mapContains")
})
}
// TestConditionForScope covers filters on the scope JSON column: declared paths, scope
// attributes, exists semantics, and the attribute-first union when a scope attribute
// shares a declared path's name.
func TestConditionForScope(t *testing.T) {
ctx := context.Background()
fm := NewFieldMapper(flaggertest.New(t))
cb := NewConditionBuilder(fm, flaggertest.New(t))
scopeName := IntrinsicFields["scope.name"]
declared := map[string][]*telemetrytypes.TelemetryFieldKey{"scope.name": {&scopeName}}
build := func(key telemetrytypes.TelemetryFieldKey, keys map[string][]*telemetrytypes.TelemetryFieldKey, op qbtypes.FilterOperator, value any) (string, []any) {
t.Helper()
sb := sqlbuilder.NewSelectBuilder()
conds, _, err := cb.ConditionFor(ctx, valuer.UUID{}, 0, 0, &key, keys, qbtypes.ConditionBuilderOptions{}, op, value, sb)
require.NoError(t, err)
sb.Where(sb.Or(conds...))
return sb.BuildWithFlavor(sqlbuilder.ClickHouse)
}
t.Run("declared scope.name equality is exists-guarded", func(t *testing.T) {
key := telemetrytypes.TelemetryFieldKey{Name: "name", FieldContext: telemetrytypes.FieldContextScope}
sql, args := build(key, declared, qbtypes.FilterOperatorEqual, "otelcol")
assert.Contains(t, sql, "scope.name::String = ?")
assert.Contains(t, sql, "scope.name::String <> ''")
assert.Contains(t, args, "otelcol")
})
t.Run("declared scope.name exists", func(t *testing.T) {
key := telemetrytypes.TelemetryFieldKey{Name: "name", FieldContext: telemetrytypes.FieldContextScope}
sql, _ := build(key, declared, qbtypes.FilterOperatorExists, nil)
assert.Contains(t, sql, "scope.name::String <> ''")
})
t.Run("scope attribute equality guards the raw JSON path", func(t *testing.T) {
key := telemetrytypes.TelemetryFieldKey{Name: "telemetry.sdk.language", FieldContext: telemetrytypes.FieldContextScope}
keys := map[string][]*telemetrytypes.TelemetryFieldKey{
"telemetry.sdk.language": {{Name: "telemetry.sdk.language", Signal: telemetrytypes.SignalTraces, FieldContext: telemetrytypes.FieldContextScope, FieldDataType: telemetrytypes.FieldDataTypeString}},
}
sql, args := build(key, keys, qbtypes.FilterOperatorEqual, "python")
assert.Contains(t, sql, "scope.attributes.`telemetry.sdk.language`::String = ?")
assert.Contains(t, sql, "scope.attributes.`telemetry.sdk.language` IS NOT NULL")
assert.Contains(t, args, "python")
assert.NotContains(t, sql, "scope.`scope.")
})
t.Run("short name unions attribute and declared path", func(t *testing.T) {
key := telemetrytypes.TelemetryFieldKey{Name: "name", FieldContext: telemetrytypes.FieldContextScope}
keys := map[string][]*telemetrytypes.TelemetryFieldKey{
"scope.name": {&scopeName},
"name": {{Name: "name", Signal: telemetrytypes.SignalTraces, FieldContext: telemetrytypes.FieldContextScope, FieldDataType: telemetrytypes.FieldDataTypeString}},
}
sql, _ := build(key, keys, qbtypes.FilterOperatorEqual, "x")
assert.Contains(t, sql, "scope.attributes.`name`::String = ?")
assert.Contains(t, sql, "scope.name::String = ?")
})
t.Run("declared scope.version equality", func(t *testing.T) {
scopeVersion := IntrinsicFields["scope.version"]
key := telemetrytypes.TelemetryFieldKey{Name: "version", FieldContext: telemetrytypes.FieldContextScope}
keys := map[string][]*telemetrytypes.TelemetryFieldKey{"scope.version": {&scopeVersion}}
sql, args := build(key, keys, qbtypes.FilterOperatorEqual, "1.2.3")
assert.Contains(t, sql, "scope.version::String = ?")
assert.Contains(t, sql, "scope.version::String <> ''")
assert.Contains(t, args, "1.2.3")
})
t.Run("negative operator on declared path does not add existence guard", func(t *testing.T) {
key := telemetrytypes.TelemetryFieldKey{Name: "name", FieldContext: telemetrytypes.FieldContextScope}
sql, _ := build(key, declared, qbtypes.FilterOperatorNotEqual, "otelcol")
assert.Contains(t, sql, "scope.name::String <> ?")
assert.NotContains(t, sql, "= ''")
})
t.Run("IN on a scope attribute", func(t *testing.T) {
key := telemetrytypes.TelemetryFieldKey{Name: "telemetry.sdk.language", FieldContext: telemetrytypes.FieldContextScope}
keys := map[string][]*telemetrytypes.TelemetryFieldKey{
"telemetry.sdk.language": {{Name: "telemetry.sdk.language", Signal: telemetrytypes.SignalTraces, FieldContext: telemetrytypes.FieldContextScope, FieldDataType: telemetrytypes.FieldDataTypeString}},
}
sql, _ := build(key, keys, qbtypes.FilterOperatorIn, []any{"python", "go"})
assert.Contains(t, sql, "scope.attributes.`telemetry.sdk.language`::String = ?")
assert.Contains(t, sql, "scope.attributes.`telemetry.sdk.language` IS NOT NULL")
})
t.Run("numeric operand on a scope attribute coerces the string path to float", func(t *testing.T) {
key := telemetrytypes.TelemetryFieldKey{Name: "sampler.ratio", FieldContext: telemetrytypes.FieldContextScope}
keys := map[string][]*telemetrytypes.TelemetryFieldKey{
"sampler.ratio": {{Name: "sampler.ratio", Signal: telemetrytypes.SignalTraces, FieldContext: telemetrytypes.FieldContextScope, FieldDataType: telemetrytypes.FieldDataTypeString}},
}
sql, _ := build(key, keys, qbtypes.FilterOperatorGreaterThan, float64(0.5))
assert.Contains(t, sql, "toFloat64OrNull(scope.attributes.`sampler.ratio`::String) > ?")
})
}

View File

@@ -121,20 +121,6 @@ var (
FieldContext: telemetrytypes.FieldContextSpan,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
"scope.name": {
Name: "scope.name",
Description: "Instrumentation scope name",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextScope,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
"scope.version": {
Name: "scope.version",
Description: "Instrumentation scope version",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextScope,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
}
IntrinsicFieldsDeprecated = map[string]telemetrytypes.TelemetryFieldKey{
"traceID": {

View File

@@ -53,7 +53,6 @@ var (
ValueType: schema.ColumnTypeString,
}},
"resource": {Name: "resource", Type: schema.JSONColumnType{}},
"scope": {Name: "scope", Type: schema.JSONColumnType{}},
"events": {Name: "events", Type: schema.ArrayColumnType{
ElementType: schema.ColumnTypeString,
@@ -182,7 +181,7 @@ func (m *fieldMapper) getColumn(
case telemetrytypes.FieldContextResource:
return []*schema.Column{indexV3Columns["resource"], indexV3Columns["resources_string"]}, nil
case telemetrytypes.FieldContextScope:
return []*schema.Column{indexV3Columns["scope"]}, nil
return []*schema.Column{}, qbtypes.ErrColumnNotFound
case telemetrytypes.FieldContextAttribute:
switch key.FieldDataType {
case telemetrytypes.FieldDataTypeString:
@@ -293,24 +292,14 @@ func (m *fieldMapper) resolveColumnExprs(
switch column.Type.GetType() {
case schema.ColumnTypeEnumJSON:
// The ::String cast is required because ClickHouse rejects Variant/Dynamic
// types in GROUP BY; revisit once the clickHouse dependency is updated.
switch key.FieldContext {
case telemetrytypes.FieldContextResource:
exprs = append(exprs, fmt.Sprintf("%s.`%s`::String", columnName, key.Name))
existExprs = append(existExprs, fmt.Sprintf("%s.`%s` IS NOT NULL", columnName, key.Name))
case telemetrytypes.FieldContextScope:
if isDeclaredScopePath(key.Name) {
// declared typed String paths are non-Nullable: absent reads '' not NULL.
exprs = append(exprs, fmt.Sprintf("%s::String", key.Name))
existExprs = append(existExprs, fmt.Sprintf("%s::String <> ''", key.Name))
} else {
exprs = append(exprs, fmt.Sprintf("%s.attributes.`%s`::String", columnName, key.Name))
existExprs = append(existExprs, fmt.Sprintf("%s.attributes.`%s` IS NOT NULL", columnName, key.Name))
}
default:
return nil, nil, nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "only resource and scope context fields are supported for json columns, got %s", key.FieldContext.String)
// json is only supported for resource context as of now
if key.FieldContext != telemetrytypes.FieldContextResource {
return nil, nil, nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "only resource context fields are supported for json columns, got %s", key.FieldContext.String)
}
// have to add ::string as clickHouse throws an error :- data types Variant/Dynamic are not allowed in GROUP BY
// once clickHouse dependency is updated, we need to check if we can remove it.
exprs = append(exprs, fmt.Sprintf("%s.`%s`::String", columnName, key.Name))
existExprs = append(existExprs, fmt.Sprintf("%s.`%s` IS NOT NULL", columnName, key.Name))
case schema.ColumnTypeEnumString,
schema.ColumnTypeEnumUInt64,
schema.ColumnTypeEnumUInt32,
@@ -428,40 +417,23 @@ func (m *fieldMapper) ColumnExpressionFor(
// Resolve the candidate logical field(s).
var candidates []*telemetrytypes.LogicalField
switch field.FieldContext {
case telemetrytypes.FieldContextScope:
// FieldFor resolves any scope key to a single expression, so the probe below
// would skip the union. Resolve scope the way the filter path does instead:
// MatchingLogicalFields returns a same-named scope attribute (attribute-first)
// alongside the declared path, and CandidateKeys synthesizes an attribute when
// metadata knows neither.
matches := querybuilder.MatchingLogicalFields(ctx, orgID, m.fl, field, keys)
candidates, _ = querybuilder.ResolveLogicalFields(field, matches)
if len(candidates) == 0 {
candidates = querybuilder.WrapAsLogicalFields(field.Name, m.CandidateKeys(ctx, orgID, field, nil, keys))
}
if len(candidates) == 0 {
return "", errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "field `%s` not found", field.Name)
switch _, err := m.FieldFor(ctx, orgID, startNs, endNs, field); {
case err == nil:
// A directly-resolvable key upgrades to its family when the metadata
// map proves membership; otherwise it stays single-member.
candidates = []*telemetrytypes.LogicalField{m.logicalForResolvedColumn(ctx, orgID, field, keys)}
case errors.Is(err, qbtypes.ErrColumnNotFound):
// The legacy candidate flow, unchanged: column (when the bare name is
// one) plus metadata matches, else synthesized type-variant keys. The
// family step below only swaps candidates for their family; it never
// changes candidate order or non-family behavior.
raw := m.CandidateKeys(ctx, orgID, field, nil, keys)
if len(raw) == 0 {
return "", errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "field `%s` not found", field.Name).WithSuggestions(errors.NewSuggestionsOnLevenshteinDistance(field.Name, errors.NounKeys, maps.Keys(keys))...)
}
candidates = m.upgradeToFamilies(ctx, orgID, field, querybuilder.WrapAsLogicalFields(field.Name, raw), keys)
default:
switch _, err := m.FieldFor(ctx, orgID, startNs, endNs, field); {
case err == nil:
// A directly-resolvable key upgrades to its family when the metadata
// map proves membership; otherwise it stays single-member.
candidates = []*telemetrytypes.LogicalField{m.logicalForResolvedColumn(ctx, orgID, field, keys)}
case errors.Is(err, qbtypes.ErrColumnNotFound):
// The legacy candidate flow, unchanged: column (when the bare name is
// one) plus metadata matches, else synthesized type-variant keys. The
// family step below only swaps candidates for their family; it never
// changes candidate order or non-family behavior.
raw := m.CandidateKeys(ctx, orgID, field, nil, keys)
if len(raw) == 0 {
return "", errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "field `%s` not found", field.Name).WithSuggestions(errors.NewSuggestionsOnLevenshteinDistance(field.Name, errors.NounKeys, maps.Keys(keys))...)
}
candidates = m.upgradeToFamilies(ctx, orgID, field, querybuilder.WrapAsLogicalFields(field.Name, raw), keys)
default:
return "", err
}
return "", err
}
// Group-by/order (String) and aggregation (String/Float64): every candidate is
@@ -512,9 +484,7 @@ func (m *fieldMapper) ColumnExpressionFor(
}
// Multiple candidates (collision / synth): multiIf picks the first that exists,
// stringified so branches share a type. Scope value expressions are already
// ::String, so they skip the redundant toString wrap.
scopeContext := field.FieldContext == telemetrytypes.FieldContextScope
// stringified so branches share a type.
args := make([]string, 0, len(candidates))
for _, logical := range candidates {
value, err := querybuilder.LogicalValueExpr(ctx, orgID, startNs, endNs, m, logical)
@@ -525,11 +495,7 @@ func (m *fieldMapper) ColumnExpressionFor(
if err != nil {
return "", err
}
if scopeContext {
args = append(args, fmt.Sprintf("%s, %s", guard, value))
} else {
args = append(args, fmt.Sprintf("%s, toString(%s)", guard, value))
}
args = append(args, fmt.Sprintf("%s, toString(%s)", guard, value))
}
return fmt.Sprintf("multiIf(%s, NULL)", strings.Join(args, ", ")), nil
}
@@ -611,13 +577,6 @@ func (m *fieldMapper) CandidateKeys(ctx context.Context, _ valuer.UUID, field *t
}
}
// Scope keys resolve only against the scope JSON column, so they never fall through to the
// name-only metadata match below: a same-named span or attribute entry is a different
// field (`scope.duration_nano` is not the duration_nano column).
if field.FieldContext == telemetrytypes.FieldContextScope {
return scopeCandidateKeys(field, keys)
}
// Metadata match by name, then the literal `{context}.{name}` spelling (a context can be
// a legitimate prefix in user data, e.g. `metric.max_count`). For a forgiving context
// this is the correction step (span.http.method -> attribute http.method).
@@ -641,73 +600,10 @@ func (m *fieldMapper) CandidateKeys(ctx context.Context, _ valuer.UUID, field *t
literal := telemetrytypes.NewTelemetryFieldKey(field.FieldContext.StringValue()+"."+field.Name, field.FieldContext, field.FieldDataType)
return append(querybuilder.SynthesizeKeys(field, value), querybuilder.SynthesizeKeys(literal, value)...)
}
// contexts that don't exist on spans (log, body, …) have nothing to synthesize
// contexts that don't exist on spans (log, body, scope, …) have nothing to synthesize
return nil
}
// scopeCandidateKeys resolves a scope-context key against the scope JSON column: a declared
// path resolves to itself even without metadata, whether referenced fully-qualified
// (`scope.name`) or by its short name (`name`); otherwise the scope-context metadata entries
// under either spelling, and failing those a synthesized scope attribute.
func scopeCandidateKeys(field *telemetrytypes.TelemetryFieldKey, keys map[string][]*telemetrytypes.TelemetryFieldKey) []*telemetrytypes.TelemetryFieldKey {
if isDeclaredScopePath(field.Name) {
return []*telemetrytypes.TelemetryFieldKey{telemetrytypes.NewTelemetryFieldKey(field.Name, telemetrytypes.FieldContextScope, telemetrytypes.FieldDataTypeString)}
}
if declaredName := telemetrytypes.FieldContextScope.StringValue() + "." + field.Name; isDeclaredScopePath(declaredName) {
return []*telemetrytypes.TelemetryFieldKey{telemetrytypes.NewTelemetryFieldKey(declaredName, telemetrytypes.FieldContextScope, telemetrytypes.FieldDataTypeString)}
}
for _, name := range []string{field.Name, telemetrytypes.FieldContextScope.StringValue() + "." + field.Name} {
scoped := []*telemetrytypes.TelemetryFieldKey{}
for _, match := range keys[name] {
if match.FieldContext == telemetrytypes.FieldContextScope {
scoped = append(scoped, match)
}
}
if len(scoped) > 0 {
return scoped
}
}
return []*telemetrytypes.TelemetryFieldKey{synthScopeAttributeKey(field)}
}
// synthScopeAttributeKey guesses a scope attribute (scope.attributes.<name>) for a name
// absent from metadata — the scope analog of querybuilder.SynthesizeKeys.
func synthScopeAttributeKey(field *telemetrytypes.TelemetryFieldKey) *telemetrytypes.TelemetryFieldKey {
return telemetrytypes.NewTelemetryFieldKey(field.Name, telemetrytypes.FieldContextScope, telemetrytypes.FieldDataTypeString)
}
// isDeclaredScopePath reports whether name is a declared typed sub-path of the scope JSON
// column (scope.name / scope.version), as opposed to an entry in scope.attributes.
func isDeclaredScopePath(name string) bool {
f, ok := IntrinsicFields[name]
return ok && f.FieldContext == telemetrytypes.FieldContextScope
}
// scopeJSONExistsExpression renders the presence predicate for a scope JSON key, whose
// two homes differ: declared typed paths are non-Nullable (absent reads ”), while
// scope.attributes.* are Dynamic/Nullable. Returns ok=false for non-scope keys so the
// caller falls back to the generic exists expression.
func scopeJSONExistsExpression(key *telemetrytypes.TelemetryFieldKey, fieldExpression string, exists bool) (string, bool) {
if key.FieldContext != telemetrytypes.FieldContextScope {
return "", false
}
if isDeclaredScopePath(key.Name) {
if exists {
return fieldExpression + " <> ''", true
}
return fieldExpression + " = ''", true
}
// The value expression casts the JSON path to String, folding a missing key's NULL to
// '', so presence must test the raw path — drop the ::String cast.
path := strings.TrimSuffix(fieldExpression, "::String")
if exists {
return path + " IS NOT NULL", true
}
return path + " IS NULL", true
}
// ExistsFor implements the per-key existence primitive of qbtypes.FieldMapper.
func (m *fieldMapper) ExistsFor(
ctx context.Context,
@@ -724,8 +620,5 @@ func (m *fieldMapper) ExistsFor(
if err != nil {
return "", err
}
if expr, ok := scopeJSONExistsExpression(key, fieldExpression, exists); ok {
return expr, nil
}
return querybuilder.ExistsExpression(columns, key, tsStart, tsEnd, fieldExpression, exists)
}

View File

@@ -304,160 +304,3 @@ func TestColumnExpressionForTimestampAttributeCollision(t *testing.T) {
assert.Contains(t, result, "attributes_number['timestamp']")
})
}
// scopeKey builds a TelemetryFieldKey the way the API boundary would after Normalize.
func scopeKey(name string) telemetrytypes.TelemetryFieldKey {
return telemetrytypes.TelemetryFieldKey{
Name: name,
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextScope,
}
}
// declaredScopeKeys injects the scope.name/scope.version intrinsics into the metadata map
// the way metadata.go does at query time; resolution of the declared paths depends on it.
func declaredScopeKeys() map[string][]*telemetrytypes.TelemetryFieldKey {
scopeName := IntrinsicFields["scope.name"]
scopeVersion := IntrinsicFields["scope.version"]
return map[string][]*telemetrytypes.TelemetryFieldKey{
"scope.name": {&scopeName},
"scope.version": {&scopeVersion},
}
}
func scopeAttribute(name string) *telemetrytypes.TelemetryFieldKey {
return &telemetrytypes.TelemetryFieldKey{
Name: name,
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextScope,
FieldDataType: telemetrytypes.FieldDataTypeString,
}
}
// TestColumnExpressionForScope covers the scope resolution matrix from PR #10920: declared
// paths, scope attributes, and the attribute-first union when a scope attribute shares its
// name with a declared path.
func TestColumnExpressionForScope(t *testing.T) {
ctx := context.Background()
tsStart := uint64(time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC).UnixNano())
tsEnd := uint64(time.Date(2024, 6, 5, 0, 0, 0, 0, time.UTC).UnixNano())
fm := NewFieldMapper(flaggertest.New(t))
run := func(field telemetrytypes.TelemetryFieldKey, keys map[string][]*telemetrytypes.TelemetryFieldKey) string {
t.Helper()
result, err := fm.ColumnExpressionFor(ctx, valuer.UUID{}, tsStart, tsEnd, &field, telemetrytypes.FieldDataTypeUnspecified, keys)
require.NoError(t, err)
return result
}
t.Run("short name binds to declared scope.name when no attribute exists", func(t *testing.T) {
assert.Equal(t,
"multiIf(scope.name::String <> '', scope.name::String, NULL)",
run(scopeKey("name"), declaredScopeKeys()))
})
t.Run("fully-qualified scope.name isolates the declared path", func(t *testing.T) {
assert.Equal(t,
"multiIf(scope.name::String <> '', scope.name::String, NULL)",
run(scopeKey("scope.name"), declaredScopeKeys()))
})
t.Run("short version binds to declared scope.version", func(t *testing.T) {
assert.Equal(t,
"multiIf(scope.version::String <> '', scope.version::String, NULL)",
run(scopeKey("version"), declaredScopeKeys()))
})
t.Run("plain scope attribute", func(t *testing.T) {
keys := declaredScopeKeys()
keys["testing.env"] = []*telemetrytypes.TelemetryFieldKey{scopeAttribute("testing.env")}
assert.Equal(t,
"multiIf(scope.attributes.`testing.env` IS NOT NULL, scope.attributes.`testing.env`::String, NULL)",
run(scopeKey("testing.env"), keys))
})
t.Run("scope attribute synthesized when absent from metadata", func(t *testing.T) {
assert.Equal(t,
"multiIf(scope.attributes.`testing.env` IS NOT NULL, scope.attributes.`testing.env`::String, NULL)",
run(scopeKey("testing.env"), declaredScopeKeys()))
})
t.Run("short name unions attribute (first) with declared path", func(t *testing.T) {
keys := declaredScopeKeys()
keys["name"] = []*telemetrytypes.TelemetryFieldKey{scopeAttribute("name")}
assert.Equal(t,
"multiIf(scope.attributes.`name` IS NOT NULL, scope.attributes.`name`::String, scope.name::String <> '', scope.name::String, NULL)",
run(scopeKey("name"), keys))
})
t.Run("fully-qualified scope.version isolates declared even with conflicting attribute", func(t *testing.T) {
keys := declaredScopeKeys()
keys["version"] = []*telemetrytypes.TelemetryFieldKey{scopeAttribute("version")}
assert.Equal(t,
"multiIf(scope.version::String <> '', scope.version::String, NULL)",
run(scopeKey("scope.version"), keys))
})
t.Run("group by short name unions attribute and declared without toString", func(t *testing.T) {
keys := declaredScopeKeys()
keys["name"] = []*telemetrytypes.TelemetryFieldKey{scopeAttribute("name")}
result, err := fm.ColumnExpressionFor(ctx, valuer.UUID{}, tsStart, tsEnd, &[]telemetrytypes.TelemetryFieldKey{scopeKey("name")}[0], telemetrytypes.FieldDataTypeString, keys)
require.NoError(t, err)
assert.Equal(t,
"multiIf(scope.attributes.`name` IS NOT NULL, scope.attributes.`name`::String, scope.name::String <> '', scope.name::String, NULL)",
result)
})
}
// TestFieldForScope covers the per-key SQL for a resolved scope key.
func TestFieldForScope(t *testing.T) {
ctx := context.Background()
tsStart := uint64(time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC).UnixNano())
tsEnd := uint64(time.Date(2024, 6, 5, 0, 0, 0, 0, time.UTC).UnixNano())
fm := NewFieldMapper(flaggertest.New(t))
cases := map[string]string{
"scope.name": "scope.name::String",
"scope.version": "scope.version::String",
"custom.attr": "scope.attributes.`custom.attr`::String",
}
for name, want := range cases {
t.Run(name, func(t *testing.T) {
key := scopeKey(name)
got, err := fm.FieldFor(ctx, valuer.UUID{}, tsStart, tsEnd, &key)
require.NoError(t, err)
assert.Equal(t, want, got)
// A scope path must never double-prefix the JSON column.
assert.NotContains(t, got, "scope.`scope.")
})
}
}
// TestExistsForScope covers the presence predicates: declared paths test <> ” (non-Nullable),
// scope attributes test the raw JSON path IS NOT NULL.
func TestExistsForScope(t *testing.T) {
ctx := context.Background()
tsStart := uint64(time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC).UnixNano())
tsEnd := uint64(time.Date(2024, 6, 5, 0, 0, 0, 0, time.UTC).UnixNano())
fm := NewFieldMapper(flaggertest.New(t))
cases := []struct {
name string
key string
exists bool
want string
}{
{"declared exists", "scope.name", true, "scope.name::String <> ''"},
{"declared not exists", "scope.name", false, "scope.name::String = ''"},
{"attribute exists", "exception.type", true, "scope.attributes.`exception.type` IS NOT NULL"},
{"attribute not exists", "exception.type", false, "scope.attributes.`exception.type` IS NULL"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
key := scopeKey(tc.key)
got, err := fm.ExistsFor(ctx, valuer.UUID{}, tsStart, tsEnd, &key, tc.exists)
require.NoError(t, err)
assert.Equal(t, tc.want, got)
})
}
}

View File

@@ -128,21 +128,6 @@ func BuildCompleteFieldKeyMap(releaseTime time.Time) map[string][]*telemetrytype
FieldDataType: telemetrytypes.FieldDataTypeString,
},
},
// declared scope paths, mirroring the intrinsics metadata.go injects at query time
"scope.name": {
{
Name: "scope.name",
FieldContext: telemetrytypes.FieldContextScope,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
},
"scope.version": {
{
Name: "scope.version",
FieldContext: telemetrytypes.FieldContextScope,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
},
}
for _, keys := range keysMap {
for _, key := range keys {

View File

@@ -25,6 +25,10 @@ const (
dashboardNameSuffixLen = 8
)
// SystemDashboardNamePrefix is reserved for dashboards SigNoz ships and owns. Generated
// names never contain consecutive hyphens, so only a typed name can carry it — create rejects that.
const SystemDashboardNamePrefix = "signoz---"
const (
dashboardIconPathPrefix = "/assets/Icons/"
dashboardLogoPathPrefix = "/assets/Logos/"
@@ -75,8 +79,8 @@ type DashboardV2 struct {
}
func (d *DashboardV2) ErrIfNotMutable() error {
if d.Source == SourceIntegration {
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardImmutable, "integration dashboards cannot be modified")
if d.Source != SourceUser {
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardImmutable, "%s dashboards cannot be modified", d.Source)
}
return nil
}
@@ -95,6 +99,11 @@ func (d *DashboardV2) Update(updatable UpdatableDashboardV2, updatedBy string, r
if err := d.ErrIfNotUpdatable(); err != nil {
return err
}
return d.UpdateUnsafe(updatable, updatedBy, resolvedTags)
}
// UpdateUnsafe applies the update without the source/lock gate. Intended for internal system callers.
func (d *DashboardV2) UpdateUnsafe(updatable UpdatableDashboardV2, updatedBy string, resolvedTags []*tagtypes.Tag) error {
if updatable.Name != d.Name {
return errors.NewInvalidInputf(ErrCodeDashboardImmutable, "name is immutable; cannot change from %q to %q", d.Name, updatable.Name)
}
@@ -129,6 +138,13 @@ func (d *DashboardV2) LockUnlock(lock bool, isAdmin bool, updatedBy string) erro
return nil
}
func (d *DashboardV2) ErrIfNotSystem() error {
if d.Source != SourceSystem {
return errors.Newf(errors.TypeNotFound, ErrCodeDashboardNotFound, "dashboard %q is not a system dashboard", d.Name)
}
return nil
}
func (d *DashboardV2) ErrIfNotClonable() error {
if !d.Source.isClonable() {
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardImmutable, "%s dashboards cannot be cloned", d.Source)
@@ -205,13 +221,18 @@ type PostableDashboardV2 struct {
Spec DashboardSpec `json:"spec" required:"true"`
}
func (postable PostableDashboardV2) NewDashboardV2(orgID valuer.UUID, createdBy string, source Source) *DashboardV2 {
func (postable PostableDashboardV2) NewDashboardV2(orgID valuer.UUID, createdBy string, source Source) (*DashboardV2, error) {
now := time.Now()
name := postable.Name
if postable.GenerateName {
name = generateDashboardName(postable.Spec.Display.Name)
}
// Checked on the final name, here rather than in validateName, because only
// the constructor knows the source.
if source != SourceSystem && strings.HasPrefix(name, SystemDashboardNamePrefix) {
return nil, errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "name %q is invalid: the %q prefix is reserved for system dashboards", name, SystemDashboardNamePrefix)
}
return &DashboardV2{
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
@@ -224,7 +245,7 @@ func (postable PostableDashboardV2) NewDashboardV2(orgID valuer.UUID, createdBy
Name: name,
Tags: tagtypes.NewTagsFromPostableTags(orgID, coretypes.KindDashboard, postable.Tags),
Spec: postable.Spec,
}
}, nil
}
func (p *PostableDashboardV2) UnmarshalJSON(data []byte) error {

View File

@@ -124,7 +124,8 @@ func TestPostableDashboardV2NewDashboardV2(t *testing.T) {
}
before := time.Now()
dashboard := postable.NewDashboardV2(orgID, "alice", tc.source)
dashboard, err := postable.NewDashboardV2(orgID, "alice", tc.source)
require.NoError(t, err)
after := time.Now()
require.NotNil(t, dashboard)
@@ -160,8 +161,10 @@ func TestPostableDashboardV2NewDashboardV2(t *testing.T) {
Spec: DashboardSpec{},
}
first := postable.NewDashboardV2(orgID, "alice", SourceUser)
second := postable.NewDashboardV2(orgID, "alice", SourceUser)
first, err := postable.NewDashboardV2(orgID, "alice", SourceUser)
require.NoError(t, err)
second, err := postable.NewDashboardV2(orgID, "alice", SourceUser)
require.NoError(t, err)
assert.NotEqual(t, first.ID, second.ID, "expected distinct UUIDs across invocations")
})
@@ -174,7 +177,8 @@ func TestPostableDashboardV2NewDashboardV2(t *testing.T) {
},
}
dashboard := postable.NewDashboardV2(orgID, "alice", SourceUser)
dashboard, err := postable.NewDashboardV2(orgID, "alice", SourceUser)
require.NoError(t, err)
assert.True(t, strings.HasPrefix(dashboard.Name, "my-dashboard-"), "expected slug prefix, got %q", dashboard.Name)
assert.Len(t, dashboard.Name, len("my-dashboard-")+dashboardNameSuffixLen)
})

View File

@@ -109,7 +109,8 @@ func TestPatchableDashboardV2_Apply(t *testing.T) {
var p PostableDashboardV2
require.NoError(t, json.Unmarshal([]byte(basePostableJSON), &p), "base postable JSON must validate")
testOrgID := valuer.GenerateUUID()
base := p.NewDashboardV2(testOrgID, "somecreatedthisiguess@signoz.io", SourceUser)
base, err := p.NewDashboardV2(testOrgID, "somecreatedthisiguess@signoz.io", SourceUser)
require.NoError(t, err)
base.Tags = []*tagtypes.Tag{
{Key: "team", Value: "alpha"},
{Key: "env", Value: "prod"},

View File

@@ -8,6 +8,7 @@ import (
"testing"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/perses/spec/go/dashboard"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -1928,3 +1929,36 @@ func TestEnsureSingleExpressionAggregation(t *testing.T) {
})
}
}
// Guards the constant: a prefixed name must stay a valid DNS-1123 label.
func TestSystemDashboardNamePrefix(t *testing.T) {
require.NoError(t, validateDashboardName(SystemDashboardNamePrefix+"ai-o11y-overview"))
}
func TestNewDashboardV2RejectsReservedName(t *testing.T) {
testCases := []struct {
description string
name string
source Source
wantErr bool
}{
{description: "reserved name for a system dashboard", name: SystemDashboardNamePrefix + "overview", source: SourceSystem},
{description: "reserved name for a user dashboard", name: SystemDashboardNamePrefix + "overview", source: SourceUser, wantErr: true},
{description: "reserved name for an integration dashboard", name: SystemDashboardNamePrefix + "overview", source: SourceIntegration, wantErr: true},
{description: "ordinary name for a user dashboard", name: "overview", source: SourceUser},
{description: "fewer hyphens than the prefix for a user dashboard", name: "signoz--overview", source: SourceUser},
}
for _, testCase := range testCases {
t.Run(testCase.description, func(t *testing.T) {
postable := PostableDashboardV2{Name: testCase.name}
_, err := postable.NewDashboardV2(valuer.GenerateUUID(), "user@signoz.io", testCase.source)
if testCase.wantErr {
require.Error(t, err)
assert.Contains(t, err.Error(), "reserved for system dashboards")
return
}
require.NoError(t, err)
})
}
}

View File

@@ -13,6 +13,9 @@ type Store interface {
Get(context.Context, valuer.UUID, valuer.UUID) (*StorableDashboard, error)
// GetByName resolves a dashboard by its per-org unique name.
GetByName(ctx context.Context, orgID valuer.UUID, name string) (*StorableDashboard, error)
GetPublic(context.Context, string) (*StorablePublicDashboard, error)
GetDashboardByOrgsAndPublicID(context.Context, []string, string) (*StorableDashboard, error)

View File

@@ -0,0 +1,95 @@
package systemdashboardtypes
import (
"bytes"
"encoding/json"
"slices"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
)
// Definition is one shipped system dashboard. Version is bumped on every content
// change and drives upgrade detection; the name is the stable key and never changes.
type Definition struct {
Version int `json:"version"`
Dashboard dashboardtypes.PostableDashboardV2 `json:"definition"`
}
func (definition Definition) Name() string {
return definition.Dashboard.Name
}
func NewDefinition(raw []byte) (Definition, error) {
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
var definition Definition
if err := decoder.Decode(&definition); err != nil {
return Definition{}, errors.WrapInvalidInputf(err, ErrCodeSystemDashboardDefinitionInvalid, "%s", err.Error())
}
if err := definition.validate(); err != nil {
return Definition{}, err
}
return definition, nil
}
func (definition Definition) validate() error {
if definition.Version < 1 {
return errors.NewInvalidInputf(ErrCodeSystemDashboardDefinitionInvalid, "version must be at least 1, got %d", definition.Version)
}
if !strings.HasPrefix(definition.Name(), dashboardtypes.SystemDashboardNamePrefix) {
return errors.NewInvalidInputf(ErrCodeSystemDashboardDefinitionInvalid, "name %q must start with %q", definition.Name(), dashboardtypes.SystemDashboardNamePrefix)
}
if definition.Dashboard.GenerateName {
return errors.NewInvalidInputf(ErrCodeSystemDashboardDefinitionInvalid, "%s: generateName is not allowed, the name is the stable key", definition.Name())
}
return nil
}
// ToUpdatable is how an upgrade re-applies a definition onto an existing row:
// everything but the dashboard's identity comes from the shipped definition.
func (definition Definition) ToUpdatable() dashboardtypes.UpdatableDashboardV2 {
return dashboardtypes.UpdatableDashboardV2{
DashboardV2MetadataBase: definition.Dashboard.DashboardV2MetadataBase,
Name: definition.Dashboard.Name,
Tags: definition.Dashboard.Tags,
Spec: definition.Dashboard.Spec,
}
}
// Registry holds every definition embedded in the binary, keyed by name.
type Registry struct {
definitions map[string]Definition
}
func NewRegistry(definitions []Definition) (Registry, error) {
byName := make(map[string]Definition, len(definitions))
for _, definition := range definitions {
if _, duplicate := byName[definition.Name()]; duplicate {
return Registry{}, errors.NewInvalidInputf(ErrCodeSystemDashboardDefinitionInvalid, "duplicate system dashboard name %q", definition.Name())
}
byName[definition.Name()] = definition
}
return Registry{definitions: byName}, nil
}
func (registry Registry) Get(name string) (Definition, bool) {
definition, ok := registry.definitions[name]
return definition, ok
}
// List returns the definitions sorted by name so provisioning order is stable.
func (registry Registry) List() []Definition {
definitions := make([]Definition, 0, len(registry.definitions))
for _, definition := range registry.definitions {
definitions = append(definitions, definition)
}
slices.SortFunc(definitions, func(a, b Definition) int { return strings.Compare(a.Name(), b.Name()) })
return definitions
}

View File

@@ -0,0 +1,58 @@
package systemdashboardtypes
import (
"context"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
)
var (
ErrCodeSystemDashboardNotFound = errors.MustNewCode("system_dashboard_not_found")
ErrCodeSystemDashboardDefinitionInvalid = errors.MustNewCode("system_dashboard_definition_invalid")
ErrCodeSystemDashboardAlreadyProvisioned = errors.MustNewCode("system_dashboard_already_provisioned")
)
// ProvisionerIdentity is stamped into created_by/updated_by by the reconciler. It
// is deliberately not a valid email, so it can never collide with a real account:
// any other value in updated_by means a foreign write.
const ProvisionerIdentity = "signoz"
type Store interface {
Create(ctx context.Context, storable *StorableSystemDashboard) error
Get(ctx context.Context, orgID valuer.UUID, name string) (*StorableSystemDashboard, error)
UpdateVersion(ctx context.Context, orgID valuer.UUID, name string, version int) error
RunInTx(ctx context.Context, cb func(ctx context.Context) error) error
}
// StorableSystemDashboard records the shipped version each org's copy of a system
// dashboard was last provisioned at. That version is the only thing the dashboard
// row cannot answer, since the binary only embeds the latest definition.
type StorableSystemDashboard struct {
bun.BaseModel `bun:"table:system_dashboard"`
types.Identifiable
types.TimeAuditable
OrgID valuer.UUID `bun:"org_id,type:text,notnull"`
DashboardID valuer.UUID `bun:"dashboard_id,type:text,notnull"`
Name string `bun:"name,type:text,notnull"`
Version int `bun:"version,notnull"`
}
func NewStorableSystemDashboard(orgID valuer.UUID, dashboardID valuer.UUID, name string, version int) *StorableSystemDashboard {
now := time.Now()
return &StorableSystemDashboard{
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
TimeAuditable: types.TimeAuditable{CreatedAt: now, UpdatedAt: now},
OrgID: orgID,
DashboardID: dashboardID,
Name: name,
Version: version,
}
}

View File

@@ -18,7 +18,7 @@ import (
// - Use `scope.` prefix to explicitly indicate and enforce scope context. Example
// - `scope.name`
// - `scope.version`
// - `scope.my.custom.attribute` resolves to the `my.custom.attribute` scope attribute
// - `scope.my.custom.attribute` and `scope.attribute.my.custom.attribute` resolve to same attribute
//
// - Use `attribute.` to explicitly indicate and enforce attribute context. Example
// - `attribute.http.method`
@@ -190,7 +190,7 @@ func (FieldContext) Enum() []any {
FieldContextSpan,
FieldContextTrace,
FieldContextResource,
FieldContextScope,
// FieldContextScope,
FieldContextAttribute,
// FieldContextEvent,
FieldContextBody,

View File

@@ -35,14 +35,6 @@ func TestGetFieldKeyFromKeyText(t *testing.T) {
FieldDataType: FieldDataTypeUnspecified,
},
},
{
keyText: "scope.custom.attr:string",
expected: TelemetryFieldKey{
Name: "custom.attr",
FieldContext: FieldContextScope,
FieldDataType: FieldDataTypeString,
},
},
{
keyText: "attribute.http.method",
expected: TelemetryFieldKey{