mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-11 11:20:32 +01:00
Compare commits
6 Commits
issue_5324
...
feat/empty
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9fa3dd6c9b | ||
|
|
1488c81380 | ||
|
|
1f7b98b314 | ||
|
|
45151d71e7 | ||
|
|
f2e01cedec | ||
|
|
7f6e15cd6e |
1
.github/workflows/integrationci.yaml
vendored
1
.github/workflows/integrationci.yaml
vendored
@@ -43,6 +43,7 @@ jobs:
|
||||
- callbackauthn
|
||||
- cloudintegrations
|
||||
- dashboard
|
||||
- emptystate
|
||||
- ingestionkeys
|
||||
- inframonitoring
|
||||
- logspipelines
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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/
|
||||
|
||||
@@ -1364,7 +1364,6 @@ components:
|
||||
- appservice
|
||||
- containerapp
|
||||
- aks
|
||||
- sqldatabase
|
||||
type: string
|
||||
CloudintegrationtypesServiceMetadata:
|
||||
properties:
|
||||
@@ -3356,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:
|
||||
@@ -9510,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
|
||||
|
||||
107
frontend/src/api/generated/services/emptystate/index.ts
Normal file
107
frontend/src/api/generated/services/emptystate/index.ts
Normal 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;
|
||||
};
|
||||
@@ -2655,7 +2655,6 @@ export enum CloudintegrationtypesServiceIDDTO {
|
||||
appservice = 'appservice',
|
||||
containerapp = 'containerapp',
|
||||
aks = 'aks',
|
||||
sqldatabase = 'sqldatabase',
|
||||
}
|
||||
export type CloudintegrationtypesCloudIntegrationServiceDTOAnyOf = {
|
||||
/**
|
||||
@@ -4827,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[] };
|
||||
|
||||
/**
|
||||
@@ -9043,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
|
||||
|
||||
34
pkg/apiserver/signozapiserver/emptystate.go
Normal file
34
pkg/apiserver/signozapiserver/emptystate.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB |
@@ -1,149 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
### 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.
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }{},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
200
pkg/statsreporter/handler.go
Normal file
200
pkg/statsreporter/handler.go
Normal 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)
|
||||
}
|
||||
268
pkg/statsreporter/handler_test.go
Normal file
268
pkg/statsreporter/handler_test.go
Normal 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}))
|
||||
}
|
||||
50
pkg/statsreporter/telemetry.go
Normal file
50
pkg/statsreporter/telemetry.go
Normal 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
|
||||
}
|
||||
58
pkg/statsreporter/telemetrystats.go
Normal file
58
pkg/statsreporter/telemetrystats.go
Normal 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
|
||||
}
|
||||
138
pkg/statsreporter/telemetrystats_test.go
Normal file
138
pkg/statsreporter/telemetrystats_test.go
Normal 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}))
|
||||
}
|
||||
@@ -31,7 +31,6 @@ 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 {
|
||||
@@ -55,7 +54,6 @@ func (ServiceID) Enum() []any {
|
||||
AzureServiceAppService,
|
||||
AzureServiceContainerApp,
|
||||
AzureServiceAKS,
|
||||
AzureServiceSQLDatabase,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +81,6 @@ var SupportedServices = map[CloudProviderType][]ServiceID{
|
||||
AzureServiceAppService,
|
||||
AzureServiceContainerApp,
|
||||
AzureServiceAKS,
|
||||
AzureServiceSQLDatabase,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
27
pkg/types/emptystatetypes/orgcontext.go
Normal file
27
pkg/types/emptystatetypes/orgcontext.go
Normal 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."`
|
||||
}
|
||||
40
pkg/types/emptystatetypes/orgcontext_test.go
Normal file
40
pkg/types/emptystatetypes/orgcontext_test.go
Normal 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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
71
tests/integration/tests/emptystate/01_org_context.py
Normal file
71
tests/integration/tests/emptystate/01_org_context.py
Normal 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"
|
||||
Reference in New Issue
Block a user