Compare commits

..

1 Commits

Author SHA1 Message Date
swapnil-signoz
e6d8713e1c feat: adding support for Azure SQL DB 2026-06-11 15:19:52 +05:30
32 changed files with 3057 additions and 1202 deletions

View File

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

2
.gitignore vendored
View File

@@ -40,8 +40,6 @@ 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

@@ -1364,6 +1364,7 @@ components:
- appservice
- containerapp
- aks
- sqldatabase
type: string
CloudintegrationtypesServiceMetadata:
properties:
@@ -3355,53 +3356,6 @@ 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:
@@ -9556,53 +9510,6 @@ 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

@@ -1,107 +0,0 @@
/**
* ! 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

@@ -2655,6 +2655,7 @@ export enum CloudintegrationtypesServiceIDDTO {
appservice = 'appservice',
containerapp = 'containerapp',
aks = 'aks',
sqldatabase = 'sqldatabase',
}
export type CloudintegrationtypesCloudIntegrationServiceDTOAnyOf = {
/**
@@ -4826,52 +4827,6 @@ 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[] };
/**
@@ -9088,14 +9043,6 @@ 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

@@ -1,34 +0,0 @@
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,7 +31,6 @@ 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"
@@ -58,7 +57,6 @@ type provider struct {
infraMonitoringHandler inframonitoring.Handler
gatewayHandler gateway.Handler
fieldsHandler fields.Handler
statsReporterHandler statsreporter.Handler
authzHandler authz.Handler
rawDataExportHandler rawdataexport.Handler
zeusHandler zeus.Handler
@@ -91,7 +89,6 @@ func NewFactory(
infraMonitoringHandler inframonitoring.Handler,
gatewayHandler gateway.Handler,
fieldsHandler fields.Handler,
statsReporterHandler statsreporter.Handler,
authzHandler authz.Handler,
rawDataExportHandler rawdataexport.Handler,
zeusHandler zeus.Handler,
@@ -127,7 +124,6 @@ func NewFactory(
infraMonitoringHandler,
gatewayHandler,
fieldsHandler,
statsReporterHandler,
authzHandler,
rawDataExportHandler,
zeusHandler,
@@ -165,7 +161,6 @@ func newProvider(
infraMonitoringHandler inframonitoring.Handler,
gatewayHandler gateway.Handler,
fieldsHandler fields.Handler,
statsReporterHandler statsreporter.Handler,
authzHandler authz.Handler,
rawDataExportHandler rawdataexport.Handler,
zeusHandler zeus.Handler,
@@ -202,7 +197,6 @@ func newProvider(
infraMonitoringHandler: infraMonitoringHandler,
gatewayHandler: gatewayHandler,
fieldsHandler: fieldsHandler,
statsReporterHandler: statsReporterHandler,
authzHandler: authzHandler,
rawDataExportHandler: rawDataExportHandler,
zeusHandler: zeusHandler,
@@ -292,10 +286,6 @@ 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

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18"><defs><linearGradient id="f67d1585-6164-4ad0-b2dd-f9cc59b2969f" x1="9.908" y1="15.943" x2="7.516" y2="2.383" gradientUnits="userSpaceOnUse"><stop offset="0.15" stop-color="#0078d4"/><stop offset="0.8" stop-color="#5ea0ef"/><stop offset="1" stop-color="#83b9f9"/></linearGradient></defs><g id="a4fd1868-54fe-4ca6-8ff6-3b01866dc27b"><path d="M14.49,7.15A5.147,5.147,0,0,0,9.24,2.164,5.272,5.272,0,0,0,4.216,5.653,4.869,4.869,0,0,0,0,10.4a4.946,4.946,0,0,0,5.068,4.814H13.82A4.292,4.292,0,0,0,18,11.127,4.105,4.105,0,0,0,14.49,7.15Z" fill="url(#f67d1585-6164-4ad0-b2dd-f9cc59b2969f)"/><path d="M12.9,11.4V8H12v4.13h2.46V11.4ZM5.76,9.73a1.825,1.825,0,0,1-.51-.31.441.441,0,0,1-.12-.32.342.342,0,0,1,.15-.3.683.683,0,0,1,.42-.12,1.62,1.62,0,0,1,1,.29V8.11a2.58,2.58,0,0,0-1-.16,1.641,1.641,0,0,0-1.09.34,1.08,1.08,0,0,0-.42.89c0,.51.32.91,1,1.21a2.907,2.907,0,0,1,.62.36.419.419,0,0,1,.15.32.381.381,0,0,1-.16.31.806.806,0,0,1-.45.11,1.66,1.66,0,0,1-1.09-.42V12a2.173,2.173,0,0,0,1.07.24,1.877,1.877,0,0,0,1.18-.33A1.08,1.08,0,0,0,6.84,11a1.048,1.048,0,0,0-.25-.7A2.425,2.425,0,0,0,5.76,9.73ZM11,11.32A2.191,2.191,0,0,0,11,9a1.808,1.808,0,0,0-.7-.75,2,2,0,0,0-1-.26,2.112,2.112,0,0,0-1.08.27A1.856,1.856,0,0,0,7.49,9a2.465,2.465,0,0,0-.26,1.14,2.256,2.256,0,0,0,.24,1,1.766,1.766,0,0,0,.69.74,2.056,2.056,0,0,0,1,.3l.86,1h1.21L10,12.08A1.79,1.79,0,0,0,11,11.32Zm-1-.25a.941.941,0,0,1-.76.35.916.916,0,0,1-.76-.36,1.523,1.523,0,0,1-.29-1,1.529,1.529,0,0,1,.29-1,1,1,0,0,1,.78-.37.869.869,0,0,1,.75.37,1.619,1.619,0,0,1,.27,1A1.459,1.459,0,0,1,10,11.07Z" fill="#f2f2f2"/></g></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,149 @@
{
"id": "sqldatabase",
"title": "Azure SQL Database",
"icon": "file://icon.svg",
"overview": "file://overview.md",
"supportedSignals": {
"metrics": true,
"logs": true
},
"dataCollected": {
"metrics": [
{
"name": "azure_cpu_percent_average",
"unit": "Percent",
"type": "Gauge",
"description": "Percentage of CPU used by the database workload, relative to its limit."
},
{
"name": "azure_sql_instance_cpu_percent_average",
"unit": "Percent",
"type": "Gauge",
"description": "Total CPU usage (user plus system) of the SQL instance, as a percentage."
},
{
"name": "azure_sql_instance_memory_percent_average",
"unit": "Percent",
"type": "Gauge",
"description": "Memory usage of the SQL instance, as a percentage of its limit."
},
{
"name": "azure_workers_percent_average",
"unit": "Percent",
"type": "Gauge",
"description": "Worker threads in use as a percentage of the limit."
},
{
"name": "azure_sessions_percent_average",
"unit": "Percent",
"type": "Gauge",
"description": "Active sessions as a percentage of the limit."
},
{
"name": "azure_sessions_count_average",
"unit": "Count",
"type": "Gauge",
"description": "Number of active sessions."
},
{
"name": "azure_physical_data_read_percent_average",
"unit": "Percent",
"type": "Gauge",
"description": "Data file IO usage as a percentage of the limit."
},
{
"name": "azure_log_write_percent_average",
"unit": "Percent",
"type": "Gauge",
"description": "Transaction log write throughput as a percentage of the limit."
},
{
"name": "azure_storage_average",
"unit": "Bytes",
"type": "Gauge",
"description": "Data space used by the database."
},
{
"name": "azure_storage_percent_average",
"unit": "Percent",
"type": "Gauge",
"description": "Data space used as a percentage of the maximum data size."
},
{
"name": "azure_allocated_data_storage_average",
"unit": "Bytes",
"type": "Gauge",
"description": "Data space allocated to the database (includes unused space)."
},
{
"name": "azure_connection_successful_total",
"unit": "Count",
"type": "Sum",
"description": "Number of successful connections."
},
{
"name": "azure_connection_failed_total",
"unit": "Count",
"type": "Sum",
"description": "Number of failed connections caused by system errors."
},
{
"name": "azure_connection_failed_user_error_total",
"unit": "Count",
"type": "Sum",
"description": "Number of failed connections caused by user errors."
},
{
"name": "azure_blocked_by_firewall_total",
"unit": "Count",
"type": "Sum",
"description": "Number of connection attempts blocked by the firewall."
},
{
"name": "azure_deadlock_total",
"unit": "Count",
"type": "Sum",
"description": "Number of deadlocks."
},
{
"name": "azure_xtp_storage_percent_average",
"unit": "Percent",
"type": "Gauge",
"description": "In-Memory OLTP storage used as a percentage of the limit."
},
{
"name": "azure_availability_average",
"unit": "Percent",
"type": "Gauge",
"description": "Database availability percentage."
}
],
"logs": [
{
"name": "Resource ID",
"path": "resources.azure.resource.id",
"type": "string"
}
]
},
"telemetryCollectionStrategy": {
"azure": {
"resourceProvider": "Microsoft.Sql",
"resourceType": "servers/databases",
"metrics": {},
"logs": {
"categoryGroups": ["allLogs"]
}
}
},
"assets": {
"dashboards": [
{
"id": "overview",
"title": "Azure SQL Database Overview",
"description": "Overview of Azure SQL Database metrics",
"definition": "file://assets/dashboards/overview.json"
}
]
}
}

View File

@@ -0,0 +1,7 @@
### Monitor Azure SQL Database with SigNoz
Collect key Azure SQL Database (single database) metrics and view them with an out of the box dashboard.
This integration collects platform metrics for the `Microsoft.Sql/servers/databases` resource type.
Note: This integration is for Azure SQL Database (the PaaS offering). Azure SQL Managed Instance and SQL Server on Azure VMs expose a different set of metrics and are not covered here.

View File

@@ -17,6 +17,8 @@ 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,8 +49,6 @@ 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"
)
@@ -82,7 +80,6 @@ type Handlers struct {
TraceDetail tracedetail.Handler
RulerHandler ruler.Handler
LLMPricingRuleHandler llmpricingrule.Handler
StatsReporter statsreporter.Handler
}
func NewHandlers(
@@ -100,7 +97,6 @@ func NewHandlers(
registryHandler factory.Handler,
alertmanagerService alertmanager.Alertmanager,
rulerService ruler.Ruler,
telemetryStore telemetrystore.TelemetryStore,
) Handlers {
return Handlers{
SavedView: implsavedview.NewHandler(modules.SavedView),
@@ -129,11 +125,5 @@ 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, nil)
handlers := NewHandlers(modules, providerSettings, nil, querierHandler, nil, nil, nil, nil, nil, nil, nil, registryHandler, alertmanager, nil)
reflectVal := reflect.ValueOf(handlers)
for i := 0; i < reflectVal.NumField(); i++ {
f := reflectVal.Field(i)

View File

@@ -36,7 +36,6 @@ 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"
@@ -71,7 +70,6 @@ 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(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(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]] {
return factory.MustNewNamedMap(
analyticsstatsreporter.NewFactory(collectors, orgGetter, userGetter, tokenizer, build, analyticsConfig),
analyticsstatsreporter.NewFactory(telemetryStore, collectors, orgGetter, userGetter, tokenizer, build, analyticsConfig),
noopstatsreporter.NewFactory(),
)
}
@@ -294,7 +294,6 @@ 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,7 +85,8 @@ 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)
NewStatsReporterProviderFactories([]statsreporter.StatsCollector{}, orgGetter, userGetter, tokenizertest.NewMockTokenizer(t), version.Build{}, analytics.Config{Enabled: true})
telemetryStore := telemetrystoretest.New(telemetrystore.Config{Provider: "clickhouse"}, sqlmock.QueryMatcherEqual)
NewStatsReporterProviderFactories(telemetryStore, []statsreporter.StatsCollector{}, orgGetter, userGetter, tokenizertest.NewMockTokenizer(t), version.Build{}, analytics.Config{Enabled: true})
})
assert.NotPanics(t, func() {

View File

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

View File

@@ -16,6 +16,7 @@ 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"
@@ -31,6 +32,9 @@ 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
@@ -56,9 +60,9 @@ type provider struct {
stopC chan struct{}
}
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] {
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] {
return factory.NewProviderFactory(factory.MustNewName("analytics"), func(ctx context.Context, settings factory.ProviderSettings, config statsreporter.Config) (statsreporter.StatsReporter, error) {
return New(ctx, settings, config, collectors, orgGetter, userGetter, tokenizer, build, analyticsConfig)
return New(ctx, settings, config, telemetryStore, collectors, orgGetter, userGetter, tokenizer, build, analyticsConfig)
})
}
@@ -66,6 +70,7 @@ func New(
ctx context.Context,
providerSettings factory.ProviderSettings,
config statsreporter.Config,
telemetryStore telemetrystore.TelemetryStore,
collectors []statsreporter.StatsCollector,
orgGetter organization.Getter,
userGetter user.Getter,
@@ -81,16 +86,17 @@ func New(
}
return &provider{
settings: settings,
config: config,
collectors: collectors,
orgGetter: orgGetter,
userGetter: userGetter,
analytics: analytics,
tokenizer: tokenizer,
build: build,
deployment: deployment,
stopC: make(chan struct{}),
settings: settings,
config: config,
telemetryStore: telemetryStore,
collectors: collectors,
orgGetter: orgGetter,
userGetter: userGetter,
analytics: analytics,
tokenizer: tokenizer,
build: build,
deployment: deployment,
stopC: make(chan struct{}),
}, nil
}
@@ -229,5 +235,44 @@ 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

@@ -1,200 +0,0 @@
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

@@ -1,268 +0,0 @@
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

@@ -1,50 +0,0 @@
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

@@ -1,58 +0,0 @@
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

@@ -1,138 +0,0 @@
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

@@ -31,6 +31,7 @@ var (
AzureServiceAppService = ServiceID{valuer.NewString("appservice")}
AzureServiceContainerApp = ServiceID{valuer.NewString("containerapp")}
AzureServiceAKS = ServiceID{valuer.NewString("aks")}
AzureServiceSQLDatabase = ServiceID{valuer.NewString("sqldatabase")}
)
func (ServiceID) Enum() []any {
@@ -54,6 +55,7 @@ func (ServiceID) Enum() []any {
AzureServiceAppService,
AzureServiceContainerApp,
AzureServiceAKS,
AzureServiceSQLDatabase,
}
}
@@ -81,6 +83,7 @@ var SupportedServices = map[CloudProviderType][]ServiceID{
AzureServiceAppService,
AzureServiceContainerApp,
AzureServiceAKS,
AzureServiceSQLDatabase,
},
}

View File

@@ -168,9 +168,6 @@ 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)
@@ -181,7 +178,7 @@ func NewStatsFromStorableDashboards(dashboards []*StorableDashboard) map[string]
addStatsFromStorableDashboard(dashboard, stats)
}
stats[StatKeyDashboardCount] = int64(len(dashboards))
stats["dashboard.count"] = int64(len(dashboards))
return stats
}

View File

@@ -1,27 +0,0 @@
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

@@ -1,40 +0,0 @@
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,14 +358,11 @@ 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(),
StatKeyLicenseStateName: license.State,
"license.state.name": license.State,
"license.free_until.time": license.FreeUntil.UTC(),
}
}

View File

@@ -20,9 +20,6 @@ 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 {
@@ -46,7 +43,7 @@ func NewStatsFromRules(rules []*StorableRule) map[string]any {
}
}
stats[StatKeyRuleCount] = int64(len(rules))
stats["rule.count"] = int64(len(rules))
return stats
}

View File

@@ -22,9 +22,6 @@ 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 {
@@ -36,6 +33,6 @@ func NewStatsFromSavedViews(savedViews []*SavedView) map[string]any {
}
}
stats[StatKeySavedViewCount] = int64(len(savedViews))
stats["savedview.count"] = int64(len(savedViews))
return stats
}

View File

@@ -1,71 +0,0 @@
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"