Compare commits

...

6 Commits

Author SHA1 Message Date
makeavish
9fa3dd6c9b fix(empty-state): preserve telemetry stats best effort 2026-06-11 15:16:34 +05:30
makeavish
1488c81380 fix(empty-state): defer infra metrics context 2026-06-11 15:05:06 +05:30
makeavish
1f7b98b314 test(empty-state): trim redundant org context tests 2026-06-11 13:30:06 +05:30
makeavish
45151d71e7 fix(empty-state): preserve telemetry count queries 2026-06-11 13:23:42 +05:30
makeavish
f2e01cedec fix(empty-state): restore last observed telemetry queries 2026-06-11 13:15:44 +05:30
makeavish
7f6e15cd6e feat(empty-state): add org context API for contextual empty states
Adds GET /api/v1/empty_state/org_context returning raw org-level
observability signals (ingestion presence per signal, infra metrics,
alert/dashboard/saved-view counts, firing and recently-fired alerts,
raw license state) consumed by the AI assistant to render contextual
empty-state chips. Includes unit tests and an integration test suite.
2026-06-11 12:09:46 +05:30
27 changed files with 1202 additions and 70 deletions

View File

@@ -43,6 +43,7 @@ jobs:
- callbackauthn
- cloudintegrations
- dashboard
- emptystate
- ingestionkeys
- inframonitoring
- logspipelines

2
.gitignore vendored
View File

@@ -40,6 +40,8 @@ frontend/src/constants/env.ts
**/__debug_bin
.env
# sqlite db created at repo root by `make go-run-community` / `make go-run-enterprise`
/signoz.db
pkg/query-service/signoz.db
pkg/query-service/tests/test-deploy/data/

View File

@@ -3355,6 +3355,53 @@ components:
- kind
- spec
type: object
EmptystatetypesLastIngestedAt:
properties:
logs:
description: Null when no logs have been ingested.
format: date-time
nullable: true
type: string
metrics:
description: Null when no metrics have been ingested.
format: date-time
nullable: true
type: string
traces:
description: Null when no traces have been ingested.
format: date-time
nullable: true
type: string
required:
- logs
- traces
- metrics
type: object
EmptystatetypesOrgContext:
properties:
alertsCount:
type: integer
dashboardsCount:
type: integer
hasIngestedData:
type: boolean
lastIngestedAt:
$ref: '#/components/schemas/EmptystatetypesLastIngestedAt'
licenseStatus:
description: Raw Zeus license state. Known values include DEFAULTED, ACTIVATED,
EXPIRED, ISSUED, EVALUATING, EVALUATION_EXPIRED, TERMINATED, CANCELLED.
UNKNOWN is emitted when no license state is available.
type: string
savedViewsCount:
type: integer
required:
- hasIngestedData
- lastIngestedAt
- alertsCount
- dashboardsCount
- savedViewsCount
- licenseStatus
type: object
ErrorsJSON:
properties:
code:
@@ -9509,6 +9556,53 @@ paths:
summary: Update downtime schedule
tags:
- downtimeschedules
/api/v1/empty_state/org_context:
get:
deprecated: false
description: This endpoint returns raw org-level observability signals used
to render contextual empty states
operationId: GetOrgContext
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/EmptystatetypesOrgContext'
status:
type: string
required:
- status
- data
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Get org context for empty states
tags:
- emptystate
/api/v1/export_raw_data:
post:
deprecated: false

View File

@@ -0,0 +1,107 @@
/**
* ! Do not edit manually
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
*/
import { useQuery } from 'react-query';
import type {
InvalidateOptions,
QueryClient,
QueryFunction,
QueryKey,
UseQueryOptions,
UseQueryResult,
} from 'react-query';
import type {
GetOrgContext200,
RenderErrorResponseDTO,
} from '../sigNoz.schemas';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType } from '../../../generatedAPIInstance';
/**
* This endpoint returns raw org-level observability signals used to render contextual empty states
* @summary Get org context for empty states
*/
export const getOrgContext = (signal?: AbortSignal) => {
return GeneratedAPIInstance<GetOrgContext200>({
url: `/api/v1/empty_state/org_context`,
method: 'GET',
signal,
});
};
export const getGetOrgContextQueryKey = () => {
return [`/api/v1/empty_state/org_context`] as const;
};
export const getGetOrgContextQueryOptions = <
TData = Awaited<ReturnType<typeof getOrgContext>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getOrgContext>>,
TError,
TData
>;
}) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetOrgContextQueryKey();
const queryFn: QueryFunction<Awaited<ReturnType<typeof getOrgContext>>> = ({
signal,
}) => getOrgContext(signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getOrgContext>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetOrgContextQueryResult = NonNullable<
Awaited<ReturnType<typeof getOrgContext>>
>;
export type GetOrgContextQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get org context for empty states
*/
export function useGetOrgContext<
TData = Awaited<ReturnType<typeof getOrgContext>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getOrgContext>>,
TError,
TData
>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetOrgContextQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get org context for empty states
*/
export const invalidateGetOrgContext = async (
queryClient: QueryClient,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetOrgContextQueryKey() },
options,
);
return queryClient;
};

View File

@@ -4826,6 +4826,52 @@ export enum DashboardtypesVariablePluginKindDTO {
'signoz/QueryVariable' = 'signoz/QueryVariable',
'signoz/CustomVariable' = 'signoz/CustomVariable',
}
export interface EmptystatetypesLastIngestedAtDTO {
/**
* @type string,null
* @format date-time
* @description Null when no logs have been ingested.
*/
logs: string | null;
/**
* @type string,null
* @format date-time
* @description Null when no metrics have been ingested.
*/
metrics: string | null;
/**
* @type string,null
* @format date-time
* @description Null when no traces have been ingested.
*/
traces: string | null;
}
export interface EmptystatetypesOrgContextDTO {
/**
* @type integer
*/
alertsCount: number;
/**
* @type integer
*/
dashboardsCount: number;
/**
* @type boolean
*/
hasIngestedData: boolean;
lastIngestedAt: EmptystatetypesLastIngestedAtDTO;
/**
* @type string
* @description Raw Zeus license state. Known values include DEFAULTED, ACTIVATED, EXPIRED, ISSUED, EVALUATING, EVALUATION_EXPIRED, TERMINATED, CANCELLED. UNKNOWN is emitted when no license state is available.
*/
licenseStatus: string;
/**
* @type integer
*/
savedViewsCount: number;
}
export type FactoryResponseDTOServicesAnyOf = { [key: string]: string[] };
/**
@@ -9042,6 +9088,14 @@ export type GetDowntimeScheduleByID200 = {
export type UpdateDowntimeScheduleByIDPathParameters = {
id: string;
};
export type GetOrgContext200 = {
data: EmptystatetypesOrgContextDTO;
/**
* @type string
*/
status: string;
};
export type HandleExportRawDataPOSTParams = {
/**
* @enum csv,jsonl

View File

@@ -0,0 +1,34 @@
package signozapiserver
import (
"net/http"
"github.com/gorilla/mux"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/emptystatetypes"
)
func (provider *provider) addEmptyStateRoutes(router *mux.Router) error {
if err := router.Handle("/api/v1/empty_state/org_context", handler.New(
provider.authzMiddleware.ViewAccess(provider.statsReporterHandler.GetOrgContext),
handler.OpenAPIDef{
ID: "GetOrgContext",
Tags: []string{"emptystate"},
Summary: "Get org context for empty states",
Description: "This endpoint returns raw org-level observability signals used to render contextual empty states",
Request: nil,
RequestContentType: "",
Response: new(emptystatetypes.OrgContext),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
},
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
return nil
}

View File

@@ -31,6 +31,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/user"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/ruler"
"github.com/SigNoz/signoz/pkg/statsreporter"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/zeus"
@@ -57,6 +58,7 @@ type provider struct {
infraMonitoringHandler inframonitoring.Handler
gatewayHandler gateway.Handler
fieldsHandler fields.Handler
statsReporterHandler statsreporter.Handler
authzHandler authz.Handler
rawDataExportHandler rawdataexport.Handler
zeusHandler zeus.Handler
@@ -89,6 +91,7 @@ func NewFactory(
infraMonitoringHandler inframonitoring.Handler,
gatewayHandler gateway.Handler,
fieldsHandler fields.Handler,
statsReporterHandler statsreporter.Handler,
authzHandler authz.Handler,
rawDataExportHandler rawdataexport.Handler,
zeusHandler zeus.Handler,
@@ -124,6 +127,7 @@ func NewFactory(
infraMonitoringHandler,
gatewayHandler,
fieldsHandler,
statsReporterHandler,
authzHandler,
rawDataExportHandler,
zeusHandler,
@@ -161,6 +165,7 @@ func newProvider(
infraMonitoringHandler inframonitoring.Handler,
gatewayHandler gateway.Handler,
fieldsHandler fields.Handler,
statsReporterHandler statsreporter.Handler,
authzHandler authz.Handler,
rawDataExportHandler rawdataexport.Handler,
zeusHandler zeus.Handler,
@@ -197,6 +202,7 @@ func newProvider(
infraMonitoringHandler: infraMonitoringHandler,
gatewayHandler: gatewayHandler,
fieldsHandler: fieldsHandler,
statsReporterHandler: statsReporterHandler,
authzHandler: authzHandler,
rawDataExportHandler: rawDataExportHandler,
zeusHandler: zeusHandler,
@@ -286,6 +292,10 @@ func (provider *provider) AddToRouter(router *mux.Router) error {
return err
}
if err := provider.addEmptyStateRoutes(router); err != nil {
return err
}
if err := provider.addRawDataExportRoutes(router); err != nil {
return err
}

View File

@@ -17,8 +17,6 @@ var nodeNameGroupByKey = qbtypes.GroupByKey{
},
}
// nodesTableMetricNamesList drives the existence/retention check.
// Includes condition_ready and pod.phase also.
var nodesTableMetricNamesList = []string{
"k8s.node.cpu.usage",
"k8s.node.allocatable_cpu",

View File

@@ -49,6 +49,8 @@ import (
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/ruler"
"github.com/SigNoz/signoz/pkg/ruler/signozruler"
"github.com/SigNoz/signoz/pkg/statsreporter"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/zeus"
)
@@ -80,6 +82,7 @@ type Handlers struct {
TraceDetail tracedetail.Handler
RulerHandler ruler.Handler
LLMPricingRuleHandler llmpricingrule.Handler
StatsReporter statsreporter.Handler
}
func NewHandlers(
@@ -97,6 +100,7 @@ func NewHandlers(
registryHandler factory.Handler,
alertmanagerService alertmanager.Alertmanager,
rulerService ruler.Ruler,
telemetryStore telemetrystore.TelemetryStore,
) Handlers {
return Handlers{
SavedView: implsavedview.NewHandler(modules.SavedView),
@@ -125,5 +129,11 @@ func NewHandlers(
TraceDetail: impltracedetail.NewHandler(modules.TraceDetail),
RulerHandler: signozruler.NewHandler(rulerService),
LLMPricingRuleHandler: impllmpricingrule.NewHandler(modules.LLMPricingRule),
StatsReporter: statsreporter.NewHandler(telemetryStore, statsreporter.OrgContextCollectors{
Rules: rulerService,
Dashboards: modules.Dashboard,
SavedViews: modules.SavedView,
Licensing: licensing,
}),
}
}

View File

@@ -63,7 +63,7 @@ func TestNewHandlers(t *testing.T) {
querierHandler := querier.NewHandler(providerSettings, nil, nil)
registryHandler := factory.NewHandler(nil)
handlers := NewHandlers(modules, providerSettings, nil, querierHandler, nil, nil, nil, nil, nil, nil, nil, registryHandler, alertmanager, nil)
handlers := NewHandlers(modules, providerSettings, nil, querierHandler, nil, nil, nil, nil, nil, nil, nil, registryHandler, alertmanager, nil, nil)
reflectVal := reflect.ValueOf(handlers)
for i := 0; i < reflectVal.NumField(); i++ {
f := reflectVal.Field(i)

View File

@@ -36,6 +36,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/user"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/ruler"
"github.com/SigNoz/signoz/pkg/statsreporter"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/zeus"
"github.com/swaggest/jsonschema-go"
@@ -70,6 +71,7 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta
struct{ inframonitoring.Handler }{},
struct{ gateway.Handler }{},
struct{ fields.Handler }{},
struct{ statsreporter.Handler }{},
struct{ authz.Handler }{},
struct{ rawdataexport.Handler }{},
struct{ zeus.Handler }{},

View File

@@ -262,9 +262,9 @@ func NewSharderProviderFactories() factory.NamedMap[factory.ProviderFactory[shar
)
}
func NewStatsReporterProviderFactories(telemetryStore telemetrystore.TelemetryStore, collectors []statsreporter.StatsCollector, orgGetter organization.Getter, userGetter user.Getter, tokenizer tokenizer.Tokenizer, build version.Build, analyticsConfig analytics.Config) factory.NamedMap[factory.ProviderFactory[statsreporter.StatsReporter, statsreporter.Config]] {
func NewStatsReporterProviderFactories(collectors []statsreporter.StatsCollector, orgGetter organization.Getter, userGetter user.Getter, tokenizer tokenizer.Tokenizer, build version.Build, analyticsConfig analytics.Config) factory.NamedMap[factory.ProviderFactory[statsreporter.StatsReporter, statsreporter.Config]] {
return factory.MustNewNamedMap(
analyticsstatsreporter.NewFactory(telemetryStore, collectors, orgGetter, userGetter, tokenizer, build, analyticsConfig),
analyticsstatsreporter.NewFactory(collectors, orgGetter, userGetter, tokenizer, build, analyticsConfig),
noopstatsreporter.NewFactory(),
)
}
@@ -294,6 +294,7 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
handlers.InfraMonitoring,
handlers.GatewayHandler,
handlers.Fields,
handlers.StatsReporter,
handlers.AuthzHandler,
handlers.RawDataExport,
handlers.ZeusHandler,

View File

@@ -85,8 +85,7 @@ func TestNewProviderFactories(t *testing.T) {
userGetter := impluser.NewGetter(impluser.NewStore(sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual), instrumentationtest.New().ToProviderSettings()), userRoleStore, flagger)
orgGetter := implorganization.NewGetter(implorganization.NewStore(sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual)), nil)
telemetryStore := telemetrystoretest.New(telemetrystore.Config{Provider: "clickhouse"}, sqlmock.QueryMatcherEqual)
NewStatsReporterProviderFactories(telemetryStore, []statsreporter.StatsCollector{}, orgGetter, userGetter, tokenizertest.NewMockTokenizer(t), version.Build{}, analytics.Config{Enabled: true})
NewStatsReporterProviderFactories([]statsreporter.StatsCollector{}, orgGetter, userGetter, tokenizertest.NewMockTokenizer(t), version.Build{}, analytics.Config{Enabled: true})
})
assert.NotPanics(t, func() {

View File

@@ -499,6 +499,7 @@ func New(
serviceAccount,
cloudIntegrationModule,
modules.LogsPipeline,
statsreporter.NewTelemetryStatsCollector(telemetrystore),
}
// Initialize stats reporter from the available stats reporter provider factories
@@ -506,7 +507,7 @@ func New(
ctx,
providerSettings,
config.StatsReporter,
NewStatsReporterProviderFactories(telemetrystore, statsCollectors, orgGetter, userGetter, tokenizer, version.Info, config.Analytics),
NewStatsReporterProviderFactories(statsCollectors, orgGetter, userGetter, tokenizer, version.Info, config.Analytics),
config.StatsReporter.Provider(),
)
if err != nil {
@@ -535,7 +536,7 @@ func New(
// Initialize all handlers for the modules
registryHandler := factory.NewHandler(registry)
handlers := NewHandlers(modules, providerSettings, analytics, querierHandler, licensing, global, flagger, gateway, telemetryMetadataStore, authz, zeus, registryHandler, alertmanager, rulerInstance)
handlers := NewHandlers(modules, providerSettings, analytics, querierHandler, licensing, global, flagger, gateway, telemetryMetadataStore, authz, zeus, registryHandler, alertmanager, rulerInstance, telemetrystore)
// Initialize the API server (after registry so it can access service health)
apiserverInstance, err := factory.NewProviderFromNamedMap(

View File

@@ -16,7 +16,6 @@ import (
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/user"
"github.com/SigNoz/signoz/pkg/statsreporter"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/tokenizer"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
@@ -32,9 +31,6 @@ type provider struct {
// config
config statsreporter.Config
// used to get telemetry details. srikanthcvv to move this to the querier layer
telemetryStore telemetrystore.TelemetryStore
// a list of collectors, used to collect stats from across the codebase
collectors []statsreporter.StatsCollector
@@ -60,9 +56,9 @@ type provider struct {
stopC chan struct{}
}
func NewFactory(telemetryStore telemetrystore.TelemetryStore, collectors []statsreporter.StatsCollector, orgGetter organization.Getter, userGetter user.Getter, tokenizer tokenizer.Tokenizer, build version.Build, analyticsConfig analytics.Config) factory.ProviderFactory[statsreporter.StatsReporter, statsreporter.Config] {
func NewFactory(collectors []statsreporter.StatsCollector, orgGetter organization.Getter, userGetter user.Getter, tokenizer tokenizer.Tokenizer, build version.Build, analyticsConfig analytics.Config) factory.ProviderFactory[statsreporter.StatsReporter, statsreporter.Config] {
return factory.NewProviderFactory(factory.MustNewName("analytics"), func(ctx context.Context, settings factory.ProviderSettings, config statsreporter.Config) (statsreporter.StatsReporter, error) {
return New(ctx, settings, config, telemetryStore, collectors, orgGetter, userGetter, tokenizer, build, analyticsConfig)
return New(ctx, settings, config, collectors, orgGetter, userGetter, tokenizer, build, analyticsConfig)
})
}
@@ -70,7 +66,6 @@ func New(
ctx context.Context,
providerSettings factory.ProviderSettings,
config statsreporter.Config,
telemetryStore telemetrystore.TelemetryStore,
collectors []statsreporter.StatsCollector,
orgGetter organization.Getter,
userGetter user.Getter,
@@ -86,17 +81,16 @@ func New(
}
return &provider{
settings: settings,
config: config,
telemetryStore: telemetryStore,
collectors: collectors,
orgGetter: orgGetter,
userGetter: userGetter,
analytics: analytics,
tokenizer: tokenizer,
build: build,
deployment: deployment,
stopC: make(chan struct{}),
settings: settings,
config: config,
collectors: collectors,
orgGetter: orgGetter,
userGetter: userGetter,
analytics: analytics,
tokenizer: tokenizer,
build: build,
deployment: deployment,
stopC: make(chan struct{}),
}, nil
}
@@ -235,44 +229,5 @@ func (provider *provider) collectOrg(ctx context.Context, orgID valuer.UUID) map
}
wg.Wait()
var traces uint64
if err := provider.telemetryStore.ClickhouseDB().QueryRow(ctx, "SELECT COUNT(*) FROM signoz_traces.distributed_signoz_index_v3").Scan(&traces); err == nil {
stats["telemetry.traces.count"] = traces
}
var logs uint64
if err := provider.telemetryStore.ClickhouseDB().QueryRow(ctx, "SELECT COUNT(*) FROM signoz_logs.distributed_logs_v2").Scan(&logs); err == nil {
stats["telemetry.logs.count"] = logs
}
var metrics uint64
if err := provider.telemetryStore.ClickhouseDB().QueryRow(ctx, "SELECT COUNT(*) FROM signoz_metrics.distributed_samples_v4").Scan(&metrics); err == nil {
stats["telemetry.metrics.count"] = metrics
}
var tracesLastSeenAt time.Time
if err := provider.telemetryStore.ClickhouseDB().QueryRow(ctx, "SELECT max(timestamp) FROM signoz_traces.distributed_signoz_index_v3").Scan(&tracesLastSeenAt); err == nil {
if tracesLastSeenAt.Unix() != 0 {
stats["telemetry.traces.last_observed.time"] = tracesLastSeenAt.UTC()
stats["telemetry.traces.last_observed.time_unix"] = tracesLastSeenAt.Unix()
}
}
var logsLastSeenAt time.Time
if err := provider.telemetryStore.ClickhouseDB().QueryRow(ctx, "SELECT fromUnixTimestamp64Nano(max(timestamp)) FROM signoz_logs.distributed_logs_v2").Scan(&logsLastSeenAt); err == nil {
if logsLastSeenAt.Unix() != 0 {
stats["telemetry.logs.last_observed.time"] = logsLastSeenAt.UTC()
stats["telemetry.logs.last_observed.time_unix"] = logsLastSeenAt.Unix()
}
}
var metricsLastSeenAt time.Time
if err := provider.telemetryStore.ClickhouseDB().QueryRow(ctx, "SELECT toDateTime(max(unix_milli) / 1000) FROM signoz_metrics.distributed_samples_v4").Scan(&metricsLastSeenAt); err == nil {
if metricsLastSeenAt.Unix() != 0 {
stats["telemetry.metrics.last_observed.time"] = metricsLastSeenAt.UTC()
stats["telemetry.metrics.last_observed.time_unix"] = metricsLastSeenAt.Unix()
}
}
return stats
}

View File

@@ -0,0 +1,200 @@
package statsreporter
import (
"context"
"net/http"
"strings"
"time"
"golang.org/x/sync/errgroup"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/types/emptystatetypes"
"github.com/SigNoz/signoz/pkg/types/licensetypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/types/savedviewtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type Handler interface {
GetOrgContext(rw http.ResponseWriter, req *http.Request)
}
// OrgContextCollectors are the collectors the org context signals are sourced from.
type OrgContextCollectors struct {
Rules StatsCollector
Dashboards StatsCollector
SavedViews StatsCollector
Licensing StatsCollector
}
type handler struct {
telemetryStore telemetrystore.TelemetryStore
collectors OrgContextCollectors
}
func NewHandler(telemetryStore telemetrystore.TelemetryStore, collectors OrgContextCollectors) Handler {
return &handler{
telemetryStore: telemetryStore,
collectors: collectors,
}
}
func (h *handler) GetOrgContext(rw http.ResponseWriter, req *http.Request) {
claims, err := authtypes.ClaimsFromContext(req.Context())
if err != nil {
render.Error(rw, err)
return
}
orgContext, err := h.getOrgContext(req.Context(), valuer.MustNewUUID(claims.OrgID))
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, orgContext)
}
func (h *handler) getOrgContext(ctx context.Context, orgID valuer.UUID) (*emptystatetypes.OrgContext, error) {
var logsLastIngestedAt *time.Time
var tracesLastIngestedAt *time.Time
var metricsLastIngestedAt *time.Time
var alertsCount int
var dashboardsCount int
var savedViewsCount int
licenseStatus := emptystatetypes.LicenseStatusUnknown
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
lastIngestedAt, err := lastObservedLogs(gCtx, h.telemetryStore)
if err != nil {
return err
}
logsLastIngestedAt = lastIngestedAt
return nil
})
g.Go(func() error {
lastIngestedAt, err := lastObservedTraces(gCtx, h.telemetryStore)
if err != nil {
return err
}
tracesLastIngestedAt = lastIngestedAt
return nil
})
g.Go(func() error {
lastIngestedAt, err := lastObservedMetrics(gCtx, h.telemetryStore)
if err != nil {
return err
}
metricsLastIngestedAt = lastIngestedAt
return nil
})
g.Go(func() error {
var err error
alertsCount, err = h.getCollectedCount(gCtx, h.collectors.Rules, ruletypes.StatKeyRuleCount, orgID)
if err != nil {
return err
}
return nil
})
g.Go(func() error {
var err error
dashboardsCount, err = h.getCollectedCount(gCtx, h.collectors.Dashboards, dashboardtypes.StatKeyDashboardCount, orgID)
if err != nil {
return err
}
return nil
})
g.Go(func() error {
var err error
savedViewsCount, err = h.getCollectedCount(gCtx, h.collectors.SavedViews, savedviewtypes.StatKeySavedViewCount, orgID)
if err != nil {
return err
}
return nil
})
g.Go(func() error {
licenseStatus = h.getLicenseStatus(gCtx, orgID)
return nil
})
if err := g.Wait(); err != nil {
return nil, err
}
lastIngestedAt := emptystatetypes.LastIngestedAt{
Logs: logsLastIngestedAt,
Traces: tracesLastIngestedAt,
Metrics: metricsLastIngestedAt,
}
return &emptystatetypes.OrgContext{
HasIngestedData: lastIngestedAt.Logs != nil || lastIngestedAt.Traces != nil || lastIngestedAt.Metrics != nil,
LastIngestedAt: lastIngestedAt,
AlertsCount: alertsCount,
DashboardsCount: dashboardsCount,
SavedViewsCount: savedViewsCount,
LicenseStatus: licenseStatus,
}, nil
}
func (h *handler) getCollectedCount(ctx context.Context, collector StatsCollector, key string, orgID valuer.UUID) (int, error) {
if collector == nil {
return 0, errors.NewInternalf(errors.CodeInternal, "collector for %q is not configured", key)
}
stats, err := collector.Collect(ctx, orgID)
if err != nil {
return 0, errors.WrapInternalf(err, errors.CodeInternal, "failed to collect %q", key)
}
count, ok := stats[key].(int64)
if !ok {
return 0, errors.NewInternalf(errors.CodeInternal, "stat %q is missing from collector output", key)
}
return int(count), nil
}
// License stats degrade to UNKNOWN: community wires nooplicensing (empty stats)
// and a licensing outage must not fail the endpoint.
func (h *handler) getLicenseStatus(ctx context.Context, orgID valuer.UUID) emptystatetypes.LicenseStatus {
if h.collectors.Licensing == nil {
return emptystatetypes.LicenseStatusUnknown
}
stats, err := h.collectors.Licensing.Collect(ctx, orgID)
if err != nil {
return emptystatetypes.LicenseStatusUnknown
}
return licenseStatusFromStats(stats)
}
func licenseStatusFromStats(stats map[string]any) emptystatetypes.LicenseStatus {
state, ok := stats[licensetypes.StatKeyLicenseStateName].(string)
if !ok || strings.TrimSpace(state) == "" {
return emptystatetypes.LicenseStatusUnknown
}
// Verbatim passthrough: trimming above is only for blank detection.
return emptystatetypes.LicenseStatus(state)
}

View File

@@ -0,0 +1,268 @@
package statsreporter
import (
"context"
"regexp"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
cmock "github.com/SigNoz/clickhouse-go-mock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/types/emptystatetypes"
"github.com/SigNoz/signoz/pkg/types/licensetypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/types/savedviewtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
var testOrgID = valuer.MustNewUUID("00000000-0000-0000-0000-000000000001")
// Exact SQL the query builders must generate; mock expectations match on these
// so any change to the statements fails the suite.
const (
logsLastObservedSQL = "SELECT fromUnixTimestamp64Nano(max(timestamp)) FROM signoz_logs.distributed_logs_v2"
tracesLastObservedSQL = "SELECT max(timestamp) FROM signoz_traces.distributed_signoz_index_v3"
metricsLastObservedSQL = "SELECT toDateTime(max(unix_milli) / 1000) FROM signoz_metrics.distributed_samples_v4"
)
type fakeCollector struct {
stats map[string]any
err error
}
func (f *fakeCollector) Collect(context.Context, valuer.UUID) (map[string]any, error) {
return f.stats, f.err
}
func okCollectors() OrgContextCollectors {
return OrgContextCollectors{
Rules: &fakeCollector{stats: map[string]any{ruletypes.StatKeyRuleCount: int64(0)}},
Dashboards: &fakeCollector{stats: map[string]any{dashboardtypes.StatKeyDashboardCount: int64(0)}},
SavedViews: &fakeCollector{stats: map[string]any{savedviewtypes.StatKeySavedViewCount: int64(0)}},
Licensing: &fakeCollector{stats: map[string]any{}},
}
}
func TestLicenseStatusFromStats(t *testing.T) {
cases := []struct {
name string
stats map[string]any
want emptystatetypes.LicenseStatus
}{
{
name: "known state passes through",
stats: map[string]any{licensetypes.StatKeyLicenseStateName: "ACTIVATED"},
want: emptystatetypes.LicenseStatus("ACTIVATED"),
},
{
name: "novel state passes through",
stats: map[string]any{licensetypes.StatKeyLicenseStateName: "FUTURE_STATE"},
want: emptystatetypes.LicenseStatus("FUTURE_STATE"),
},
{
name: "missing key returns unknown",
stats: map[string]any{},
want: emptystatetypes.LicenseStatusUnknown,
},
{
name: "blank state returns unknown",
stats: map[string]any{licensetypes.StatKeyLicenseStateName: " "},
want: emptystatetypes.LicenseStatusUnknown,
},
{
name: "non-string state returns unknown",
stats: map[string]any{licensetypes.StatKeyLicenseStateName: 42},
want: emptystatetypes.LicenseStatusUnknown,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
status := licenseStatusFromStats(tc.stats)
assert.Equal(t, tc.want, status)
})
}
}
func TestGetLicenseStatusDegradesToUnknown(t *testing.T) {
t.Run("collector error", func(t *testing.T) {
h := &handler{collectors: OrgContextCollectors{Licensing: &fakeCollector{err: assert.AnError}}}
status := h.getLicenseStatus(context.Background(), testOrgID)
assert.Equal(t, emptystatetypes.LicenseStatusUnknown, status)
})
t.Run("nil collector", func(t *testing.T) {
h := &handler{}
status := h.getLicenseStatus(context.Background(), testOrgID)
assert.Equal(t, emptystatetypes.LicenseStatusUnknown, status)
})
}
func TestGetCollectedCount(t *testing.T) {
h := &handler{}
t.Run("missing key fails", func(t *testing.T) {
_, err := h.getCollectedCount(context.Background(), &fakeCollector{stats: map[string]any{}}, ruletypes.StatKeyRuleCount, testOrgID)
assert.Error(t, err)
})
t.Run("nil collector fails", func(t *testing.T) {
_, err := h.getCollectedCount(context.Background(), nil, ruletypes.StatKeyRuleCount, testOrgID)
assert.Error(t, err)
})
}
func TestGetOrgContextDerivesAggregates(t *testing.T) {
lastIngested := time.Unix(5000, 0).UTC()
cases := []struct {
name string
logs bool
traces bool
metrics bool
}{
{name: "logs only", logs: true},
{name: "traces only", traces: true},
{name: "metrics only", metrics: true},
{name: "nothing ingested"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
h, chMock := newOrgContextTestHandler(t, OrgContextCollectors{
Rules: &fakeCollector{stats: map[string]any{ruletypes.StatKeyRuleCount: int64(2)}},
Dashboards: &fakeCollector{stats: map[string]any{dashboardtypes.StatKeyDashboardCount: int64(1)}},
SavedViews: &fakeCollector{stats: map[string]any{savedviewtypes.StatKeySavedViewCount: int64(3)}},
Licensing: &fakeCollector{stats: map[string]any{licensetypes.StatKeyLicenseStateName: "ACTIVATED"}},
})
if tc.logs {
expectLogsLastIngested(chMock, lastIngested)
} else {
expectLogsLastIngested(chMock, time.Time{})
}
if tc.traces {
expectTracesLastIngested(chMock, lastIngested)
} else {
expectTracesLastIngested(chMock, time.Time{})
}
if tc.metrics {
expectMetricsLastIngested(chMock, lastIngested)
} else {
expectMetricsLastIngested(chMock, time.Time{})
}
orgContext, err := h.getOrgContext(context.Background(), testOrgID)
require.NoError(t, err)
assert.Equal(t, tc.logs || tc.traces || tc.metrics, orgContext.HasIngestedData)
assertLastIngested(t, tc.logs, orgContext.LastIngestedAt.Logs, lastIngested)
assertLastIngested(t, tc.traces, orgContext.LastIngestedAt.Traces, lastIngested)
assertLastIngested(t, tc.metrics, orgContext.LastIngestedAt.Metrics, lastIngested)
assert.Equal(t, 2, orgContext.AlertsCount)
assert.Equal(t, 1, orgContext.DashboardsCount)
assert.Equal(t, 3, orgContext.SavedViewsCount)
assert.Equal(t, emptystatetypes.LicenseStatus("ACTIVATED"), orgContext.LicenseStatus)
assert.NoError(t, chMock.ExpectationsWereMet())
})
}
}
func assertLastIngested(t *testing.T, ingested bool, got *time.Time, want time.Time) {
t.Helper()
if !ingested {
assert.Nil(t, got)
return
}
require.NotNil(t, got)
assert.Equal(t, want, *got)
}
func TestGetOrgContextLicenseErrorDoesNotFail(t *testing.T) {
collectors := okCollectors()
collectors.Licensing = &fakeCollector{err: assert.AnError}
h, chMock := newOrgContextTestHandler(t, collectors)
expectTelemetryQuiet(chMock)
orgContext, err := h.getOrgContext(context.Background(), testOrgID)
require.NoError(t, err)
assert.Equal(t, emptystatetypes.LicenseStatusUnknown, orgContext.LicenseStatus)
assert.NoError(t, chMock.ExpectationsWereMet())
}
func TestGetOrgContextCollectorErrorFails(t *testing.T) {
collectors := okCollectors()
collectors.Rules = &fakeCollector{err: assert.AnError}
h, chMock := newOrgContextTestHandler(t, collectors)
expectTelemetryQuiet(chMock)
orgContext, err := h.getOrgContext(context.Background(), testOrgID)
assert.Error(t, err)
assert.Nil(t, orgContext)
}
func TestGetOrgContextClickHouseErrorFails(t *testing.T) {
h, chMock := newOrgContextTestHandler(t, okCollectors())
chMock.ExpectQueryRow(regexp.QuoteMeta(logsLastObservedSQL)).
WillReturnRow(cmock.NewRow([]cmock.ColumnType{{Name: "max(timestamp)", Type: "DateTime64(9)"}}, nil)).
WillReturnError(assert.AnError)
expectTracesLastIngested(chMock, time.Time{})
expectMetricsLastIngested(chMock, time.Time{})
orgContext, err := h.getOrgContext(context.Background(), testOrgID)
assert.Error(t, err)
assert.Nil(t, orgContext)
}
func newOrgContextTestHandler(t *testing.T, collectors OrgContextCollectors) (*handler, cmock.ClickConnMockCommon) {
t.Helper()
ts := telemetrystoretest.New(telemetrystore.Config{}, sqlmock.QueryMatcherRegexp)
ts.Mock().MatchExpectationsInOrder(false)
return &handler{
telemetryStore: ts,
collectors: collectors,
}, ts.Mock()
}
func expectTelemetryQuiet(mock cmock.ClickConnMockCommon) {
expectLogsLastIngested(mock, time.Time{})
expectTracesLastIngested(mock, time.Time{})
expectMetricsLastIngested(mock, time.Time{})
}
func expectLogsLastIngested(mock cmock.ClickConnMockCommon, lastIngestedAt time.Time) {
mock.ExpectQueryRow(regexp.QuoteMeta(logsLastObservedSQL)).
WillReturnRow(cmock.NewRow([]cmock.ColumnType{{Name: "max(timestamp)", Type: "DateTime64(9)"}}, []any{lastIngestedAt}))
}
func expectTracesLastIngested(mock cmock.ClickConnMockCommon, lastIngestedAt time.Time) {
mock.ExpectQueryRow(regexp.QuoteMeta(tracesLastObservedSQL)).
WillReturnRow(cmock.NewRow([]cmock.ColumnType{{Name: "max(timestamp)", Type: "DateTime64(9)"}}, []any{lastIngestedAt}))
}
func expectMetricsLastIngested(mock cmock.ClickConnMockCommon, lastIngestedAt time.Time) {
mock.ExpectQueryRow(regexp.QuoteMeta(metricsLastObservedSQL)).
WillReturnRow(cmock.NewRow([]cmock.ColumnType{{Name: "toDateTime(divide(max(unix_milli), 1000))", Type: "DateTime"}}, []any{lastIngestedAt}))
}

View File

@@ -0,0 +1,50 @@
package statsreporter
import (
"context"
"database/sql"
"fmt"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/telemetrylogs"
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/telemetrytraces"
)
// Last-observed queries match the previous analytics provider queries. They are
// deployment-scoped since telemetry tables have no org column.
func lastObservedLogs(ctx context.Context, telemetryStore telemetrystore.TelemetryStore) (*time.Time, error) {
query := fmt.Sprintf("SELECT fromUnixTimestamp64Nano(max(timestamp)) FROM %s.%s", telemetrylogs.DBName, telemetrylogs.LogsV2TableName)
return scanLastObserved(ctx, telemetryStore, "logs", query)
}
func lastObservedTraces(ctx context.Context, telemetryStore telemetrystore.TelemetryStore) (*time.Time, error) {
query := fmt.Sprintf("SELECT max(timestamp) FROM %s.%s", telemetrytraces.DBName, telemetrytraces.SpanIndexV3TableName)
return scanLastObserved(ctx, telemetryStore, "traces", query)
}
func lastObservedMetrics(ctx context.Context, telemetryStore telemetrystore.TelemetryStore) (*time.Time, error) {
query := fmt.Sprintf("SELECT toDateTime(max(unix_milli) / 1000) FROM %s.%s", telemetrymetrics.DBName, telemetrymetrics.SamplesV4TableName)
return scanLastObserved(ctx, telemetryStore, "metrics", query)
}
func scanLastObserved(ctx context.Context, telemetryStore telemetrystore.TelemetryStore, signal string, query string, args ...any) (*time.Time, error) {
var lastObserved time.Time
err := telemetryStore.ClickhouseDB().QueryRow(ctx, query, args...).Scan(&lastObserved)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil //nolint:nilnil
}
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to check %s last observed", signal)
}
if lastObserved.Unix() <= 0 {
return nil, nil //nolint:nilnil
}
lastObservedAt := lastObserved.UTC()
return &lastObservedAt, nil
}

View File

@@ -0,0 +1,58 @@
package statsreporter
import (
"context"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/valuer"
)
// telemetryStatsCollector collects telemetry usage stats (counts and
// last-observed times per signal). Stats are deployment-scoped since telemetry
// tables carry no org column.
type telemetryStatsCollector struct {
telemetryStore telemetrystore.TelemetryStore
}
func NewTelemetryStatsCollector(telemetryStore telemetrystore.TelemetryStore) StatsCollector {
return &telemetryStatsCollector{telemetryStore: telemetryStore}
}
func (collector *telemetryStatsCollector) Collect(ctx context.Context, _ valuer.UUID) (map[string]any, error) {
stats := make(map[string]any)
if traces, err := collector.countRows(ctx, "SELECT COUNT(*) FROM signoz_traces.distributed_signoz_index_v3"); err == nil {
stats["telemetry.traces.count"] = traces
}
if logs, err := collector.countRows(ctx, "SELECT COUNT(*) FROM signoz_logs.distributed_logs_v2"); err == nil {
stats["telemetry.logs.count"] = logs
}
if metrics, err := collector.countRows(ctx, "SELECT COUNT(*) FROM signoz_metrics.distributed_samples_v4"); err == nil {
stats["telemetry.metrics.count"] = metrics
}
if tracesLastSeenAt, err := lastObservedTraces(ctx, collector.telemetryStore); err == nil && tracesLastSeenAt != nil {
stats["telemetry.traces.last_observed.time"] = tracesLastSeenAt.UTC()
stats["telemetry.traces.last_observed.time_unix"] = tracesLastSeenAt.Unix()
}
if logsLastSeenAt, err := lastObservedLogs(ctx, collector.telemetryStore); err == nil && logsLastSeenAt != nil {
stats["telemetry.logs.last_observed.time"] = logsLastSeenAt.UTC()
stats["telemetry.logs.last_observed.time_unix"] = logsLastSeenAt.Unix()
}
if metricsLastSeenAt, err := lastObservedMetrics(ctx, collector.telemetryStore); err == nil && metricsLastSeenAt != nil {
stats["telemetry.metrics.last_observed.time"] = metricsLastSeenAt.UTC()
stats["telemetry.metrics.last_observed.time_unix"] = metricsLastSeenAt.Unix()
}
return stats, nil
}
func (collector *telemetryStatsCollector) countRows(ctx context.Context, query string) (uint64, error) {
var count uint64
err := collector.telemetryStore.ClickhouseDB().QueryRow(ctx, query).Scan(&count)
return count, err
}

View File

@@ -0,0 +1,138 @@
package statsreporter
import (
"context"
"regexp"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
cmock "github.com/SigNoz/clickhouse-go-mock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
)
// Exact SQL the count queries must generate; see the matching last-observed
// constants in handler_test.go.
const (
tracesCountSQL = "SELECT COUNT(*) FROM signoz_traces.distributed_signoz_index_v3"
logsCountSQL = "SELECT COUNT(*) FROM signoz_logs.distributed_logs_v2"
metricsCountSQL = "SELECT COUNT(*) FROM signoz_metrics.distributed_samples_v4"
)
func TestTelemetryStatsCollectorCollect(t *testing.T) {
collector, chMock := newTelemetryStatsCollectorTest(t)
tracesAt := time.Date(2026, 6, 10, 10, 0, 0, 0, time.UTC)
logsAt := time.Date(2026, 6, 10, 11, 0, 0, 0, time.UTC)
metricsAt := time.Date(2026, 6, 10, 12, 0, 0, 0, time.UTC)
expectTelemetryCount(chMock, tracesCountSQL, 5)
expectTelemetryCount(chMock, logsCountSQL, 7)
expectTelemetryCount(chMock, metricsCountSQL, 9)
expectTracesLastIngested(chMock, tracesAt)
expectLogsLastIngested(chMock, logsAt)
expectMetricsLastIngested(chMock, metricsAt)
stats, err := collector.Collect(context.Background(), testOrgID)
require.NoError(t, err)
assert.Equal(t, uint64(5), stats["telemetry.traces.count"])
assert.Equal(t, uint64(7), stats["telemetry.logs.count"])
assert.Equal(t, uint64(9), stats["telemetry.metrics.count"])
assert.Equal(t, tracesAt, stats["telemetry.traces.last_observed.time"])
assert.Equal(t, tracesAt.Unix(), stats["telemetry.traces.last_observed.time_unix"])
assert.Equal(t, logsAt, stats["telemetry.logs.last_observed.time"])
assert.Equal(t, logsAt.Unix(), stats["telemetry.logs.last_observed.time_unix"])
assert.Equal(t, metricsAt, stats["telemetry.metrics.last_observed.time"])
assert.Equal(t, metricsAt.Unix(), stats["telemetry.metrics.last_observed.time_unix"])
assert.NoError(t, chMock.ExpectationsWereMet())
}
func TestTelemetryStatsCollectorOmitsQuietLastObserved(t *testing.T) {
collector, chMock := newTelemetryStatsCollectorTest(t)
expectTelemetryCount(chMock, tracesCountSQL, 0)
expectTelemetryCount(chMock, logsCountSQL, 0)
expectTelemetryCount(chMock, metricsCountSQL, 0)
expectTracesLastIngested(chMock, time.Time{})
expectLogsLastIngested(chMock, time.Time{})
expectMetricsLastIngested(chMock, time.Time{})
stats, err := collector.Collect(context.Background(), testOrgID)
require.NoError(t, err)
assert.Equal(t, map[string]any{
"telemetry.traces.count": uint64(0),
"telemetry.logs.count": uint64(0),
"telemetry.metrics.count": uint64(0),
}, stats)
assert.NoError(t, chMock.ExpectationsWereMet())
}
func TestTelemetryStatsCollectorCountErrorOmitsOnlyFailedKey(t *testing.T) {
collector, chMock := newTelemetryStatsCollectorTest(t)
tracesAt := time.Date(2026, 6, 10, 10, 0, 0, 0, time.UTC)
chMock.ExpectQueryRow(regexp.QuoteMeta(tracesCountSQL)).
WillReturnRow(cmock.NewRow([]cmock.ColumnType{{Name: "count()", Type: "UInt64"}}, nil)).
WillReturnError(assert.AnError)
expectTelemetryCount(chMock, logsCountSQL, 7)
expectTelemetryCount(chMock, metricsCountSQL, 9)
expectTracesLastIngested(chMock, tracesAt)
expectLogsLastIngested(chMock, time.Time{})
expectMetricsLastIngested(chMock, time.Time{})
stats, err := collector.Collect(context.Background(), testOrgID)
require.NoError(t, err)
assert.NotContains(t, stats, "telemetry.traces.count")
assert.Equal(t, uint64(7), stats["telemetry.logs.count"])
assert.Equal(t, uint64(9), stats["telemetry.metrics.count"])
assert.Equal(t, tracesAt, stats["telemetry.traces.last_observed.time"])
assert.NoError(t, chMock.ExpectationsWereMet())
}
func TestTelemetryStatsCollectorLastObservedErrorOmitsOnlyFailedKeys(t *testing.T) {
collector, chMock := newTelemetryStatsCollectorTest(t)
logsAt := time.Date(2026, 6, 10, 11, 0, 0, 0, time.UTC)
metricsAt := time.Date(2026, 6, 10, 12, 0, 0, 0, time.UTC)
expectTelemetryCount(chMock, tracesCountSQL, 1)
expectTelemetryCount(chMock, logsCountSQL, 1)
expectTelemetryCount(chMock, metricsCountSQL, 1)
chMock.ExpectQueryRow(regexp.QuoteMeta(tracesLastObservedSQL)).
WillReturnRow(cmock.NewRow([]cmock.ColumnType{{Name: "max(timestamp)", Type: "DateTime64(9)"}}, nil)).
WillReturnError(assert.AnError)
expectLogsLastIngested(chMock, logsAt)
expectMetricsLastIngested(chMock, metricsAt)
stats, err := collector.Collect(context.Background(), testOrgID)
require.NoError(t, err)
assert.Equal(t, uint64(1), stats["telemetry.traces.count"])
assert.Equal(t, uint64(1), stats["telemetry.logs.count"])
assert.Equal(t, uint64(1), stats["telemetry.metrics.count"])
assert.NotContains(t, stats, "telemetry.traces.last_observed.time")
assert.NotContains(t, stats, "telemetry.traces.last_observed.time_unix")
assert.Equal(t, logsAt, stats["telemetry.logs.last_observed.time"])
assert.Equal(t, metricsAt, stats["telemetry.metrics.last_observed.time"])
assert.NoError(t, chMock.ExpectationsWereMet())
}
func newTelemetryStatsCollectorTest(t *testing.T) (StatsCollector, cmock.ClickConnMockCommon) {
t.Helper()
ts := telemetrystoretest.New(telemetrystore.Config{}, sqlmock.QueryMatcherRegexp)
ts.Mock().MatchExpectationsInOrder(false)
return NewTelemetryStatsCollector(ts), ts.Mock()
}
func expectTelemetryCount(mock cmock.ClickConnMockCommon, query string, count uint64) {
mock.ExpectQueryRow(regexp.QuoteMeta(query)).
WillReturnRow(cmock.NewRow([]cmock.ColumnType{{Name: "count()", Type: "UInt64"}}, []any{count}))
}

View File

@@ -168,6 +168,9 @@ func NewGettableDashboardFromDashboard(dashboard *Dashboard) (*GettableDashboard
}, nil
}
// StatKeyDashboardCount is the dashboard count stat key shared by the stats reporter and its API consumers.
const StatKeyDashboardCount = "dashboard.count"
func NewStatsFromStorableDashboards(dashboards []*StorableDashboard) map[string]any {
stats := make(map[string]any)
stats["dashboard.panels.count"] = int64(0)
@@ -178,7 +181,7 @@ func NewStatsFromStorableDashboards(dashboards []*StorableDashboard) map[string]
addStatsFromStorableDashboard(dashboard, stats)
}
stats["dashboard.count"] = int64(len(dashboards))
stats[StatKeyDashboardCount] = int64(len(dashboards))
return stats
}

View File

@@ -0,0 +1,27 @@
package emptystatetypes
import "time"
type LicenseStatus string
const LicenseStatusUnknown LicenseStatus = "UNKNOWN"
func (status LicenseStatus) StringValue() string {
return string(status)
}
type OrgContext struct {
HasIngestedData bool `json:"hasIngestedData" required:"true"`
LastIngestedAt LastIngestedAt `json:"lastIngestedAt" required:"true"`
AlertsCount int `json:"alertsCount" required:"true"`
DashboardsCount int `json:"dashboardsCount" required:"true"`
SavedViewsCount int `json:"savedViewsCount" required:"true"`
LicenseStatus LicenseStatus `json:"licenseStatus" required:"true" description:"Raw Zeus license state. Known values include DEFAULTED, ACTIVATED, EXPIRED, ISSUED, EVALUATING, EVALUATION_EXPIRED, TERMINATED, CANCELLED. UNKNOWN is emitted when no license state is available."`
}
// LastIngestedAt carries per-signal latest ingest times; null means no data has been observed.
type LastIngestedAt struct {
Logs *time.Time `json:"logs" required:"true" description:"Null when no logs have been ingested."`
Traces *time.Time `json:"traces" required:"true" description:"Null when no traces have been ingested."`
Metrics *time.Time `json:"metrics" required:"true" description:"Null when no metrics have been ingested."`
}

View File

@@ -0,0 +1,40 @@
package emptystatetypes
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
)
func TestLicenseStatusMarshalJSON(t *testing.T) {
tests := []struct {
name string
status LicenseStatus
want string
}{
{
name: "known zeus state preserves case",
status: LicenseStatus("ACTIVATED"),
want: `"ACTIVATED"`,
},
{
name: "unknown sentinel preserves case",
status: LicenseStatusUnknown,
want: `"UNKNOWN"`,
},
{
name: "novel state passes through",
status: LicenseStatus("FUTURE_STATE"),
want: `"FUTURE_STATE"`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := json.Marshal(tt.status)
assert.NoError(t, err)
assert.Equal(t, tt.want, string(got))
})
}
}

View File

@@ -358,11 +358,14 @@ func NewLicenseFromStorableLicense(storableLicense *StorableLicense) (*License,
}
// StatKeyLicenseStateName is the license state stat key shared by the stats reporter and its API consumers.
const StatKeyLicenseStateName = "license.state.name"
func NewStatsFromLicense(license *License) map[string]any {
return map[string]any{
"license.id": license.ID.StringValue(),
"license.plan.name": license.PlanName.StringValue(),
"license.state.name": license.State,
StatKeyLicenseStateName: license.State,
"license.free_until.time": license.FreeUntil.UTC(),
}
}

View File

@@ -20,6 +20,9 @@ type StorableRule struct {
OrgID string `bun:"org_id,type:text"`
}
// StatKeyRuleCount is the rule count stat key shared by the stats reporter and its API consumers.
const StatKeyRuleCount = "rule.count"
func NewStatsFromRules(rules []*StorableRule) map[string]any {
stats := make(map[string]any)
for _, rule := range rules {
@@ -43,7 +46,7 @@ func NewStatsFromRules(rules []*StorableRule) map[string]any {
}
}
stats["rule.count"] = int64(len(rules))
stats[StatKeyRuleCount] = int64(len(rules))
return stats
}

View File

@@ -22,6 +22,9 @@ type SavedView struct {
ExtraData string `json:"extraData" bun:"extra_data,type:text"`
}
// StatKeySavedViewCount is the saved view count stat key shared by the stats reporter and its API consumers.
const StatKeySavedViewCount = "savedview.count"
func NewStatsFromSavedViews(savedViews []*SavedView) map[string]any {
stats := make(map[string]any)
for _, savedView := range savedViews {
@@ -33,6 +36,6 @@ func NewStatsFromSavedViews(savedViews []*SavedView) map[string]any {
}
}
stats["savedview.count"] = int64(len(savedViews))
stats[StatKeySavedViewCount] = int64(len(savedViews))
return stats
}

View File

@@ -0,0 +1,71 @@
from collections.abc import Callable
from http import HTTPStatus
import requests
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
ORG_CONTEXT_PATH = "/api/v1/empty_state/org_context"
def test_get_org_context_unauthenticated(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
):
response = requests.get(
signoz.self.host_configs["8080"].get(ORG_CONTEXT_PATH),
timeout=2,
)
assert response.status_code == HTTPStatus.UNAUTHORIZED
def test_get_org_context_without_license(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get(ORG_CONTEXT_PATH),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
data = response.json()["data"]
assert isinstance(data["hasIngestedData"], bool)
# Fresh stack has no telemetry, so every last-ingested timestamp is null.
assert data["lastIngestedAt"]["logs"] is None
assert data["lastIngestedAt"]["traces"] is None
assert data["lastIngestedAt"]["metrics"] is None
assert data["hasIngestedData"] is False
assert data["alertsCount"] == 0
assert data["dashboardsCount"] == 0
assert data["savedViewsCount"] == 0
# No license registered yet, so the sentinel is returned.
assert data["licenseStatus"] == "UNKNOWN"
def test_get_org_context_with_license(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
apply_license: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get(ORG_CONTEXT_PATH),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
# The Zeus mock issues a license with state EVALUATING; the API passes it
# through verbatim rather than mapping it to a trial/paid vocabulary.
assert response.json()["data"]["licenseStatus"] == "EVALUATING"