mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-01 15:50:41 +01:00
Compare commits
2 Commits
issue_6090
...
nv/promql-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55763eded7 | ||
|
|
7021fe71d9 |
1
.github/workflows/integrationci.yaml
vendored
1
.github/workflows/integrationci.yaml
vendored
@@ -68,7 +68,6 @@ jobs:
|
||||
- semconvfamilies
|
||||
- serviceaccount
|
||||
- spanmapper
|
||||
- tracedetail
|
||||
- querier_json_body
|
||||
- querier_skip_resource_fingerprint
|
||||
- ttl
|
||||
|
||||
@@ -184,7 +184,6 @@ components:
|
||||
headers:
|
||||
additionalProperties:
|
||||
type: string
|
||||
nullable: true
|
||||
type: object
|
||||
html:
|
||||
type: string
|
||||
@@ -218,7 +217,6 @@ components:
|
||||
metadata:
|
||||
additionalProperties:
|
||||
type: string
|
||||
nullable: true
|
||||
type: object
|
||||
sendResolved:
|
||||
nullable: true
|
||||
@@ -260,7 +258,6 @@ components:
|
||||
type: string
|
||||
customFields:
|
||||
additionalProperties: {}
|
||||
nullable: true
|
||||
type: object
|
||||
description:
|
||||
type: string
|
||||
@@ -271,7 +268,6 @@ components:
|
||||
labels:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
priority:
|
||||
type: string
|
||||
@@ -350,7 +346,6 @@ components:
|
||||
details:
|
||||
additionalProperties:
|
||||
type: string
|
||||
nullable: true
|
||||
type: object
|
||||
message:
|
||||
type: string
|
||||
@@ -379,7 +374,6 @@ components:
|
||||
details:
|
||||
additionalProperties:
|
||||
type: string
|
||||
nullable: true
|
||||
type: object
|
||||
group:
|
||||
type: string
|
||||
@@ -457,7 +451,6 @@ components:
|
||||
actions:
|
||||
items:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelSlackAction'
|
||||
nullable: true
|
||||
type: array
|
||||
apiUrl:
|
||||
format: password
|
||||
@@ -471,7 +464,6 @@ components:
|
||||
fields:
|
||||
items:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelSlackField'
|
||||
nullable: true
|
||||
type: array
|
||||
footer:
|
||||
type: string
|
||||
@@ -1771,15 +1763,12 @@ components:
|
||||
additionalProperties: {}
|
||||
nullable: true
|
||||
type: object
|
||||
syncState:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesSyncState'
|
||||
timestampMillis:
|
||||
format: int64
|
||||
type: integer
|
||||
required:
|
||||
- timestampMillis
|
||||
- data
|
||||
- syncState
|
||||
type: object
|
||||
CloudintegrationtypesAzureAccountConfig:
|
||||
properties:
|
||||
@@ -2024,8 +2013,6 @@ components:
|
||||
format: date-time
|
||||
nullable: true
|
||||
type: string
|
||||
syncState:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesSyncState'
|
||||
required:
|
||||
- account_id
|
||||
- cloud_account_id
|
||||
@@ -2035,7 +2022,6 @@ components:
|
||||
- providerAccountId
|
||||
- integrationConfig
|
||||
- removedAt
|
||||
- syncState
|
||||
type: object
|
||||
CloudintegrationtypesGettableServicesMetadata:
|
||||
properties:
|
||||
@@ -2135,9 +2121,6 @@ components:
|
||||
type: object
|
||||
providerAccountId:
|
||||
type: string
|
||||
syncedVersion:
|
||||
nullable: true
|
||||
type: integer
|
||||
required:
|
||||
- data
|
||||
type: object
|
||||
@@ -2150,18 +2133,6 @@ components:
|
||||
gcp:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesGCPIntegrationConfig'
|
||||
type: object
|
||||
CloudintegrationtypesRegionState:
|
||||
enum:
|
||||
- enabled
|
||||
- disabled
|
||||
type: string
|
||||
CloudintegrationtypesRegionSyncState:
|
||||
properties:
|
||||
state:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesRegionState'
|
||||
required:
|
||||
- state
|
||||
type: object
|
||||
CloudintegrationtypesService:
|
||||
properties:
|
||||
assets:
|
||||
@@ -2303,23 +2274,6 @@ components:
|
||||
metrics:
|
||||
type: boolean
|
||||
type: object
|
||||
CloudintegrationtypesSyncState:
|
||||
nullable: true
|
||||
properties:
|
||||
inSync:
|
||||
type: boolean
|
||||
regions:
|
||||
additionalProperties:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesRegionSyncState'
|
||||
type: object
|
||||
version:
|
||||
format: int64
|
||||
type: integer
|
||||
required:
|
||||
- version
|
||||
- inSync
|
||||
- regions
|
||||
type: object
|
||||
CloudintegrationtypesUpdatableAccount:
|
||||
properties:
|
||||
config:
|
||||
@@ -9693,31 +9647,6 @@ components:
|
||||
required:
|
||||
- aggregations
|
||||
type: object
|
||||
SpantypesGettableTraceSummary:
|
||||
properties:
|
||||
ai:
|
||||
$ref: '#/components/schemas/SpantypesTraceAISummary'
|
||||
endTimestampMillis:
|
||||
minimum: 0
|
||||
type: integer
|
||||
hasMissingSpans:
|
||||
type: boolean
|
||||
rootServiceEntryPoint:
|
||||
type: string
|
||||
rootServiceName:
|
||||
type: string
|
||||
rootSpanStatusCode:
|
||||
type: string
|
||||
startTimestampMillis:
|
||||
minimum: 0
|
||||
type: integer
|
||||
totalErrorSpansCount:
|
||||
minimum: 0
|
||||
type: integer
|
||||
totalSpansCount:
|
||||
minimum: 0
|
||||
type: integer
|
||||
type: object
|
||||
SpantypesGettableWaterfallTrace:
|
||||
properties:
|
||||
endTimestampMillis:
|
||||
@@ -10032,32 +9961,6 @@ components:
|
||||
nullable: true
|
||||
type: object
|
||||
type: object
|
||||
SpantypesTraceAISummary:
|
||||
properties:
|
||||
tokens:
|
||||
$ref: '#/components/schemas/SpantypesTraceAITokens'
|
||||
totalCost:
|
||||
nullable: true
|
||||
type: number
|
||||
type: object
|
||||
SpantypesTraceAITokens:
|
||||
properties:
|
||||
cacheRead:
|
||||
minimum: 0
|
||||
type: integer
|
||||
cacheWrite:
|
||||
minimum: 0
|
||||
type: integer
|
||||
input:
|
||||
minimum: 0
|
||||
type: integer
|
||||
output:
|
||||
minimum: 0
|
||||
type: integer
|
||||
reasoning:
|
||||
minimum: 0
|
||||
type: integer
|
||||
type: object
|
||||
SpantypesUpdatableSpanMapper:
|
||||
properties:
|
||||
config:
|
||||
@@ -15782,67 +15685,6 @@ paths:
|
||||
tags:
|
||||
- tracedetail
|
||||
x-signoz-stability: alpha
|
||||
/api/v1/traces/{traceID}/summary:
|
||||
get:
|
||||
deprecated: false
|
||||
description: Returns the trace-level fields of the waterfall (time range, root,
|
||||
span counts, missing spans) and, when the trace has gen_ai spans, its token
|
||||
and cost totals. Computed in one aggregate query.
|
||||
operationId: GetTraceSummary
|
||||
parameters:
|
||||
- in: path
|
||||
name: traceID
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/SpantypesGettableTraceSummary'
|
||||
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
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: Get summary for a trace
|
||||
tags:
|
||||
- tracedetail
|
||||
x-signoz-stability: alpha
|
||||
/api/v1/user/me:
|
||||
get:
|
||||
deprecated: true
|
||||
|
||||
@@ -118,7 +118,7 @@ router.Handle("/api/v1/service_accounts", handler.New(
|
||||
The pieces:
|
||||
|
||||
- **`CheckResources(handlerFn, roles...)`** — the resource-aware authorization wrapper from [pkg/http/middleware/authz.go](/pkg/http/middleware/authz.go). The role list is the community-edition fallback: which managed roles may call this route when per-resource checks are unavailable.
|
||||
- **`ResourceDef`** — declares the resource, verb, audit category, how to extract the instance ID, and how to turn that ID into selectors. ID extractors live in [pkg/types/coretypes/extractor.go](/pkg/types/coretypes/extractor.go): `PathParam("id")`, `BodyJSONPath("data.id")`, `BodyJSONArray("ids")`, and `ResponseJSONPath("data.id")` for IDs only known after the handler runs (e.g. `create`).
|
||||
- **`ResourceDef`** — declares the resource, verb, audit category, how to extract the instance ID, and how to turn that ID into selectors. ID extractors live in [pkg/types/coretypes/extractor.go](/pkg/types/coretypes/extractor.go): `PathParam("id")`, `BodyField(func(req *T) string)` / `BodyFields(func(req *T) []string)` reading the request body the resource middleware decoded into the route's `OpenAPIDef.Request` type `T`, and `ResponseJSONPath("data.id")` for IDs only known after the handler runs (e.g. `create`). A handler on such a route reads the same decoded value with `coretypes.BodyFromContext[T](r.Context())`.
|
||||
- **`SecuritySchemes`** — advertises the required scope (`resource.Scope(verb)`, e.g. `serviceaccount:create`) in the OpenAPI spec.
|
||||
|
||||
For routes that link two resources, use `AttachDetachSiblingResourceDef` (both sides are authz-checked, e.g. attaching a role to a service account requires `attach` on **both** the service account and the role). For parent-child routes (e.g. creating an API key under a service account), both sides are checked too, but with different verbs: declare a `BasicResourceDef` checking the child with `create`/`delete`, alongside an `AttachDetachParentChildResourceDef` checking the parent with `attach`/`detach` (within that def the child is only recorded for audit) — see the `/api/v1/service_accounts/{id}/keys` route in [pkg/apiserver/signozapiserver/serviceaccount.go](/pkg/apiserver/signozapiserver/serviceaccount.go).
|
||||
|
||||
@@ -183,52 +183,32 @@ func (module *module) AgentCheckIn(ctx context.Context, orgID valuer.UUID, provi
|
||||
return nil, errors.New(errors.TypeAlreadyExists, cloudintegrationtypes.ErrCodeCloudIntegrationAlreadyConnected, errMessage)
|
||||
}
|
||||
|
||||
storableAccount, err := module.store.GetAccountByID(ctx, orgID, req.CloudIntegrationID, provider)
|
||||
account, err := module.store.GetAccountByID(ctx, orgID, req.CloudIntegrationID, provider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
account, err := cloudintegrationtypes.NewAccountFromStorable(storableAccount)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
syncState := account.NextSyncState(req.SyncedVersion)
|
||||
|
||||
// If account has been removed (disconnected), return a minimal response with empty integration config.
|
||||
// The agent uses this response to clean up resources
|
||||
if account.RemovedAt != nil {
|
||||
// Heartbeat stays frozen after removal, only the sync state is updated.
|
||||
if account.AgentReport != nil && syncState != nil {
|
||||
account.UpdateSyncState(syncState)
|
||||
|
||||
storableAccount, err = cloudintegrationtypes.NewStorableCloudIntegration(account)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = module.store.UpdateAgentReport(ctx, storableAccount)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return cloudintegrationtypes.NewAgentCheckInResponse(
|
||||
req.ProviderAccountID,
|
||||
account.ID.StringValue(),
|
||||
new(cloudintegrationtypes.ProviderIntegrationConfig),
|
||||
account.RemovedAt,
|
||||
syncState,
|
||||
), nil
|
||||
}
|
||||
|
||||
account.UpdateAgentReport(&req.ProviderAccountID, cloudintegrationtypes.NewAgentReport(req.Data, syncState))
|
||||
// update account with cloud provider account id and agent report (heartbeat)
|
||||
account.Update(&req.ProviderAccountID, cloudintegrationtypes.NewAgentReport(req.Data))
|
||||
|
||||
storableAccount, err = cloudintegrationtypes.NewStorableCloudIntegration(account)
|
||||
err = module.store.UpdateAccount(ctx, account)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = module.store.UpdateAgentReport(ctx, storableAccount)
|
||||
// Get account as domain object for config access (enabled regions, etc.)
|
||||
domainAccount, err := cloudintegrationtypes.NewAccountFromStorable(account)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -243,7 +223,8 @@ func (module *module) AgentCheckIn(ctx context.Context, orgID valuer.UUID, provi
|
||||
return nil, err
|
||||
}
|
||||
|
||||
integrationConfig, err := cloudProvider.BuildIntegrationConfig(ctx, account, storedServices)
|
||||
// Delegate integration config building entirely to the provider module
|
||||
integrationConfig, err := cloudProvider.BuildIntegrationConfig(ctx, domainAccount, storedServices)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -253,7 +234,6 @@ func (module *module) AgentCheckIn(ctx context.Context, orgID valuer.UUID, provi
|
||||
account.ID.StringValue(),
|
||||
integrationConfig,
|
||||
account.RemovedAt,
|
||||
syncState,
|
||||
), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -104,9 +104,9 @@ export interface AlertmanagertypesChannelSlackFieldDTO {
|
||||
|
||||
export interface AlertmanagertypesChannelSlackConfigDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
* @type array
|
||||
*/
|
||||
actions?: AlertmanagertypesChannelSlackActionDTO[] | null;
|
||||
actions?: AlertmanagertypesChannelSlackActionDTO[];
|
||||
/**
|
||||
* @type string
|
||||
* @format password
|
||||
@@ -125,9 +125,9 @@ export interface AlertmanagertypesChannelSlackConfigDTO {
|
||||
*/
|
||||
fallback?: string;
|
||||
/**
|
||||
* @type array,null
|
||||
* @type array
|
||||
*/
|
||||
fields?: AlertmanagertypesChannelSlackFieldDTO[] | null;
|
||||
fields?: AlertmanagertypesChannelSlackFieldDTO[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -166,19 +166,13 @@ export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTy
|
||||
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelEmailConfigDTOKind {
|
||||
email = 'email',
|
||||
}
|
||||
export type AlertmanagertypesChannelEmailConfigDTOHeadersAnyOf = {
|
||||
export type AlertmanagertypesChannelEmailConfigDTOHeaders = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type AlertmanagertypesChannelEmailConfigDTOHeaders =
|
||||
AlertmanagertypesChannelEmailConfigDTOHeadersAnyOf | null;
|
||||
|
||||
export interface AlertmanagertypesChannelEmailConfigDTO {
|
||||
/**
|
||||
* @type object,null
|
||||
* @type object
|
||||
*/
|
||||
headers?: AlertmanagertypesChannelEmailConfigDTOHeaders;
|
||||
/**
|
||||
@@ -245,16 +239,10 @@ export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTy
|
||||
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelPagerdutyConfigDTOKind {
|
||||
pagerduty = 'pagerduty',
|
||||
}
|
||||
export type AlertmanagertypesChannelPagerdutyConfigDTODetailsAnyOf = {
|
||||
export type AlertmanagertypesChannelPagerdutyConfigDTODetails = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type AlertmanagertypesChannelPagerdutyConfigDTODetails =
|
||||
AlertmanagertypesChannelPagerdutyConfigDTODetailsAnyOf | null;
|
||||
|
||||
export interface AlertmanagertypesChannelPagerdutyConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -277,7 +265,7 @@ export interface AlertmanagertypesChannelPagerdutyConfigDTO {
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type object,null
|
||||
* @type object
|
||||
*/
|
||||
details?: AlertmanagertypesChannelPagerdutyConfigDTODetails;
|
||||
/**
|
||||
@@ -319,16 +307,10 @@ export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTy
|
||||
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelOpsgenieConfigDTOKind {
|
||||
opsgenie = 'opsgenie',
|
||||
}
|
||||
export type AlertmanagertypesChannelOpsgenieConfigDTODetailsAnyOf = {
|
||||
export type AlertmanagertypesChannelOpsgenieConfigDTODetails = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type AlertmanagertypesChannelOpsgenieConfigDTODetails =
|
||||
AlertmanagertypesChannelOpsgenieConfigDTODetailsAnyOf | null;
|
||||
|
||||
export interface AlertmanagertypesChannelOpsgenieConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -344,7 +326,7 @@ export interface AlertmanagertypesChannelOpsgenieConfigDTO {
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type object,null
|
||||
* @type object
|
||||
*/
|
||||
details?: AlertmanagertypesChannelOpsgenieConfigDTODetails;
|
||||
/**
|
||||
@@ -441,16 +423,10 @@ export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTy
|
||||
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfigDTOKind {
|
||||
jira = 'jira',
|
||||
}
|
||||
export type AlertmanagertypesChannelJiraConfigDTOCustomFieldsAnyOf = {
|
||||
export type AlertmanagertypesChannelJiraConfigDTOCustomFields = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type AlertmanagertypesChannelJiraConfigDTOCustomFields =
|
||||
AlertmanagertypesChannelJiraConfigDTOCustomFieldsAnyOf | null;
|
||||
|
||||
export interface AlertmanagertypesChannelJiraConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -458,7 +434,7 @@ export interface AlertmanagertypesChannelJiraConfigDTO {
|
||||
*/
|
||||
apiToken: string;
|
||||
/**
|
||||
* @type object,null
|
||||
* @type object
|
||||
*/
|
||||
customFields?: AlertmanagertypesChannelJiraConfigDTOCustomFields;
|
||||
/**
|
||||
@@ -474,9 +450,9 @@ export interface AlertmanagertypesChannelJiraConfigDTO {
|
||||
*/
|
||||
issueType: string;
|
||||
/**
|
||||
* @type array,null
|
||||
* @type array
|
||||
*/
|
||||
labels?: string[] | null;
|
||||
labels?: string[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -567,23 +543,17 @@ export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTy
|
||||
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfigDTOKind {
|
||||
incidentio = 'incidentio',
|
||||
}
|
||||
export type AlertmanagertypesChannelIncidentIOConfigDTOMetadataAnyOf = {
|
||||
export type AlertmanagertypesChannelIncidentIOConfigDTOMetadata = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type AlertmanagertypesChannelIncidentIOConfigDTOMetadata =
|
||||
AlertmanagertypesChannelIncidentIOConfigDTOMetadataAnyOf | null;
|
||||
|
||||
export interface AlertmanagertypesChannelIncidentIOConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type object,null
|
||||
* @type object
|
||||
*/
|
||||
metadata?: AlertmanagertypesChannelIncidentIOConfigDTOMetadata;
|
||||
/**
|
||||
@@ -3396,37 +3366,6 @@ export interface CloudintegrationtypesAWSServiceConfigDTO {
|
||||
metrics?: CloudintegrationtypesAWSServiceMetricsConfigDTO;
|
||||
}
|
||||
|
||||
export enum CloudintegrationtypesRegionStateDTO {
|
||||
enabled = 'enabled',
|
||||
disabled = 'disabled',
|
||||
}
|
||||
export interface CloudintegrationtypesRegionSyncStateDTO {
|
||||
state: CloudintegrationtypesRegionStateDTO;
|
||||
}
|
||||
|
||||
export type CloudintegrationtypesSyncStateDTORegions = {
|
||||
[key: string]: CloudintegrationtypesRegionSyncStateDTO;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type CloudintegrationtypesSyncStateDTO = {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
inSync: boolean;
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
regions: CloudintegrationtypesSyncStateDTORegions;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
version: number;
|
||||
} | null;
|
||||
|
||||
export type CloudintegrationtypesAgentReportDTODataAnyOf = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
@@ -3445,7 +3384,6 @@ export type CloudintegrationtypesAgentReportDTO = {
|
||||
* @type object,null
|
||||
*/
|
||||
data: CloudintegrationtypesAgentReportDTOData;
|
||||
syncState: CloudintegrationtypesSyncStateDTO | null;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
@@ -3874,7 +3812,6 @@ export interface CloudintegrationtypesGettableAgentCheckInDTO {
|
||||
* @format date-time
|
||||
*/
|
||||
removedAt: string | null;
|
||||
syncState: CloudintegrationtypesSyncStateDTO | null;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesServiceMetadataDTO {
|
||||
@@ -3945,10 +3882,6 @@ export interface CloudintegrationtypesPostableAgentCheckInDTO {
|
||||
* @type string
|
||||
*/
|
||||
providerAccountId?: string;
|
||||
/**
|
||||
* @type integer,null
|
||||
*/
|
||||
syncedVersion?: number | null;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesStorableIntegrationDashboardDTO {
|
||||
@@ -11194,82 +11127,6 @@ export interface SpantypesGettableTraceAggregationsDTO {
|
||||
aggregations: SpantypesSpanAggregationResultDTO[];
|
||||
}
|
||||
|
||||
export interface SpantypesTraceAITokensDTO {
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
cacheRead?: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
cacheWrite?: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
input?: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
output?: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
reasoning?: number;
|
||||
}
|
||||
|
||||
export interface SpantypesTraceAISummaryDTO {
|
||||
tokens?: SpantypesTraceAITokensDTO;
|
||||
/**
|
||||
* @type number,null
|
||||
*/
|
||||
totalCost?: number | null;
|
||||
}
|
||||
|
||||
export interface SpantypesGettableTraceSummaryDTO {
|
||||
ai?: SpantypesTraceAISummaryDTO;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
endTimestampMillis?: number;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
hasMissingSpans?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
rootServiceEntryPoint?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
rootServiceName?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
rootSpanStatusCode?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
startTimestampMillis?: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
totalErrorSpansCount?: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
totalSpansCount?: number;
|
||||
}
|
||||
|
||||
export interface SpantypesOtelSpanRefDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -12958,17 +12815,6 @@ export type GetTraceAggregations200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetTraceSummaryPathParameters = {
|
||||
traceID: string;
|
||||
};
|
||||
export type GetTraceSummary200 = {
|
||||
data: SpantypesGettableTraceSummaryDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListUserPreferences200 = {
|
||||
/**
|
||||
* @type array
|
||||
|
||||
@@ -4,17 +4,11 @@
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import { useMutation } from 'react-query';
|
||||
import type {
|
||||
InvalidateOptions,
|
||||
MutationFunction,
|
||||
QueryClient,
|
||||
QueryFunction,
|
||||
QueryKey,
|
||||
UseMutationOptions,
|
||||
UseMutationResult,
|
||||
UseQueryOptions,
|
||||
UseQueryResult,
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
@@ -22,8 +16,6 @@ import type {
|
||||
GetFlamegraphPathParameters,
|
||||
GetTraceAggregations200,
|
||||
GetTraceAggregationsPathParameters,
|
||||
GetTraceSummary200,
|
||||
GetTraceSummaryPathParameters,
|
||||
GetWaterfallV4200,
|
||||
GetWaterfallV4PathParameters,
|
||||
RenderErrorResponseDTO,
|
||||
@@ -35,26 +27,6 @@ import type {
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
|
||||
|
||||
const withQueryKey = <T extends object, K>(
|
||||
query: T,
|
||||
queryKey: K,
|
||||
): T & { queryKey: K } => {
|
||||
const result = { queryKey } as T & { queryKey: K };
|
||||
for (const key of Object.keys(query)) {
|
||||
// The explicit queryKey always wins, matching the previous
|
||||
// `{ ...query, queryKey }` spread where it was set last.
|
||||
if (key === 'queryKey') {
|
||||
continue;
|
||||
}
|
||||
Object.defineProperty(result, key, {
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
get: () => (query as Record<string, unknown>)[key],
|
||||
});
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes span aggregations grouped by requested field.
|
||||
* @summary Get aggregations for a trace
|
||||
@@ -155,108 +127,6 @@ export const useGetTraceAggregations = <
|
||||
> => {
|
||||
return useMutation(getGetTraceAggregationsMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns the trace-level fields of the waterfall (time range, root, span counts, missing spans) and, when the trace has gen_ai spans, its token and cost totals. Computed in one aggregate query.
|
||||
* @summary Get summary for a trace
|
||||
*/
|
||||
export const getTraceSummary = (
|
||||
{ traceID }: GetTraceSummaryPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetTraceSummary200>({
|
||||
url: `/api/v1/traces/${traceID}/summary`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetTraceSummaryQueryKey = ({
|
||||
traceID,
|
||||
}: GetTraceSummaryPathParameters) => {
|
||||
return [`/api/v1/traces/${traceID}/summary`] as const;
|
||||
};
|
||||
|
||||
export const getGetTraceSummaryQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getTraceSummary>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ traceID }: GetTraceSummaryPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getTraceSummary>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetTraceSummaryQueryKey({ traceID });
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getTraceSummary>>> = ({
|
||||
signal,
|
||||
}) => getTraceSummary({ traceID }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: traceID !== null && traceID !== undefined,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getTraceSummary>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetTraceSummaryQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getTraceSummary>>
|
||||
>;
|
||||
export type GetTraceSummaryQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get summary for a trace
|
||||
*/
|
||||
|
||||
export function useGetTraceSummary<
|
||||
TData = Awaited<ReturnType<typeof getTraceSummary>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ traceID }: GetTraceSummaryPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getTraceSummary>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetTraceSummaryQueryOptions({ traceID }, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return withQueryKey(query, queryOptions.queryKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get summary for a trace
|
||||
*/
|
||||
export const invalidateGetTraceSummary = async (
|
||||
queryClient: QueryClient,
|
||||
{ traceID }: GetTraceSummaryPathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetTraceSummaryQueryKey({ traceID }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the flamegraph view of spans for a given trace ID.
|
||||
* @summary Get flamegraph view for a trace
|
||||
|
||||
@@ -24,7 +24,6 @@ const accountsResponse: ListAccounts200 = {
|
||||
agentReport: {
|
||||
timestampMillis: 1747114366214,
|
||||
data: null,
|
||||
syncState: null,
|
||||
},
|
||||
providerAccountId: PROVIDER_ACCOUNT_ID,
|
||||
removedAt: null,
|
||||
|
||||
@@ -295,11 +295,7 @@ const account = (
|
||||
provider,
|
||||
providerAccountId: ACCOUNTS[provider][index],
|
||||
config: accountConfig(provider),
|
||||
agentReport: {
|
||||
timestampMillis: Date.now() - 45 * 1000,
|
||||
data: null,
|
||||
syncState: null,
|
||||
},
|
||||
agentReport: { timestampMillis: Date.now() - 45 * 1000, data: null },
|
||||
createdAt: new Date(Date.now() - 21 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
updatedAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
|
||||
removedAt: null,
|
||||
|
||||
@@ -187,7 +187,7 @@ func (store *config) ListChannels(ctx context.Context, orgID string, params *ale
|
||||
}
|
||||
|
||||
if !params.Kind.IsZero() {
|
||||
q = q.Where("type = ?", params.Kind.StringValue())
|
||||
q = q.Where("type = ?", params.Kind.ToStoredType())
|
||||
}
|
||||
|
||||
q = q.
|
||||
|
||||
@@ -98,12 +98,11 @@ func (handler *handler) ListChannels(rw http.ResponseWriter, req *http.Request)
|
||||
}
|
||||
|
||||
// This ensures that the UI receives an empty array instead of null
|
||||
v1Channels := make([]*alertmanagertypes.Channel, 0, len(channels))
|
||||
for _, channel := range channels {
|
||||
v1Channels = append(v1Channels, channel.ToV1Channel())
|
||||
if len(channels) == 0 {
|
||||
channels = make([]*alertmanagertypes.Channel, 0)
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, v1Channels)
|
||||
render.Success(rw, http.StatusOK, channels)
|
||||
}
|
||||
|
||||
func (handler *handler) ListAllChannels(rw http.ResponseWriter, req *http.Request) {
|
||||
@@ -153,7 +152,7 @@ func (handler *handler) GetChannelByID(rw http.ResponseWriter, req *http.Request
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, channel.ToV1Channel())
|
||||
render.Success(rw, http.StatusOK, channel)
|
||||
}
|
||||
|
||||
func (handler *handler) UpdateChannelByID(rw http.ResponseWriter, req *http.Request) {
|
||||
@@ -273,7 +272,7 @@ func (handler *handler) CreateChannel(rw http.ResponseWriter, req *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusCreated, channel.ToV1Channel())
|
||||
render.Success(rw, http.StatusCreated, channel)
|
||||
}
|
||||
|
||||
func (handler *handler) CreateRoutePolicy(rw http.ResponseWriter, req *http.Request) {
|
||||
|
||||
@@ -262,7 +262,7 @@ func (provider *provider) CreateChannel(ctx context.Context, orgID string, recei
|
||||
}
|
||||
|
||||
func (provider *provider) CreateNotificationChannel(ctx context.Context, orgID string, postable alertmanagertypes.PostableNotificationChannel) (*alertmanagertypes.Channel, error) {
|
||||
channel, receiver, err := postable.ToChannel(orgID)
|
||||
receiver, err := postable.ToReceiver()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -280,6 +280,11 @@ func (provider *provider) CreateNotificationChannel(ctx context.Context, orgID s
|
||||
return nil, err
|
||||
}
|
||||
|
||||
channel, err := alertmanagertypes.NewChannelFromReceiverWithName(receiver, postable.Name, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = provider.configStore.CreateChannel(ctx, channel, alertmanagertypes.WithCb(func(ctx context.Context) error {
|
||||
return provider.configStore.Set(ctx, config)
|
||||
}))
|
||||
@@ -299,11 +304,15 @@ func (provider *provider) UpdateNotificationChannel(ctx context.Context, orgID s
|
||||
return nil, err
|
||||
}
|
||||
|
||||
receiver, err := channel.UpdateFromUpdatable(updatable)
|
||||
receiver, err := updatable.ToReceiver(channel.DisplayName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := channel.Update(receiver); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
config, err := provider.configStore.Get(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
package signozapiserver
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"slices"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func (provider *provider) addAuthDomainRoutes(router *mux.Router) error {
|
||||
@@ -77,7 +74,7 @@ func (provider *provider) addAuthDomainRoutes(router *mux.Router) error {
|
||||
SourceIDs: coretypes.OneID(coretypes.ResponseJSONPath("data.id")),
|
||||
SourceSelector: coretypes.WildcardSelector,
|
||||
TargetResource: coretypes.ResourceRole,
|
||||
TargetIDs: authDomainRoleNamesExtractor(),
|
||||
TargetIDs: authDomainPostableRoleNamesExtractor(),
|
||||
TargetSelector: coretypes.IDSelector,
|
||||
},
|
||||
),
|
||||
@@ -149,7 +146,7 @@ func (provider *provider) addAuthDomainRoutes(router *mux.Router) error {
|
||||
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
|
||||
SourceSelector: coretypes.IDSelector,
|
||||
TargetResource: coretypes.ResourceRole,
|
||||
TargetIDs: authDomainRoleNamesExtractor(),
|
||||
TargetIDs: authDomainUpdatableRoleNamesExtractor(),
|
||||
TargetSelector: coretypes.IDSelector,
|
||||
},
|
||||
handler.AttachDetachSiblingResourceDef{
|
||||
@@ -199,20 +196,16 @@ func (provider *provider) addAuthDomainRoutes(router *mux.Router) error {
|
||||
|
||||
// The extracted names are the roles the request body's mapping grants at SSO
|
||||
// login — see authDomainEffectiveRoleNames.
|
||||
func authDomainRoleNamesExtractor() coretypes.ResourceIDsExtractor {
|
||||
return coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: func(ec coretypes.ExtractorContext) ([]string, error) {
|
||||
roleMappingJSON := gjson.GetBytes(ec.RequestBody, "roleMapping")
|
||||
if !roleMappingJSON.Exists() || roleMappingJSON.Type == gjson.Null {
|
||||
return authDomainEffectiveRoleNames(nil), nil
|
||||
}
|
||||
func authDomainPostableRoleNamesExtractor() coretypes.ResourceIDsExtractor {
|
||||
return coretypes.BodyFields(func(req *authtypes.PostableAuthDomain) []string {
|
||||
return authDomainEffectiveRoleNames(req.RoleMapping)
|
||||
})
|
||||
}
|
||||
|
||||
roleMapping := new(authtypes.RoleMapping)
|
||||
if err := json.Unmarshal([]byte(roleMappingJSON.Raw), roleMapping); err != nil {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid role mapping: %v", err)
|
||||
}
|
||||
|
||||
return authDomainEffectiveRoleNames(roleMapping), nil
|
||||
}}
|
||||
func authDomainUpdatableRoleNamesExtractor() coretypes.ResourceIDsExtractor {
|
||||
return coretypes.BodyFields(func(req *authtypes.UpdatableAuthDomain) []string {
|
||||
return authDomainEffectiveRoleNames(req.RoleMapping)
|
||||
})
|
||||
}
|
||||
|
||||
// The extracted names are the roles the stored domain's mapping grants at SSO
|
||||
|
||||
@@ -350,7 +350,7 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
|
||||
Resource: coretypes.ResourceMetaResourceCloudIntegration,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
ID: coretypes.BodyJSONPath("account_id"),
|
||||
ID: coretypes.BodyField(func(req *citypes.PostableAgentCheckIn) string { return req.ID }),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
@@ -377,7 +377,12 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
|
||||
Resource: coretypes.ResourceMetaResourceCloudIntegration,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
ID: coretypes.BodyJSONPath("cloudIntegrationId"),
|
||||
ID: coretypes.BodyField(func(req *citypes.PostableAgentCheckIn) string {
|
||||
if req.CloudIntegrationID.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return req.CloudIntegrationID.StringValue()
|
||||
}),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
|
||||
@@ -332,7 +332,7 @@ func (provider *provider) addGatewayRoutes(router *mux.Router) error {
|
||||
Verb: coretypes.VerbAttach,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ParentResource: coretypes.ResourceMetaResourceIngestionKey,
|
||||
ParentID: coretypes.BodyJSONPath("keyId"),
|
||||
ParentID: coretypes.BodyField(func(req *gatewaytypes.PostableIngestionKeyLimit) string { return req.KeyID }),
|
||||
ParentSelector: coretypes.IDSelector,
|
||||
ChildResource: coretypes.ResourceMetaResourceIngestionLimit,
|
||||
ChildIDs: coretypes.OneID(coretypes.ResponseJSONPath("data.id")),
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/http/binding"
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/prometheus"
|
||||
@@ -79,6 +80,14 @@ func (h *prometheusOpenAPIHandler) ResourceDefs() []handler.ResourceDef {
|
||||
}}
|
||||
}
|
||||
|
||||
func (h *prometheusOpenAPIHandler) Request() any {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *prometheusOpenAPIHandler) BindBodyOptions() []binding.BindBodyOption {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *provider) addPrometheusRoutes(router *mux.Router) error {
|
||||
if err := router.Handle("/prometheus/api/v1/query", &prometheusOpenAPIHandler{
|
||||
handlerFunc: provider.authzMiddleware.CheckResources(provider.prometheusHandler.Query, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
|
||||
@@ -461,10 +461,11 @@ func (provider *provider) addQuerierRoutes(router *mux.Router) error {
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
SecuritySchemes: newScopedSecuritySchemes(telemetryReadScopes()),
|
||||
}, handler.WithResourceDefs(handler.TelemetryResourceDef{
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: querybuilder.TelemetrySelector,
|
||||
Resources: querybuilder.QueryRangeResources,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: querybuilder.TelemetrySelector,
|
||||
Resources: querybuilder.QueryRangeResources,
|
||||
RequiresBody: true,
|
||||
}))).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -483,10 +484,11 @@ func (provider *provider) addQuerierRoutes(router *mux.Router) error {
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
SecuritySchemes: newScopedSecuritySchemes(telemetryReadScopes()),
|
||||
}, handler.WithResourceDefs(handler.TelemetryResourceDef{
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: querybuilder.TelemetrySelector,
|
||||
Resources: querybuilder.QueryRangeResources,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: querybuilder.TelemetrySelector,
|
||||
Resources: querybuilder.QueryRangeResources,
|
||||
RequiresBody: true,
|
||||
}))).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/http/binding"
|
||||
pkghandler "github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/gorilla/mux"
|
||||
@@ -55,6 +56,14 @@ func (handler *healthOpenAPIHandler) ResourceDefs() []pkghandler.ResourceDef {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (handler *healthOpenAPIHandler) Request() any {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (handler *healthOpenAPIHandler) BindBodyOptions() []binding.BindBodyOption {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *provider) addRegistryRoutes(router *mux.Router) error {
|
||||
if err := router.Handle("/api/v2/healthz", newHealthOpenAPIHandler(
|
||||
provider.authzMiddleware.OpenAccess(provider.factoryHandler.Healthz),
|
||||
|
||||
@@ -358,10 +358,20 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
|
||||
Verb: coretypes.VerbAttach,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
SourceResource: coretypes.ResourceServiceAccount,
|
||||
SourceIDs: coretypes.OneID(coretypes.BodyJSONPath("serviceAccountId")),
|
||||
SourceIDs: coretypes.OneID(coretypes.BodyField(func(req *serviceaccounttypes.PostableServiceAccountRole) string {
|
||||
if req.ServiceAccountID.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return req.ServiceAccountID.StringValue()
|
||||
})),
|
||||
SourceSelector: coretypes.IDSelector,
|
||||
TargetResource: coretypes.ResourceRole,
|
||||
TargetIDs: coretypes.OneID(coretypes.BodyJSONPath("roleId")),
|
||||
TargetIDs: coretypes.OneID(coretypes.BodyField(func(req *serviceaccounttypes.PostableServiceAccountRole) string {
|
||||
if req.RoleID.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return req.RoleID.StringValue()
|
||||
})),
|
||||
TargetSelector: provider.roleSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
|
||||
@@ -10,23 +10,6 @@ import (
|
||||
)
|
||||
|
||||
func (provider *provider) addTraceDetailRoutes(router *mux.Router) error {
|
||||
if err := router.Handle("/api/v1/traces/{traceID}/summary", handler.New(
|
||||
provider.authzMiddleware.ViewAccess(provider.traceDetailHandler.GetTraceSummary),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetTraceSummary",
|
||||
Tags: []string{"tracedetail"},
|
||||
Summary: "Get summary for a trace",
|
||||
Description: "Returns the trace-level fields of the waterfall (time range, root, span counts, missing spans) and, when the trace has gen_ai spans, its token and cost totals. Computed in one aggregate query.",
|
||||
Response: new(spantypes.GettableTraceSummary),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
},
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v4/traces/{traceID}/waterfall", handler.New(
|
||||
provider.authzMiddleware.ViewAccess(provider.traceDetailHandler.GetWaterfallV4),
|
||||
handler.OpenAPIDef{
|
||||
|
||||
@@ -68,7 +68,7 @@ func (provider *provider) addZeusRoutes(router *mux.Router) error {
|
||||
Resource: coretypes.ResourceMetaResourceDeploymentHost,
|
||||
Verb: coretypes.VerbUpdate,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.BodyJSONPath("name"),
|
||||
ID: coretypes.BodyField(func(req *zeustypes.PostableHost) string { return req.Name }),
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}))).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/http/binding"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/gatewaytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
@@ -284,8 +285,8 @@ func (handler *handler) CreateIngestionKeyLimit(rw http.ResponseWriter, r *http.
|
||||
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
|
||||
var req gatewaytypes.PostableIngestionKeyLimit
|
||||
if err := binding.JSON.BindBody(r.Body, &req); err != nil {
|
||||
req, err := coretypes.BodyFromContext[gatewaytypes.PostableIngestionKeyLimit](r.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"slices"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/http/binding"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/swaggest/openapi-go"
|
||||
"github.com/swaggest/openapi-go/openapi3"
|
||||
@@ -16,12 +19,15 @@ type Handler interface {
|
||||
http.Handler
|
||||
ServeOpenAPI(openapi.OperationContext)
|
||||
ResourceDefs() []ResourceDef
|
||||
Request() any
|
||||
BindBodyOptions() []binding.BindBodyOption
|
||||
}
|
||||
|
||||
type handler struct {
|
||||
handlerFunc http.HandlerFunc
|
||||
openAPIDef OpenAPIDef
|
||||
resourceDefs []ResourceDef
|
||||
handlerFunc http.HandlerFunc
|
||||
openAPIDef OpenAPIDef
|
||||
resourceDefs []ResourceDef
|
||||
bindBodyOptions []binding.BindBodyOption
|
||||
}
|
||||
|
||||
func New(handlerFunc http.HandlerFunc, openAPIDef OpenAPIDef, opts ...Option) Handler {
|
||||
@@ -47,6 +53,10 @@ func New(handlerFunc http.HandlerFunc, openAPIDef OpenAPIDef, opts ...Option) Ha
|
||||
opt(handler)
|
||||
}
|
||||
|
||||
if RequiresBody(handler.resourceDefs) && (openAPIDef.Request == nil || reflect.TypeOf(openAPIDef.Request).Kind() != reflect.Pointer) {
|
||||
panic(fmt.Sprintf("handler %s: a body extractor needs OpenAPIDef.Request to be a pointer, got %T", openAPIDef.ID, openAPIDef.Request))
|
||||
}
|
||||
|
||||
return handler
|
||||
}
|
||||
|
||||
@@ -135,3 +145,11 @@ func (handler *handler) ServeOpenAPI(opCtx openapi.OperationContext) {
|
||||
func (handler *handler) ResourceDefs() []ResourceDef {
|
||||
return handler.resourceDefs
|
||||
}
|
||||
|
||||
func (handler *handler) Request() any {
|
||||
return handler.openAPIDef.Request
|
||||
}
|
||||
|
||||
func (handler *handler) BindBodyOptions() []binding.BindBodyOption {
|
||||
return handler.bindBodyOptions
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/binding"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -22,6 +24,41 @@ func (bespokeOpenAPIHandler) ServeOpenAPI(opCtx openapi.OperationContext) {
|
||||
|
||||
func (bespokeOpenAPIHandler) ResourceDefs() []ResourceDef { return nil }
|
||||
|
||||
func (bespokeOpenAPIHandler) Request() any { return nil }
|
||||
|
||||
func (bespokeOpenAPIHandler) BindBodyOptions() []binding.BindBodyOption { return nil }
|
||||
|
||||
func TestNewPanicsWhenBodyExtractorHasNoPointerRequest(t *testing.T) {
|
||||
type body struct{ ID string }
|
||||
bodyDef := BasicResourceDef{Resource: coretypes.ResourceRole, Verb: coretypes.VerbRead, ID: coretypes.BodyField(func(req *body) string { return req.ID }), Selector: coretypes.IDSelector}
|
||||
pathDef := BasicResourceDef{Resource: coretypes.ResourceRole, Verb: coretypes.VerbRead, ID: coretypes.PathParam("id"), Selector: coretypes.IDSelector}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
request any
|
||||
def ResourceDef
|
||||
panics bool
|
||||
}{
|
||||
{name: "BodyExtractor_ValueRequest_Panics", request: body{}, def: bodyDef, panics: true},
|
||||
{name: "BodyExtractor_NilRequest_Panics", request: nil, def: bodyDef, panics: true},
|
||||
{name: "BodyExtractor_PointerRequest_Registers", request: new(body), def: bodyDef, panics: false},
|
||||
{name: "PathExtractor_ValueRequest_Registers", request: body{}, def: pathDef, panics: false},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
register := func() {
|
||||
New(func(http.ResponseWriter, *http.Request) {}, OpenAPIDef{ID: testCase.name, Request: testCase.request}, WithResourceDefs(testCase.def))
|
||||
}
|
||||
if testCase.panics {
|
||||
assert.Panics(t, register)
|
||||
} else {
|
||||
assert.NotPanics(t, register)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachStabilities(t *testing.T) {
|
||||
router := mux.NewRouter()
|
||||
router.Handle("/development", New(func(http.ResponseWriter, *http.Request) {}, OpenAPIDef{ID: "Development", SuccessStatusCode: http.StatusOK, Stability: StabilityDevelopment})).Methods(http.MethodGet)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package handler
|
||||
|
||||
import "github.com/SigNoz/signoz/pkg/http/binding"
|
||||
|
||||
type Option func(*handler)
|
||||
|
||||
func WithResourceDefs(defs ...ResourceDef) Option {
|
||||
@@ -7,3 +9,9 @@ func WithResourceDefs(defs ...ResourceDef) Option {
|
||||
h.resourceDefs = append(h.resourceDefs, defs...)
|
||||
}
|
||||
}
|
||||
|
||||
func WithBindBodyOptions(opts ...binding.BindBodyOption) Option {
|
||||
return func(h *handler) {
|
||||
h.bindBodyOptions = append(h.bindBodyOptions, opts...)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ type ResourceDef interface {
|
||||
// resolveRequest is unexported to seal the interface. It returns a slice so a
|
||||
// single def can fan out (e.g. a telemetry query touching multiple signals).
|
||||
resolveRequest(ec coretypes.ExtractorContext) []coretypes.ResolvedResource
|
||||
requiresBody() bool
|
||||
}
|
||||
|
||||
func ResolveRequest(defs []ResourceDef, ec coretypes.ExtractorContext) []coretypes.ResolvedResource {
|
||||
@@ -20,6 +21,17 @@ func ResolveRequest(defs []ResourceDef, ec coretypes.ExtractorContext) []coretyp
|
||||
return resolved
|
||||
}
|
||||
|
||||
// RequiresBody reports whether any def needs the decoded request body.
|
||||
func RequiresBody(defs []ResourceDef) bool {
|
||||
for _, def := range defs {
|
||||
if def.requiresBody() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// BasicResourceDef checks a single resource for one verb.
|
||||
type BasicResourceDef struct {
|
||||
Resource coretypes.Resource
|
||||
@@ -42,6 +54,10 @@ func (def BasicResourceDef) resolveRequest(ec coretypes.ExtractorContext) []core
|
||||
}
|
||||
}
|
||||
|
||||
func (def BasicResourceDef) requiresBody() bool {
|
||||
return def.ID.RequiresBody
|
||||
}
|
||||
|
||||
// AttachDetachSiblingResourceDef checks an attach/detach between peer resources;
|
||||
// both source and target are authz-checked.
|
||||
type AttachDetachSiblingResourceDef struct {
|
||||
@@ -72,6 +88,10 @@ func (def AttachDetachSiblingResourceDef) resolveRequest(ec coretypes.ExtractorC
|
||||
}
|
||||
}
|
||||
|
||||
func (def AttachDetachSiblingResourceDef) requiresBody() bool {
|
||||
return def.SourceIDs.RequiresBody || def.TargetIDs.RequiresBody
|
||||
}
|
||||
|
||||
// AttachDetachParentChildResourceDef authz-checks only the parent; the child
|
||||
// rides along for audit context.
|
||||
type AttachDetachParentChildResourceDef struct {
|
||||
@@ -101,11 +121,20 @@ func (def AttachDetachParentChildResourceDef) resolveRequest(ec coretypes.Extrac
|
||||
}
|
||||
}
|
||||
|
||||
func (def AttachDetachParentChildResourceDef) requiresBody() bool {
|
||||
return def.ParentID.RequiresBody || def.ChildIDs.RequiresBody
|
||||
}
|
||||
|
||||
type TelemetryResourceDef struct {
|
||||
Verb coretypes.Verb
|
||||
Category coretypes.ActionCategory
|
||||
Selector coretypes.SelectorFunc
|
||||
Resources coretypes.ResourceExtractor
|
||||
Verb coretypes.Verb
|
||||
Category coretypes.ActionCategory
|
||||
Selector coretypes.SelectorFunc
|
||||
Resources coretypes.ResourceExtractor
|
||||
RequiresBody bool
|
||||
}
|
||||
|
||||
func (def TelemetryResourceDef) requiresBody() bool {
|
||||
return def.RequiresBody
|
||||
}
|
||||
|
||||
func (def TelemetryResourceDef) resolveRequest(ec coretypes.ExtractorContext) []coretypes.ResolvedResource {
|
||||
|
||||
@@ -5,7 +5,9 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"reflect"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/binding"
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/gorilla/mux"
|
||||
@@ -23,8 +25,8 @@ func NewResource(logger *slog.Logger) *Resource {
|
||||
|
||||
func (middleware *Resource) Wrap(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
||||
defs := resourceDefsFromRequest(req)
|
||||
if len(defs) == 0 {
|
||||
provider := handlerFromRequest(req)
|
||||
if provider == nil || len(provider.ResourceDefs()) == 0 {
|
||||
next.ServeHTTP(rw, req)
|
||||
return
|
||||
}
|
||||
@@ -36,18 +38,40 @@ func (middleware *Resource) Wrap(next http.Handler) http.Handler {
|
||||
req.Body = io.NopCloser(bytes.NewReader(body))
|
||||
}
|
||||
|
||||
extractorCtx := coretypes.ExtractorContext{
|
||||
Request: req,
|
||||
RequestBody: body,
|
||||
}
|
||||
resolved := handler.ResolveRequest(defs, extractorCtx)
|
||||
defs := provider.ResourceDefs()
|
||||
|
||||
ctx := coretypes.NewContextWithResolvedResources(req.Context(), resolved)
|
||||
var decoded any
|
||||
var decodeErr error
|
||||
if handler.RequiresBody(defs) {
|
||||
decoded, decodeErr = decodeBody(provider.Request(), body, provider.BindBodyOptions()...)
|
||||
}
|
||||
|
||||
extractorCtx := coretypes.ExtractorContext{Request: req, RequestBody: decoded}
|
||||
|
||||
var resolved []coretypes.ResolvedResource
|
||||
if decodeErr != nil {
|
||||
// authz renders the error inside the audit middleware, so the request is still logged
|
||||
resolved = []coretypes.ResolvedResource{coretypes.NewResolvedResourceWithError(coretypes.Verb{}, coretypes.ActionCategory{}, decodeErr)}
|
||||
} else {
|
||||
resolved = handler.ResolveRequest(defs, extractorCtx)
|
||||
}
|
||||
|
||||
ctx := coretypes.NewContextWithExtractorContext(req.Context(), extractorCtx)
|
||||
ctx = coretypes.NewContextWithResolvedResources(ctx, resolved)
|
||||
next.ServeHTTP(rw, req.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func resourceDefsFromRequest(req *http.Request) []handler.ResourceDef {
|
||||
func decodeBody(prototype any, body []byte, opts ...binding.BindBodyOption) (any, error) {
|
||||
decoded := reflect.New(reflect.TypeOf(prototype).Elem()).Interface()
|
||||
if err := binding.JSON.BindBody(bytes.NewReader(body), decoded, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
func handlerFromRequest(req *http.Request) handler.Handler {
|
||||
route := mux.CurrentRoute(req)
|
||||
if route == nil {
|
||||
return nil
|
||||
@@ -63,5 +87,5 @@ func resourceDefsFromRequest(req *http.Request) []handler.ResourceDef {
|
||||
return nil
|
||||
}
|
||||
|
||||
return provider.ResourceDefs()
|
||||
return provider
|
||||
}
|
||||
|
||||
@@ -5,11 +5,11 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/binding"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/modules/authdomain"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
@@ -32,8 +32,8 @@ func (handler *handler) Create(rw http.ResponseWriter, req *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
body := new(authtypes.PostableAuthDomain)
|
||||
if err := binding.JSON.BindBody(req.Body, body); err != nil {
|
||||
body, err := coretypes.BodyFromContext[authtypes.PostableAuthDomain](req.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
@@ -142,8 +142,8 @@ func (handler *handler) Update(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
body := new(authtypes.UpdatableAuthDomain)
|
||||
if err := binding.JSON.BindBody(r.Body, body); err != nil {
|
||||
body, err := coretypes.BodyFromContext[authtypes.UpdatableAuthDomain](r.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ func newConfig() factory.Config {
|
||||
Agent: AgentConfig{
|
||||
// we will maintain the latest version of cloud integration agent from here,
|
||||
// till we automate it externally or figure out a way to validate it.
|
||||
Version: "v0.0.15",
|
||||
Version: "v0.0.14",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
@@ -467,8 +468,8 @@ func (handler *handler) AgentCheckIn(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
req := new(cloudintegrationtypes.PostableAgentCheckIn)
|
||||
if err := binding.JSON.BindBody(r.Body, req); err != nil {
|
||||
req, err := coretypes.BodyFromContext[cloudintegrationtypes.PostableAgentCheckIn](r.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -134,24 +134,6 @@ func (store *store) UpdateAccount(ctx context.Context, account *cloudintegration
|
||||
BunDBCtx(ctx).
|
||||
NewUpdate().
|
||||
Model(account).
|
||||
Column("config").
|
||||
Column("updated_at").
|
||||
WherePK().
|
||||
Where("org_id = ?", account.OrgID).
|
||||
Where("provider = ?", account.Provider).
|
||||
Exec(ctx)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (store *store) UpdateAgentReport(ctx context.Context, account *cloudintegrationtypes.StorableCloudIntegration) error {
|
||||
_, err := store.
|
||||
store.
|
||||
BunDBCtx(ctx).
|
||||
NewUpdate().
|
||||
Model(account).
|
||||
Column("account_id").
|
||||
Column("last_agent_report").
|
||||
WherePK().
|
||||
Where("org_id = ?", account.OrgID).
|
||||
Where("provider = ?", account.Provider).
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/serviceaccounttypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
@@ -222,8 +223,8 @@ func (handler *handler) CreateServiceAccountRole(rw http.ResponseWriter, r *http
|
||||
return
|
||||
}
|
||||
|
||||
req := new(serviceaccounttypes.PostableServiceAccountRole)
|
||||
if err := binding.JSON.BindBody(r.Body, req); err != nil {
|
||||
req, err := coretypes.BodyFromContext[serviceaccounttypes.PostableServiceAccountRole](r.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -6,9 +6,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/http/binding"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
@@ -20,27 +18,6 @@ func NewHandler(module tracedetail.Module) tracedetail.Handler {
|
||||
return &handler{module: module}
|
||||
}
|
||||
|
||||
func (h *handler) GetTraceSummary(rw http.ResponseWriter, r *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
orgID, err := valuer.NewUUID(claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
stats, err := h.module.GetTraceSummary(r.Context(), orgID, mux.Vars(r)["traceID"])
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, spantypes.NewGettableTraceSummary(stats))
|
||||
}
|
||||
|
||||
func (h *handler) GetWaterfallV4(rw http.ResponseWriter, r *http.Request) {
|
||||
req := new(spantypes.PostableWaterfall)
|
||||
if err := binding.JSON.BindBody(r.Body, req); err != nil {
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"go.opentelemetry.io/otel/metric"
|
||||
)
|
||||
|
||||
@@ -40,21 +39,6 @@ func NewModule(traceStore spantypes.TraceStore, providerSettings factory.Provide
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *module) GetTraceSummary(ctx context.Context, orgID valuer.UUID, traceID string) (*spantypes.TraceStats, error) {
|
||||
summary, err := m.store.GetTraceSummary(ctx, traceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats, err := m.store.GetTraceStats(ctx, orgID, traceID, summary)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if stats.TotalSpans == 0 {
|
||||
return nil, spantypes.ErrTraceNotFound
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// GetWaterfallV4 is the OOM-safe V4 waterfall.
|
||||
// For large traces (NumSpans > effectiveLimit) it uses a two-step fetch:
|
||||
// minimal fields for all spans to build the tree, then full fields for the
|
||||
|
||||
@@ -4,22 +4,15 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
sqlbuilder "github.com/huandu/go-sqlbuilder"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/clickhousesql"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/tracestelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
const colServiceName = `resource_string_service$$$$name` // $ gets escaped so $$$$ converts to $$.
|
||||
@@ -45,18 +38,10 @@ type spanDurationRow struct {
|
||||
|
||||
type traceStore struct {
|
||||
telemetryStore telemetrystore.TelemetryStore
|
||||
metadataStore telemetrytypes.MetadataStore
|
||||
storage qbtypes.Storage
|
||||
flagger flagger.Flagger
|
||||
}
|
||||
|
||||
func NewTraceStore(ts telemetrystore.TelemetryStore, metadataStore telemetrytypes.MetadataStore, fl flagger.Flagger) *traceStore {
|
||||
return &traceStore{
|
||||
telemetryStore: ts,
|
||||
metadataStore: metadataStore,
|
||||
storage: tracestelemetryschema.NewStorage(),
|
||||
flagger: fl,
|
||||
}
|
||||
func NewTraceStore(ts telemetrystore.TelemetryStore) *traceStore {
|
||||
return &traceStore{telemetryStore: ts}
|
||||
}
|
||||
|
||||
func (s *traceStore) GetTraceSummary(ctx context.Context, traceID string) (*spantypes.TraceSummary, error) {
|
||||
@@ -80,134 +65,6 @@ func (s *traceStore) GetTraceSummary(ctx context.Context, traceID string) (*span
|
||||
return &summary, nil
|
||||
}
|
||||
|
||||
func (s *traceStore) GetTraceStats(ctx context.Context, orgID valuer.UUID, traceID string, summary *spantypes.TraceSummary) (*spantypes.TraceStats, error) {
|
||||
table := fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable)
|
||||
spans := sqlbuilder.NewSelectBuilder()
|
||||
|
||||
genAIColumns, err := s.genAISpanColumns(ctx, orgID, summary, spans)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// A span whose parent was never recorded hangs off a synthetic "Missing Span" root in the waterfall.
|
||||
ids := sqlbuilder.NewSelectBuilder()
|
||||
ids.Select("span_id")
|
||||
ids.From(table)
|
||||
ids.Where(
|
||||
ids.E("trace_id", traceID),
|
||||
ids.GE("ts_bucket_start", summary.Start.Unix()-1800),
|
||||
ids.LE("ts_bucket_start", summary.End.Unix()),
|
||||
)
|
||||
missingParent := fmt.Sprintf("parent_span_id <> '' AND parent_span_id GLOBAL NOT IN (%s)", spans.Var(ids))
|
||||
|
||||
spans.Select(
|
||||
"toUnixTimestamp64Nano(timestamp) AS span_start_ns",
|
||||
"span_start_ns + duration_nano AS span_end_ns",
|
||||
"span_id",
|
||||
"has_error",
|
||||
"("+missingParent+") AS has_missing_parent",
|
||||
"(parent_span_id = '' OR has_missing_parent) AS is_root",
|
||||
"if(parent_span_id = '', name, 'Missing Span') AS root_name",
|
||||
"if(parent_span_id = '', "+colServiceName+", '') AS root_service",
|
||||
"if(parent_span_id = '', response_status_code, '') AS root_status_code",
|
||||
)
|
||||
spans.SelectMore(genAIColumns...)
|
||||
spans.From(table)
|
||||
spans.Where(
|
||||
spans.E("trace_id", traceID),
|
||||
spans.GE("ts_bucket_start", summary.Start.Unix()-1800),
|
||||
spans.LE("ts_bucket_start", summary.End.Unix()),
|
||||
)
|
||||
spans.SQL("LIMIT 1 BY span_id")
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select(
|
||||
"toUInt64(min(span_start_ns)) AS start_ns",
|
||||
"toUInt64(max(span_end_ns)) AS end_ns",
|
||||
"count() AS total_spans",
|
||||
"countIf(has_error) AS total_error_spans",
|
||||
"countIf(has_missing_parent) > 0 AS has_missing_spans",
|
||||
"argMinIf(root_service, (span_start_ns, root_name), is_root) AS root_service_name",
|
||||
"argMinIf(root_name, (span_start_ns, root_name), is_root) AS root_entry_point",
|
||||
"argMinIf(root_status_code, (span_start_ns, root_name), is_root) AS root_span_status_code",
|
||||
"countIf(is_gen_ai) AS gen_ai_span_count",
|
||||
"toUInt64(coalesce(sum(input_tokens_value), 0)) AS input_tokens",
|
||||
"toUInt64(coalesce(sum(output_tokens_value), 0)) AS output_tokens",
|
||||
"toUInt64(coalesce(sum(cache_read_tokens_value), 0)) AS cache_read_tokens",
|
||||
"toUInt64(coalesce(sum(cache_write_tokens_value), 0)) AS cache_write_tokens",
|
||||
"toUInt64(coalesce(sum(reasoning_tokens_value), 0)) AS reasoning_tokens",
|
||||
"sum(total_cost_value) AS total_cost",
|
||||
)
|
||||
sb.From(sb.BuilderAs(spans, "spans"))
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
var stats spantypes.TraceStats
|
||||
err = s.telemetryStore.ClickhouseDB().QueryRow(ctx, query, args...).Scan(
|
||||
&stats.StartNs, &stats.EndNs, &stats.TotalSpans, &stats.TotalErrorSpans, &stats.HasMissingSpans,
|
||||
&stats.RootServiceName, &stats.RootEntryPoint, &stats.RootSpanStatusCode, &stats.GenAISpanCount,
|
||||
&stats.Tokens.Input, &stats.Tokens.Output, &stats.Tokens.CacheRead, &stats.Tokens.CacheWrite, &stats.Tokens.Reasoning,
|
||||
&stats.TotalCost,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error querying trace stats")
|
||||
}
|
||||
return &stats, nil
|
||||
}
|
||||
|
||||
// genAISpanColumns renders the per-span gen_ai gate and value reads through the shared
|
||||
// traces storage, so each attribute is read from the column its evolutions place it in
|
||||
// over the trace's own time window. Exists predicates bind their args into sb.
|
||||
func (s *traceStore) genAISpanColumns(ctx context.Context, orgID valuer.UUID, summary *spantypes.TraceSummary, sb *sqlbuilder.SelectBuilder) ([]string, error) {
|
||||
attributeKey := func(name string, dataType telemetrytypes.FieldDataType) *telemetrytypes.TelemetryFieldKey {
|
||||
return &telemetrytypes.TelemetryFieldKey{Name: name, Signal: telemetrytypes.SignalTraces, FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: dataType}
|
||||
}
|
||||
|
||||
values := []struct{ key, alias string }{
|
||||
{aiobservabilitytypes.GenAIUsageInputTokens, "input_tokens_value"},
|
||||
{aiobservabilitytypes.GenAIUsageOutputTokens, "output_tokens_value"},
|
||||
{aiobservabilitytypes.GenAIUsageCacheReadInputTokens, "cache_read_tokens_value"},
|
||||
{aiobservabilitytypes.GenAIUsageCacheCreationInputTokens, "cache_write_tokens_value"},
|
||||
{aiobservabilitytypes.GenAIUsageReasoningOutputTokens, "reasoning_tokens_value"},
|
||||
{aiobservabilitytypes.SignozGenAITotalCost, "total_cost_value"},
|
||||
}
|
||||
|
||||
names := slices.Clone(aiobservabilitytypes.GenAISpanGateKeys)
|
||||
for _, value := range values {
|
||||
names = append(names, value.key)
|
||||
}
|
||||
selectors := make([]*telemetrytypes.FieldKeySelector, 0, len(names))
|
||||
for _, name := range names {
|
||||
selectors = append(selectors, &telemetrytypes.FieldKeySelector{Name: name, Signal: telemetrytypes.SignalTraces, FieldContext: telemetrytypes.FieldContextAttribute, SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeExact})
|
||||
}
|
||||
keys, _, err := s.metadataStore.GetKeysMulti(ctx, orgID, querybuilder.ExpandKeySelectorsForFamilies(ctx, orgID, s.flagger, selectors))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
q := querybuilder.NewQueryInfo(ctx, orgID, s.flagger, telemetrytypes.SignalTraces, nil, uint64(summary.Start.UnixNano()), uint64(summary.End.UnixNano()))
|
||||
|
||||
gate := make([]string, 0, len(aiobservabilitytypes.GenAISpanGateKeys))
|
||||
for _, name := range aiobservabilitytypes.GenAISpanGateKeys {
|
||||
conds, _, err := querybuilder.Conditions(ctx, q, s.storage, attributeKey(name, telemetrytypes.FieldDataTypeString), qbtypes.FilterOperatorExists, nil, keys, false, sb)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gate = append(gate, conds...)
|
||||
}
|
||||
columns := []string{sb.Or(gate...) + " AS is_gen_ai"}
|
||||
|
||||
for _, value := range values {
|
||||
// lookup by number, the type metadata stores numeric attributes under; float64 is only the output cast
|
||||
expr, err := querybuilder.ResolveColumn(ctx, q, s.storage, attributeKey(value.key, telemetrytypes.FieldDataTypeNumber), telemetrytypes.FieldDataTypeFloat64, keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// a materialized column name carries `$$`, which Build would otherwise unescape
|
||||
columns = append(columns, sqlbuilder.Escape(expr)+" AS "+value.alias)
|
||||
}
|
||||
return columns, nil
|
||||
}
|
||||
|
||||
func (s *traceStore) GetTraceSpans(ctx context.Context, traceID string, summary *spantypes.TraceSummary) ([]spantypes.StorableSpan, error) {
|
||||
// DISTINCT ON (span_id) is ClickHouse-specific syntax not supported by sqlbuilder
|
||||
query := fmt.Sprintf(`
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -6,12 +6,10 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
// Handler exposes HTTP handlers for trace detail APIs.
|
||||
type Handler interface {
|
||||
GetTraceSummary(http.ResponseWriter, *http.Request)
|
||||
GetWaterfallV4(http.ResponseWriter, *http.Request)
|
||||
GetTraceAggregations(http.ResponseWriter, *http.Request)
|
||||
GetFlamegraph(http.ResponseWriter, *http.Request)
|
||||
@@ -19,7 +17,6 @@ type Handler interface {
|
||||
|
||||
// Module defines the business logic for trace detail operations.
|
||||
type Module interface {
|
||||
GetTraceSummary(ctx context.Context, orgID valuer.UUID, traceID string) (*spantypes.TraceStats, error)
|
||||
GetWaterfallV4(ctx context.Context, traceID string, selectedSpanID string, uncollapsedSpans []string) (*spantypes.GettableWaterfallTrace, error)
|
||||
GetTraceAggregations(ctx context.Context, traceID string, req *spantypes.PostableTraceAggregations) (*spantypes.GettableTraceAggregations, error)
|
||||
GetFlamegraph(ctx context.Context, traceID string, selectedSpanID string, selectFields []telemetrytypes.TelemetryFieldKey) (*spantypes.GettableFlamegraphTrace, error)
|
||||
|
||||
@@ -381,6 +381,12 @@ func toMatrix(series []transpiledSeries, startMs, stepMs int64) promql.Matrix {
|
||||
// lookback cannot resurrect the previous grid point. Each unit's synthetic
|
||||
// samples sit on its own grid: the query grid, or the subquery grid for
|
||||
// units inside subqueries.
|
||||
//
|
||||
// Synthetic series carry no __name__: substituted units all drop it, so
|
||||
// nameless matches the replaced expressions' output. A stamped name splits
|
||||
// or arms into per-unit series that a later name drop collides into the
|
||||
// duplicate-labelset error. hybridQuerier.Select resolves the selector
|
||||
// from the matcher, not from series labels.
|
||||
func (e *executor) executeHybrid(ctx context.Context, plan *transpilePlan, results [][]transpiledSeries) (promql.Matrix, error) {
|
||||
synthetic := make(map[string][]*series, len(plan.units))
|
||||
staleMarker := math.Float64frombits(promValue.StaleNaN)
|
||||
@@ -395,9 +401,7 @@ func (e *executor) executeHybrid(ctx context.Context, plan *transpilePlan, resul
|
||||
}
|
||||
list := make([]*series, 0, len(results[i]))
|
||||
for _, cs := range results[i] {
|
||||
builder := labels.NewBuilder(cs.lset)
|
||||
builder.Set(metricNameLabel, unit.name)
|
||||
s := &series{lset: builder.Labels()}
|
||||
s := &series{lset: cs.lset}
|
||||
s.ts = make([]int64, 0, gridLen)
|
||||
s.vs = make([]float64, 0, gridLen)
|
||||
for idx := 0; idx < gridLen; idx++ {
|
||||
@@ -440,65 +444,17 @@ func (e *executor) executeHybrid(ctx context.Context, plan *transpilePlan, resul
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Deep-copy before Close returns the result's slices to the engine pool,
|
||||
// and drop the synthetic __name__ that filter comparisons preserve.
|
||||
// Deep-copy before Close returns the result's slices to the engine pool.
|
||||
out := make(promql.Matrix, 0, len(matrix))
|
||||
for _, s := range matrix {
|
||||
lset := s.Metric
|
||||
if name := lset.Get(metricNameLabel); len(name) >= len(syntheticNamePrefix) && name[:len(syntheticNamePrefix)] == syntheticNamePrefix {
|
||||
builder := labels.NewBuilder(lset)
|
||||
builder.Del(metricNameLabel)
|
||||
lset = builder.Labels()
|
||||
}
|
||||
floats := make([]promql.FPoint, len(s.Floats))
|
||||
copy(floats, s.Floats)
|
||||
out = append(out, promql.Series{Metric: lset.Copy(), Floats: floats})
|
||||
}
|
||||
// The strip can leave twins: two units' outputs that only their
|
||||
// synthetic names told apart (e.g. -metric_a or -metric_b, both {}
|
||||
// once real names are dropped). The engine assembles its matrix by
|
||||
// labelset. It merges such temporally-disjoint elements into one
|
||||
// series. Reproduce that, with its duplicate error on same-timestamp
|
||||
// overlap.
|
||||
out, err = mergeMatrixByLabelset(out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
out = append(out, promql.Series{Metric: s.Metric.Copy(), Floats: floats})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return labels.Compare(out[i].Metric, out[j].Metric) < 0 })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// mergeMatrixByLabelset merges series that share a labelset. It interleaves
|
||||
// their points in timestamp order. A timestamp present in both is the
|
||||
// engine's duplicate-labelset error.
|
||||
func mergeMatrixByLabelset(matrix promql.Matrix) (promql.Matrix, error) {
|
||||
index := make(map[uint64]int, len(matrix))
|
||||
out := matrix[:0]
|
||||
for _, s := range matrix {
|
||||
hash := s.Metric.Hash()
|
||||
idx, ok := index[hash]
|
||||
if ok && labels.Equal(out[idx].Metric, s.Metric) {
|
||||
merged := make([]promql.FPoint, 0, len(out[idx].Floats)+len(s.Floats))
|
||||
a, b := out[idx].Floats, s.Floats
|
||||
for len(a) > 0 && len(b) > 0 {
|
||||
switch {
|
||||
case a[0].T < b[0].T:
|
||||
merged, a = append(merged, a[0]), a[1:]
|
||||
case b[0].T < a[0].T:
|
||||
merged, b = append(merged, b[0]), b[1:]
|
||||
default:
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "vector cannot contain metrics with the same labelset")
|
||||
}
|
||||
}
|
||||
out[idx].Floats = append(append(merged, a...), b...)
|
||||
continue
|
||||
}
|
||||
index[hash] = len(out)
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func resultToMatrix(res *promql.Result) (promql.Matrix, error) {
|
||||
switch v := res.Value.(type) {
|
||||
case promql.Matrix:
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
|
||||
"github.com/prometheus/prometheus/model/labels"
|
||||
"github.com/prometheus/prometheus/promql"
|
||||
"github.com/prometheus/prometheus/promql/parser"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -679,26 +678,3 @@ func TestMergeSameLabelsetSeries(t *testing.T) {
|
||||
require.Error(t, err, "two values on one evaluation timestamp is the engine's duplicate error")
|
||||
assert.True(t, errors.Ast(err, errors.TypeInvalidInput))
|
||||
}
|
||||
|
||||
// Hybrid twin case: stripping the synthetic __name__ can leave two engine
|
||||
// output series distinguishable only by those names (-metric_a or -metric_b:
|
||||
// both {} once real names are dropped). Pinned by conformance cases
|
||||
// name_label_dropping.test:137 and operators.test:1016.
|
||||
func TestMergeMatrixByLabelset(t *testing.T) {
|
||||
empty := labels.EmptyLabels()
|
||||
|
||||
out, err := mergeMatrixByLabelset(promql.Matrix{
|
||||
{Metric: empty, Floats: []promql.FPoint{{T: 0, F: -1}}},
|
||||
{Metric: empty, Floats: []promql.FPoint{{T: 600_000, F: -4}}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out, 1)
|
||||
assert.Equal(t, []promql.FPoint{{T: 0, F: -1}, {T: 600_000, F: -4}}, out[0].Floats)
|
||||
|
||||
_, err = mergeMatrixByLabelset(promql.Matrix{
|
||||
{Metric: empty, Floats: []promql.FPoint{{T: 0, F: -1}}},
|
||||
{Metric: empty, Floats: []promql.FPoint{{T: 0, F: -3}}},
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Ast(err, errors.TypeInvalidInput))
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/http/binding"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
@@ -52,8 +53,8 @@ func (handler *handler) QueryRange(rw http.ResponseWriter, req *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
var queryRangeRequest qbtypes.QueryRangeRequest
|
||||
if err := binding.JSON.BindBody(req.Body, &queryRangeRequest); err != nil {
|
||||
queryRangeRequest, err := coretypes.BodyFromContext[qbtypes.QueryRangeRequest](req.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
@@ -70,7 +71,7 @@ func (handler *handler) QueryRange(rw http.ResponseWriter, req *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
queryRangeResponse, err := handler.querier.QueryRange(ctx, orgID, &queryRangeRequest)
|
||||
queryRangeResponse, err := handler.querier.QueryRange(ctx, orgID, queryRangeRequest)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
@@ -96,8 +97,8 @@ func (handler *handler) QueryRangePreview(rw http.ResponseWriter, req *http.Requ
|
||||
return
|
||||
}
|
||||
|
||||
var queryRangeRequest qbtypes.QueryRangeRequest
|
||||
if err := json.NewDecoder(req.Body).Decode(&queryRangeRequest); err != nil {
|
||||
queryRangeRequest, err := coretypes.BodyFromContext[qbtypes.QueryRangeRequest](req.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
@@ -118,7 +119,7 @@ func (handler *handler) QueryRangePreview(rw http.ResponseWriter, req *http.Requ
|
||||
return
|
||||
}
|
||||
|
||||
preview, err := handler.querier.QueryRangePreview(ctx, orgID, &queryRangeRequest, previewOpts)
|
||||
preview, err := handler.querier.QueryRangePreview(ctx, orgID, queryRangeRequest, previewOpts)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
|
||||
@@ -2,7 +2,6 @@ package querybuilder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
@@ -10,7 +9,6 @@ import (
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TelemetrySelector(_ context.Context, resource coretypes.Resource, id string, _ valuer.UUID) ([]coretypes.Selector, error) {
|
||||
@@ -29,20 +27,19 @@ func TelemetrySelector(_ context.Context, resource coretypes.Resource, id string
|
||||
}
|
||||
|
||||
func QueryRangeResources(ec coretypes.ExtractorContext) ([]coretypes.ResourceWithID, error) {
|
||||
queries := gjson.GetBytes(ec.RequestBody, "compositeQuery.queries")
|
||||
if !queries.IsArray() || len(queries.Array()) == 0 {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "atleast one query is required")
|
||||
}
|
||||
|
||||
variables, err := queryRangeVariables(ec.RequestBody)
|
||||
req, err := coretypes.BodyAs[qbtypes.QueryRangeRequest](ec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
refs := make([]coretypes.ResourceWithID, 0, len(queries.Array()))
|
||||
if len(req.CompositeQuery.Queries) == 0 {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "atleast one query is required")
|
||||
}
|
||||
|
||||
refs := make([]coretypes.ResourceWithID, 0, len(req.CompositeQuery.Queries))
|
||||
seen := make(map[string]struct{})
|
||||
for _, query := range queries.Array() {
|
||||
queryRefs, err := resourcesForQuery(query, variables)
|
||||
for _, query := range req.CompositeQuery.Queries {
|
||||
queryRefs, err := resourcesForQuery(query, req.Variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -60,21 +57,6 @@ func QueryRangeResources(ec coretypes.ExtractorContext) ([]coretypes.ResourceWit
|
||||
return refs, nil
|
||||
}
|
||||
|
||||
func queryRangeVariables(body []byte) (map[string]qbtypes.VariableItem, error) {
|
||||
variables := make(map[string]qbtypes.VariableItem)
|
||||
|
||||
raw := gjson.GetBytes(body, "variables")
|
||||
if !raw.Exists() {
|
||||
return variables, nil
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(raw.Raw), &variables); err != nil {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid variables in query range request")
|
||||
}
|
||||
|
||||
return variables, nil
|
||||
}
|
||||
|
||||
// PromQLResources is the resource set of a bare PromQL query: metrics on
|
||||
// the promql wildcard, the same ID resourcesForQuery assigns to a PromQL
|
||||
// query inside a composite — one grant covers both entry points.
|
||||
@@ -85,42 +67,53 @@ func PromQLResources(coretypes.ExtractorContext) ([]coretypes.ResourceWithID, er
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func resourcesForQuery(query gjson.Result, variables map[string]qbtypes.VariableItem) ([]coretypes.ResourceWithID, error) {
|
||||
queryType := query.Get("type").String()
|
||||
func resourcesForQuery(query qbtypes.QueryEnvelope, variables map[string]qbtypes.VariableItem) ([]coretypes.ResourceWithID, error) {
|
||||
queryType := query.Type.StringValue()
|
||||
typeWildcard := queryType + "/" + coretypes.WildCardSelectorString
|
||||
|
||||
switch queryType {
|
||||
case qbtypes.QueryTypeBuilder.StringValue(), qbtypes.QueryTypeSubQuery.StringValue():
|
||||
return resourcesForBuilderQuery(queryType, query.Get("spec"), variables)
|
||||
case qbtypes.QueryTypeBuilderAI.StringValue():
|
||||
switch query.Type {
|
||||
case qbtypes.QueryTypeBuilder, qbtypes.QueryTypeSubQuery:
|
||||
return resourcesForBuilderQuery(queryType, query.Spec, variables)
|
||||
case qbtypes.QueryTypeBuilderAI:
|
||||
// always a traces query; the signal may be absent from the payload
|
||||
return builderQueryResourceRefs(queryType, coretypes.ResourceTelemetryResourceTraces, query.Get("spec"), variables)
|
||||
case qbtypes.QueryTypePromQL.StringValue():
|
||||
_, _, expression, err := builderQuerySpec(query.Spec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return builderQueryResourceRefs(queryType, coretypes.ResourceTelemetryResourceTraces, expression, variables)
|
||||
case qbtypes.QueryTypePromQL:
|
||||
return []coretypes.ResourceWithID{{Resource: coretypes.ResourceTelemetryResourceMetrics, ID: typeWildcard}}, nil
|
||||
case qbtypes.QueryTypeClickHouseSQL.StringValue():
|
||||
case qbtypes.QueryTypeClickHouseSQL:
|
||||
return []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: typeWildcard},
|
||||
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: typeWildcard},
|
||||
{Resource: coretypes.ResourceTelemetryResourceMetrics, ID: typeWildcard},
|
||||
{Resource: coretypes.ResourceTelemetryResourceMeterMetrics, ID: typeWildcard},
|
||||
}, nil
|
||||
case qbtypes.QueryTypeFormula.StringValue(), qbtypes.QueryTypeJoin.StringValue(), qbtypes.QueryTypeTraceOperator.StringValue():
|
||||
case qbtypes.QueryTypeFormula, qbtypes.QueryTypeJoin, qbtypes.QueryTypeTraceOperator:
|
||||
return nil, nil
|
||||
default:
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported query type %q", queryType)
|
||||
}
|
||||
}
|
||||
|
||||
func resourcesForBuilderQuery(queryType string, spec gjson.Result, variables map[string]qbtypes.VariableItem) ([]coretypes.ResourceWithID, error) {
|
||||
resource, err := builderQueryResource(spec)
|
||||
func resourcesForBuilderQuery(queryType string, spec any, variables map[string]qbtypes.VariableItem) ([]coretypes.ResourceWithID, error) {
|
||||
signal, source, expression, err := builderQuerySpec(spec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return builderQueryResourceRefs(queryType, resource, spec, variables)
|
||||
|
||||
resource, err := builderQueryResource(signal, source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return builderQueryResourceRefs(queryType, resource, expression, variables)
|
||||
}
|
||||
|
||||
func builderQueryResourceRefs(queryType string, resource coretypes.Resource, spec gjson.Result, variables map[string]qbtypes.VariableItem) ([]coretypes.ResourceWithID, error) {
|
||||
ids, err := builderQuerySelectors(queryType, spec.Get("filter.expression").String(), variables)
|
||||
func builderQueryResourceRefs(queryType string, resource coretypes.Resource, expression string, variables map[string]qbtypes.VariableItem) ([]coretypes.ResourceWithID, error) {
|
||||
ids, err := builderQuerySelectors(queryType, expression, variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -133,27 +126,46 @@ func builderQueryResourceRefs(queryType string, resource coretypes.Resource, spe
|
||||
return refs, nil
|
||||
}
|
||||
|
||||
func builderQueryResource(spec gjson.Result) (coretypes.Resource, error) {
|
||||
source := spec.Get("source").String()
|
||||
|
||||
switch spec.Get("signal").String() {
|
||||
case telemetrytypes.SignalTraces.StringValue():
|
||||
func builderQueryResource(signal telemetrytypes.Signal, source telemetrytypes.Source) (coretypes.Resource, error) {
|
||||
switch signal {
|
||||
case telemetrytypes.SignalTraces:
|
||||
return coretypes.ResourceTelemetryResourceTraces, nil
|
||||
case telemetrytypes.SignalLogs.StringValue():
|
||||
if source == telemetrytypes.SourceAudit.StringValue() {
|
||||
case telemetrytypes.SignalLogs:
|
||||
if source == telemetrytypes.SourceAudit {
|
||||
return coretypes.ResourceTelemetryResourceAuditLogs, nil
|
||||
}
|
||||
return coretypes.ResourceTelemetryResourceLogs, nil
|
||||
case telemetrytypes.SignalMetrics.StringValue():
|
||||
if source == telemetrytypes.SourceMeter.StringValue() {
|
||||
case telemetrytypes.SignalMetrics:
|
||||
if source == telemetrytypes.SourceMeter {
|
||||
return coretypes.ResourceTelemetryResourceMeterMetrics, nil
|
||||
}
|
||||
return coretypes.ResourceTelemetryResourceMetrics, nil
|
||||
default:
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported signal %q", spec.Get("signal").String())
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported signal %q", signal.StringValue())
|
||||
}
|
||||
}
|
||||
|
||||
func builderQuerySpec(spec any) (telemetrytypes.Signal, telemetrytypes.Source, string, error) {
|
||||
switch typed := spec.(type) {
|
||||
case qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]:
|
||||
return typed.Signal, typed.Source, filterExpression(typed.Filter), nil
|
||||
case qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]:
|
||||
return typed.Signal, typed.Source, filterExpression(typed.Filter), nil
|
||||
case qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]:
|
||||
return typed.Signal, typed.Source, filterExpression(typed.Filter), nil
|
||||
default:
|
||||
return telemetrytypes.Signal{}, telemetrytypes.Source{}, "", errors.Newf(errors.TypeInternal, errors.CodeInternal, "unexpected builder query spec %T", spec)
|
||||
}
|
||||
}
|
||||
|
||||
func filterExpression(filter *qbtypes.Filter) string {
|
||||
if filter == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return filter.Expression
|
||||
}
|
||||
|
||||
func builderQuerySelectors(queryType, expression string, variables map[string]qbtypes.VariableItem) ([]string, error) {
|
||||
typeWildcard := queryType + "/" + coretypes.WildCardSelectorString
|
||||
|
||||
|
||||
@@ -2,14 +2,24 @@ package querybuilder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/binding"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func queryRangeExtractorContext(t *testing.T, body string) coretypes.ExtractorContext {
|
||||
t.Helper()
|
||||
req := new(qbtypes.QueryRangeRequest)
|
||||
require.NoError(t, binding.JSON.BindBody(strings.NewReader(body), req))
|
||||
return coretypes.ExtractorContext{RequestBody: req}
|
||||
}
|
||||
|
||||
func builderQueryBody(signal, filterExpression string) string {
|
||||
return `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"` + signal + `","filter":{"expression":"` + filterExpression + `"}}}]}}`
|
||||
}
|
||||
@@ -142,6 +152,13 @@ func TestQueryRangeResources(t *testing.T) {
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/checkout"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "DuplicateSignalKey_LastValueWins",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs","signal":"traces","filter":{"expression":"signoz.workspace.key.id = 'a'"}}}]}}`,
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/signoz.workspace.key.id/a"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "duplicate queries dedupe",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs","filter":{"expression":"signoz.workspace.key.id = 'a'"}}},{"type":"builder_query","spec":{"signal":"logs","filter":{"expression":"signoz.workspace.key.id='a'"}}}]}}`,
|
||||
@@ -153,7 +170,7 @@ func TestQueryRangeResources(t *testing.T) {
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
refs, err := QueryRangeResources(coretypes.ExtractorContext{RequestBody: []byte(testCase.body)})
|
||||
refs, err := QueryRangeResources(queryRangeExtractorContext(t, testCase.body))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testCase.expected, refs)
|
||||
})
|
||||
@@ -165,14 +182,20 @@ func TestQueryRangeResourcesErrors(t *testing.T) {
|
||||
`{"compositeQuery":{"queries":[]}}`,
|
||||
`{}`,
|
||||
builderQueryBody("logs", "signoz.workspace.key.id = "),
|
||||
`{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"unknown"}}]}}`,
|
||||
`{"compositeQuery":{"queries":[{"type":"unknown_type"}]}}`,
|
||||
}
|
||||
|
||||
for _, body := range bodies {
|
||||
_, err := QueryRangeResources(coretypes.ExtractorContext{RequestBody: []byte(body)})
|
||||
_, err := QueryRangeResources(queryRangeExtractorContext(t, body))
|
||||
assert.Error(t, err, "body %s", body)
|
||||
}
|
||||
|
||||
// rejected by the decode the middleware runs, before any extractor
|
||||
for _, body := range []string{
|
||||
`{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"unknown"}}]}}`,
|
||||
`{"compositeQuery":{"queries":[{"type":"unknown_type"}]}}`,
|
||||
} {
|
||||
assert.Error(t, binding.JSON.BindBody(strings.NewReader(body), new(qbtypes.QueryRangeRequest)), "body %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelemetrySelector(t *testing.T) {
|
||||
|
||||
@@ -161,7 +161,7 @@ func NewModules(
|
||||
LogsPipeline: impllogspipeline.NewModule(sqlstore),
|
||||
RuleStateHistory: implrulestatehistory.NewModule(implrulestatehistory.NewStore(telemetryStore, telemetryMetadataStore, providerSettings.Logger), ruleStore),
|
||||
CloudIntegration: cloudIntegrationModule,
|
||||
TraceDetail: impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore, telemetryMetadataStore, fl), providerSettings, config.TraceDetail),
|
||||
TraceDetail: impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore), providerSettings, config.TraceDetail),
|
||||
SpanMapper: spanMapper,
|
||||
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore), querier),
|
||||
Tag: tagModule,
|
||||
|
||||
@@ -257,7 +257,6 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewAddCloudIntegrationTuplesFactory(sqlstore),
|
||||
sqlmigration.NewAddNotificationChannelTuplesFactory(sqlstore),
|
||||
sqlmigration.NewAddAIObservabilityQuickFiltersFactory(sqlstore),
|
||||
sqlmigration.NewAddChannelSpecFactory(sqlschema),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,737 +0,0 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlschema"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
)
|
||||
|
||||
var channelSpecBackfillKinds = []channelSpecBackfillKind{
|
||||
{kind: "slack", configsKey: "slack_configs", convert: convertSlackNotifierJSON},
|
||||
{kind: "email", configsKey: "email_configs", convert: convertEmailNotifierJSON},
|
||||
{kind: "webhook", configsKey: "webhook_configs", convert: convertWebhookNotifierJSON},
|
||||
{kind: "pagerduty", configsKey: "pagerduty_configs", convert: convertPagerdutyNotifierJSON},
|
||||
{kind: "opsgenie", configsKey: "opsgenie_configs", convert: convertOpsgenieNotifierJSON},
|
||||
{kind: "msteams", configsKey: "msteamsv2_configs", convert: convertMSTeamsNotifierJSON},
|
||||
{kind: "googlechat", configsKey: "googlechat_configs", convert: convertGoogleChatNotifierJSON},
|
||||
{kind: "jira", configsKey: "jira_configs", convert: convertJiraNotifierJSON},
|
||||
{kind: "jsmops", configsKey: "jsmops_configs", convert: convertJSMOpsNotifierJSON},
|
||||
{kind: "incidentio", configsKey: "incidentio_configs", convert: convertIncidentIONotifierJSON},
|
||||
}
|
||||
|
||||
func NewAddChannelSpecFactory(sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(
|
||||
factory.MustNewName("add_channel_spec"),
|
||||
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return &addChannelSpec{sqlschema: sqlschema, logger: ps.Logger}, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (migration *addChannelSpec) Register(migrations *migrate.Migrations) error {
|
||||
return migrations.Register(migration.Up, migration.Down)
|
||||
}
|
||||
|
||||
// Up adds the column and fills it from each channel's receiver, as a write
|
||||
// through a receiver does, pinning type to the kind the spec came from because
|
||||
// a read decodes the spec under it. The msteams kind, which v1 stored as
|
||||
// msteamsv2 after upstream's configs list, is renamed on every row. A receiver
|
||||
// v2 cannot represent, such as one carrying several notifiers or a notifier
|
||||
// kind v2 does not model, stays NULL and is logged; the repair endpoint is the
|
||||
// remedy for those.
|
||||
func (migration *addChannelSpec) Up(ctx context.Context, db *bun.DB) error {
|
||||
table, uniqueConstraints, err := migration.sqlschema.GetTable(ctx, sqlschema.TableName("notification_channel"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sqls := migration.sqlschema.Operator().AddColumn(table, uniqueConstraints, &sqlschema.Column{
|
||||
Name: sqlschema.ColumnName("spec"),
|
||||
DataType: sqlschema.DataTypeText,
|
||||
Nullable: true,
|
||||
}, nil)
|
||||
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
_ = tx.Rollback()
|
||||
}()
|
||||
|
||||
for _, sql := range sqls {
|
||||
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := tx.NewUpdate().Model((*channelSpecBackfillRow)(nil)).Set("type = ?", "msteams").Where("type = ?", "msteamsv2").Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rows := make([]*channelSpecBackfillRow, 0)
|
||||
if err := tx.NewSelect().Model(&rows).Where("spec IS NULL").OrderExpr("org_id, id").Scan(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
type orgStats struct{ total, filled, unrepresentable int }
|
||||
statsByOrg := map[string]*orgStats{}
|
||||
for _, row := range rows {
|
||||
stats, ok := statsByOrg[row.OrgID]
|
||||
if !ok {
|
||||
stats = &orgStats{}
|
||||
statsByOrg[row.OrgID] = stats
|
||||
}
|
||||
stats.total++
|
||||
|
||||
storedType, spec, err := channelSpecFromReceiverJSON(row.Data)
|
||||
if err != nil {
|
||||
stats.unrepresentable++
|
||||
migration.logger.WarnContext(ctx, "leaving notification channel without a v2 spec", slog.String("org_id", row.OrgID), slog.String("channel_id", row.ID), errors.Attr(err))
|
||||
continue
|
||||
}
|
||||
|
||||
encoded, err := marshalUnescaped(spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := tx.NewUpdate().
|
||||
Model((*channelSpecBackfillRow)(nil)).
|
||||
Set("spec = ?", string(encoded)).
|
||||
Set("type = ?", storedType).
|
||||
Where("id = ?", row.ID).
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
stats.filled++
|
||||
}
|
||||
|
||||
for _, orgID := range slices.Sorted(maps.Keys(statsByOrg)) {
|
||||
stats := statsByOrg[orgID]
|
||||
migration.logger.InfoContext(ctx, "filled v2 spec on notification channels", slog.String("org_id", orgID), slog.Int("total", stats.total), slog.Int("filled", stats.filled), slog.Int("unrepresentable", stats.unrepresentable))
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (migration *addChannelSpec) Down(context.Context, *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type addChannelSpec struct {
|
||||
sqlschema sqlschema.SQLSchema
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
type channelSpecBackfillRow struct {
|
||||
bun.BaseModel `bun:"table:notification_channel"`
|
||||
|
||||
ID string `bun:"id,pk"`
|
||||
OrgID string `bun:"org_id"`
|
||||
Data string `bun:"data"`
|
||||
}
|
||||
|
||||
// notifierJSON is one entry of a receiver's *_configs list as stored in
|
||||
// notification_channel.data.
|
||||
type notifierJSON map[string]json.RawMessage
|
||||
|
||||
type channelSpecBackfillKind struct {
|
||||
kind string
|
||||
configsKey string
|
||||
convert func(notifierJSON) (map[string]any, error)
|
||||
}
|
||||
|
||||
// channelSpecFromReceiverJSON mirrors the v2 read of a stored receiver: one
|
||||
// notifier of a modelled kind, with the receiver's field names renamed to the
|
||||
// spec's and its unset templates left out. The type alongside is what a read
|
||||
// decodes the spec under.
|
||||
func channelSpecFromReceiverJSON(data string) (string, map[string]any, error) {
|
||||
receiver := map[string]json.RawMessage{}
|
||||
if err := json.Unmarshal([]byte(data), &receiver); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
total := 0
|
||||
var found *channelSpecBackfillKind
|
||||
var notifier notifierJSON
|
||||
for key, raw := range receiver {
|
||||
if !strings.HasSuffix(key, "_configs") {
|
||||
continue
|
||||
}
|
||||
|
||||
var list []notifierJSON
|
||||
if err := json.Unmarshal(raw, &list); err != nil {
|
||||
return "", nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "%s", key)
|
||||
}
|
||||
total += len(list)
|
||||
if len(list) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
for i := range channelSpecBackfillKinds {
|
||||
if channelSpecBackfillKinds[i].configsKey == key {
|
||||
found = &channelSpecBackfillKinds[i]
|
||||
notifier = list[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if total > 1 {
|
||||
return "", nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "carries %d notifier configurations; only one per channel is supported", total)
|
||||
}
|
||||
if found == nil {
|
||||
return "", nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "carries no supported notifier configuration")
|
||||
}
|
||||
|
||||
spec, err := found.convert(notifier)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
sendResolved, err := notifier.boolValue("send_resolved")
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
spec["sendResolved"] = sendResolved
|
||||
|
||||
return found.kind, spec, nil
|
||||
}
|
||||
|
||||
func convertSlackNotifierJSON(notifier notifierJSON) (map[string]any, error) {
|
||||
if err := rejectAnyHTTPAuthJSON(notifier); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
spec := map[string]any{}
|
||||
if err := notifier.copyStrings(spec, map[string]string{"api_url": "apiUrl", "channel": "channel"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := notifier.copyNonEmptyStrings(spec, map[string]string{"title": "title", "text": "text", "color": "color", "title_link": "titleLink", "pretext": "pretext", "fallback": "fallback", "footer": "footer"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if spec["apiUrl"] == "" {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "slack: api_url is required")
|
||||
}
|
||||
|
||||
fields, err := notifier.objectList("fields")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(fields) > 0 {
|
||||
converted := make([]map[string]any, 0, len(fields))
|
||||
for _, field := range fields {
|
||||
item := map[string]any{}
|
||||
if err := field.copyStrings(item, map[string]string{"title": "title", "value": "value"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if field.has("short") {
|
||||
short, err := field.boolValue("short")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item["short"] = short
|
||||
}
|
||||
converted = append(converted, item)
|
||||
}
|
||||
spec["fields"] = converted
|
||||
}
|
||||
|
||||
actions, err := notifier.objectList("actions")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(actions) > 0 {
|
||||
converted := make([]map[string]any, 0, len(actions))
|
||||
for _, action := range actions {
|
||||
item := map[string]any{}
|
||||
if err := action.copyStrings(item, map[string]string{"type": "type", "text": "text", "url": "url", "style": "style", "name": "name", "value": "value"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if action.has("confirm") {
|
||||
confirm, err := action.object("confirm")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
confirmation := map[string]any{}
|
||||
if err := confirm.copyStrings(confirmation, map[string]string{"text": "text", "title": "title", "ok_text": "okText", "dismiss_text": "dismissText"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item["confirm"] = confirmation
|
||||
}
|
||||
converted = append(converted, item)
|
||||
}
|
||||
spec["actions"] = converted
|
||||
}
|
||||
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
func convertEmailNotifierJSON(notifier notifierJSON) (map[string]any, error) {
|
||||
spec := map[string]any{}
|
||||
if err := notifier.copyStrings(spec, map[string]string{"to": "to"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := notifier.copyNonEmptyStrings(spec, map[string]string{"html": "html"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if spec["to"] == "" {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "email: to is required")
|
||||
}
|
||||
if err := notifier.copyNonEmptyObjects(spec, map[string]string{"headers": "headers"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
func convertWebhookNotifierJSON(notifier notifierJSON) (map[string]any, error) {
|
||||
spec := map[string]any{}
|
||||
if err := notifier.copyStrings(spec, map[string]string{"url": "url"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if spec["url"] == "" {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "webhook: url is required")
|
||||
}
|
||||
|
||||
if err := rejectUnsupportedHTTPConfigJSON(notifier); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpConfig, err := notifier.object("http_config")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
username, password, err := extractBasicAuthJSON(httpConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bearerToken, err := extractBearerTokenJSON(httpConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if (username != "" || password != "") && bearerToken != "" {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "webhook: basic auth and bearer token cannot be combined")
|
||||
}
|
||||
spec["username"], spec["password"], spec["bearerToken"] = username, password, bearerToken
|
||||
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
func convertPagerdutyNotifierJSON(notifier notifierJSON) (map[string]any, error) {
|
||||
if err := rejectAnyHTTPAuthJSON(notifier); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
spec := map[string]any{}
|
||||
if err := notifier.copyStrings(spec, map[string]string{"routing_key": "routingKey", "url": "url", "severity": "severity", "component": "component", "group": "group", "class": "class"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := notifier.copyNonEmptyStrings(spec, map[string]string{"source": "source", "client": "client", "client_url": "clientUrl", "description": "description"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if spec["routingKey"] == "" {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "pagerduty: routing_key is required")
|
||||
}
|
||||
|
||||
details, err := notifier.object("details")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(details) > 0 {
|
||||
for key, raw := range details {
|
||||
var value string
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "pagerduty: details.%s is not a string", key)
|
||||
}
|
||||
}
|
||||
spec["details"] = notifier["details"]
|
||||
}
|
||||
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
func convertOpsgenieNotifierJSON(notifier notifierJSON) (map[string]any, error) {
|
||||
if err := rejectAnyHTTPAuthJSON(notifier); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
spec := map[string]any{}
|
||||
if err := notifier.copyStrings(spec, map[string]string{"api_key": "apiKey", "api_url": "apiUrl", "priority": "priority"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := notifier.copyNonEmptyStrings(spec, map[string]string{"message": "message", "description": "description", "source": "source"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if spec["apiKey"] == "" {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "opsgenie: api_key is required")
|
||||
}
|
||||
if err := notifier.copyNonEmptyObjects(spec, map[string]string{"details": "details"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
func convertMSTeamsNotifierJSON(notifier notifierJSON) (map[string]any, error) {
|
||||
return convertWebhookURLNotifierJSON("msteams", notifier)
|
||||
}
|
||||
|
||||
func convertGoogleChatNotifierJSON(notifier notifierJSON) (map[string]any, error) {
|
||||
return convertWebhookURLNotifierJSON("googlechat", notifier)
|
||||
}
|
||||
|
||||
func convertWebhookURLNotifierJSON(name string, notifier notifierJSON) (map[string]any, error) {
|
||||
if err := rejectAnyHTTPAuthJSON(notifier); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
spec := map[string]any{}
|
||||
if err := notifier.copyStrings(spec, map[string]string{"webhook_url": "webhookUrl"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := notifier.copyNonEmptyStrings(spec, map[string]string{"title": "title", "text": "text"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if spec["webhookUrl"] == "" {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "%s: webhook_url is required", name)
|
||||
}
|
||||
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
func convertJiraNotifierJSON(notifier notifierJSON) (map[string]any, error) {
|
||||
spec := map[string]any{}
|
||||
if err := notifier.copyStrings(spec, map[string]string{"site": "site", "project": "project", "issue_type": "issueType", "priority": "priority", "resolve_transition": "resolveTransition", "reopen_transition": "reopenTransition", "wont_fix_resolution": "wontFixResolution"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := notifier.copyNonEmptyStrings(spec, map[string]string{"summary": "summary", "description": "description", "reopen_duration": "reopenDuration"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := notifier.copyNonEmptyObjects(spec, map[string]string{"custom_fields": "customFields"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
labels, err := notifier.list("labels")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(labels) > 0 {
|
||||
spec["labels"] = notifier["labels"]
|
||||
}
|
||||
|
||||
if err := rejectUnsupportedHTTPConfigJSON(notifier); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpConfig, err := notifier.object("http_config")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if httpConfig.has("authorization") {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "jira: http_config.authorization is not supported")
|
||||
}
|
||||
email, apiToken, err := extractBasicAuthJSON(httpConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
spec["email"], spec["apiToken"] = email, apiToken
|
||||
|
||||
for _, required := range []string{"site", "project", "issueType", "email", "apiToken"} {
|
||||
if spec[required] == "" {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "jira: %s is required", required)
|
||||
}
|
||||
}
|
||||
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
func convertJSMOpsNotifierJSON(notifier notifierJSON) (map[string]any, error) {
|
||||
if err := rejectAnyHTTPAuthJSON(notifier); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
spec := map[string]any{}
|
||||
if err := notifier.copyStrings(spec, map[string]string{"api_key": "apiKey", "priority": "priority"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := notifier.copyNonEmptyStrings(spec, map[string]string{"message": "message", "description": "description", "tags": "tags"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if spec["apiKey"] == "" {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "jsmops: api_key is required")
|
||||
}
|
||||
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
func convertIncidentIONotifierJSON(notifier notifierJSON) (map[string]any, error) {
|
||||
if err := rejectAnyHTTPAuthJSON(notifier); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
spec := map[string]any{}
|
||||
if err := notifier.copyStrings(spec, map[string]string{"url": "url", "token": "token"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := notifier.copyNonEmptyStrings(spec, map[string]string{"title": "title", "description": "description"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if spec["url"] == "" || spec["token"] == "" {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "incidentio: url and token are required")
|
||||
}
|
||||
if err := notifier.copyNonEmptyObjects(spec, map[string]string{"metadata": "metadata"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
func rejectAnyHTTPAuthJSON(notifier notifierJSON) error {
|
||||
httpConfig, err := notifier.object("http_config")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if httpConfig.has("basic_auth") {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "http_config.basic_auth is not supported")
|
||||
}
|
||||
if httpConfig.has("authorization") {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "http_config.authorization is not supported")
|
||||
}
|
||||
|
||||
return rejectUnsupportedHTTPConfigJSON(notifier)
|
||||
}
|
||||
|
||||
// rejectUnsupportedHTTPConfigJSON refuses every http_config setting the spec has
|
||||
// no field for, since a config that dropped it would unauthenticate or reroute
|
||||
// the channel on the next write. An absent follow_redirects or enable_http2 is
|
||||
// the upstream default, true; only an explicit false is refused.
|
||||
func rejectUnsupportedHTTPConfigJSON(notifier notifierJSON) error {
|
||||
if !notifier.has("http_config") {
|
||||
return nil
|
||||
}
|
||||
httpConfig, err := notifier.object("http_config")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, key := range []string{"oauth2", "http_headers"} {
|
||||
if httpConfig.has(key) {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "http_config.%s is not supported", key)
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"bearer_token", "bearer_token_file", "proxy_url", "no_proxy"} {
|
||||
value, err := httpConfig.stringValue(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if value != "" {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "http_config.%s is not supported", key)
|
||||
}
|
||||
}
|
||||
if httpConfig.has("proxy_from_environment") {
|
||||
fromEnvironment, err := httpConfig.boolValue("proxy_from_environment")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if fromEnvironment {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "http_config.proxy_from_environment is not supported")
|
||||
}
|
||||
}
|
||||
|
||||
tlsConfig, err := httpConfig.object("tls_config")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for key := range tlsConfig {
|
||||
if key != "insecure_skip_verify" {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "http_config.tls_config is not supported")
|
||||
}
|
||||
}
|
||||
if tlsConfig.has("insecure_skip_verify") {
|
||||
insecure, err := tlsConfig.boolValue("insecure_skip_verify")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if insecure {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "http_config.tls_config is not supported")
|
||||
}
|
||||
}
|
||||
|
||||
for _, key := range []string{"follow_redirects", "enable_http2"} {
|
||||
if !httpConfig.has(key) {
|
||||
continue
|
||||
}
|
||||
enabled, err := httpConfig.boolValue(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !enabled {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "http_config.%s cannot be disabled", key)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractBasicAuthJSON(httpConfig notifierJSON) (string, string, error) {
|
||||
basicAuth, err := httpConfig.object("basic_auth")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if len(basicAuth) == 0 {
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
for key := range basicAuth {
|
||||
if key != "username" && key != "password" {
|
||||
return "", "", errors.NewInvalidInputf(errors.CodeInvalidInput, "http_config.basic_auth.%s is not supported", key)
|
||||
}
|
||||
}
|
||||
username, err := basicAuth.stringValue("username")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
password, err := basicAuth.stringValue("password")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
return username, password, nil
|
||||
}
|
||||
|
||||
func extractBearerTokenJSON(httpConfig notifierJSON) (string, error) {
|
||||
if !httpConfig.has("authorization") {
|
||||
return "", nil
|
||||
}
|
||||
authorization, err := httpConfig.object("authorization")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for key := range authorization {
|
||||
if key != "type" && key != "credentials" {
|
||||
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "http_config.authorization.%s is not supported", key)
|
||||
}
|
||||
}
|
||||
scheme, err := authorization.stringValue("type")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !strings.EqualFold(scheme, "Bearer") {
|
||||
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "http_config.authorization.type %q is not supported", scheme)
|
||||
}
|
||||
|
||||
return authorization.stringValue("credentials")
|
||||
}
|
||||
|
||||
// has reports a key that is present and not null.
|
||||
func (n notifierJSON) has(key string) bool {
|
||||
raw, ok := n[key]
|
||||
return ok && string(raw) != "null"
|
||||
}
|
||||
|
||||
func (n notifierJSON) stringValue(key string) (string, error) {
|
||||
if !n.has(key) {
|
||||
return "", nil
|
||||
}
|
||||
var value string
|
||||
if err := json.Unmarshal(n[key], &value); err != nil {
|
||||
return "", errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "%s", key)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (n notifierJSON) boolValue(key string) (bool, error) {
|
||||
if !n.has(key) {
|
||||
return false, nil
|
||||
}
|
||||
var value bool
|
||||
if err := json.Unmarshal(n[key], &value); err != nil {
|
||||
return false, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "%s", key)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// object reads an absent key as an empty object; a caller that must tell the
|
||||
// two apart checks has first.
|
||||
func (n notifierJSON) object(key string) (notifierJSON, error) {
|
||||
if !n.has(key) {
|
||||
return notifierJSON{}, nil
|
||||
}
|
||||
value := notifierJSON{}
|
||||
if err := json.Unmarshal(n[key], &value); err != nil {
|
||||
return nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "%s", key)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (n notifierJSON) list(key string) ([]json.RawMessage, error) {
|
||||
if !n.has(key) {
|
||||
return nil, nil
|
||||
}
|
||||
var value []json.RawMessage
|
||||
if err := json.Unmarshal(n[key], &value); err != nil {
|
||||
return nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "%s", key)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (n notifierJSON) objectList(key string) ([]notifierJSON, error) {
|
||||
if !n.has(key) {
|
||||
return nil, nil
|
||||
}
|
||||
var value []notifierJSON
|
||||
if err := json.Unmarshal(n[key], &value); err != nil {
|
||||
return nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "%s", key)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// copyStrings writes each field as the spec's plain string, "" when absent.
|
||||
func (n notifierJSON) copyStrings(spec map[string]any, keys map[string]string) error {
|
||||
for from, to := range keys {
|
||||
value, err := n.stringValue(from)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
spec[to] = value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyNonEmptyStrings leaves an empty field out, which is how the spec spells
|
||||
// an unset template.
|
||||
func (n notifierJSON) copyNonEmptyStrings(spec map[string]any, keys map[string]string) error {
|
||||
for from, to := range keys {
|
||||
value, err := n.stringValue(from)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if value != "" {
|
||||
spec[to] = value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n notifierJSON) copyNonEmptyObjects(spec map[string]any, keys map[string]string) error {
|
||||
for from, to := range keys {
|
||||
value, err := n.object(from)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(value) > 0 {
|
||||
spec[to] = n[from]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -15,12 +15,11 @@ var (
|
||||
aiobservabilitytypes.GenAIAgentName: genAIAttribute(aiobservabilitytypes.GenAIAgentName, telemetrytypes.FieldDataTypeString),
|
||||
aiobservabilitytypes.GenAIProviderName: genAIAttribute(aiobservabilitytypes.GenAIProviderName, telemetrytypes.FieldDataTypeString),
|
||||
|
||||
aiobservabilitytypes.GenAIUsageInputTokens: genAIAttribute(aiobservabilitytypes.GenAIUsageInputTokens, telemetrytypes.FieldDataTypeNumber),
|
||||
aiobservabilitytypes.GenAIUsageOutputTokens: genAIAttribute(aiobservabilitytypes.GenAIUsageOutputTokens, telemetrytypes.FieldDataTypeNumber),
|
||||
aiobservabilitytypes.GenAIUsageCacheReadInputTokens: genAIAttribute(aiobservabilitytypes.GenAIUsageCacheReadInputTokens, telemetrytypes.FieldDataTypeNumber),
|
||||
aiobservabilitytypes.GenAIUsageCacheCreationInputTokens: genAIAttribute(aiobservabilitytypes.GenAIUsageCacheCreationInputTokens, telemetrytypes.FieldDataTypeNumber),
|
||||
aiobservabilitytypes.GenAIUsageReasoningOutputTokens: genAIAttribute(aiobservabilitytypes.GenAIUsageReasoningOutputTokens, telemetrytypes.FieldDataTypeNumber),
|
||||
aiobservabilitytypes.SignozGenAITotalCost: genAIAttribute(aiobservabilitytypes.SignozGenAITotalCost, telemetrytypes.FieldDataTypeNumber),
|
||||
aiobservabilitytypes.GenAIUsageInputTokens: genAIAttribute(aiobservabilitytypes.GenAIUsageInputTokens, telemetrytypes.FieldDataTypeFloat64),
|
||||
aiobservabilitytypes.GenAIUsageOutputTokens: genAIAttribute(aiobservabilitytypes.GenAIUsageOutputTokens, telemetrytypes.FieldDataTypeFloat64),
|
||||
aiobservabilitytypes.GenAIUsageCacheReadInputTokens: genAIAttribute(aiobservabilitytypes.GenAIUsageCacheReadInputTokens, telemetrytypes.FieldDataTypeFloat64),
|
||||
aiobservabilitytypes.GenAIUsageCacheCreationInputTokens: genAIAttribute(aiobservabilitytypes.GenAIUsageCacheCreationInputTokens, telemetrytypes.FieldDataTypeFloat64),
|
||||
aiobservabilitytypes.SignozGenAITotalCost: genAIAttribute(aiobservabilitytypes.SignozGenAITotalCost, telemetrytypes.FieldDataTypeFloat64),
|
||||
|
||||
aiobservabilitytypes.GenAIInputMessages: genAIAttribute(aiobservabilitytypes.GenAIInputMessages, telemetrytypes.FieldDataTypeString),
|
||||
aiobservabilitytypes.GenAIOutputMessages: genAIAttribute(aiobservabilitytypes.GenAIOutputMessages, telemetrytypes.FieldDataTypeString),
|
||||
|
||||
@@ -15,7 +15,6 @@ const (
|
||||
GenAIUsageOutputTokens = "gen_ai.usage.output_tokens"
|
||||
GenAIUsageCacheReadInputTokens = "gen_ai.usage.cache_read.input_tokens"
|
||||
GenAIUsageCacheCreationInputTokens = "gen_ai.usage.cache_creation.input_tokens"
|
||||
GenAIUsageReasoningOutputTokens = "gen_ai.usage.reasoning.output_tokens"
|
||||
|
||||
GenAIInputMessages = "gen_ai.input.messages"
|
||||
GenAIOutputMessages = "gen_ai.output.messages"
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
package alertmanagertypes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
"github.com/swaggest/jsonschema-go"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
@@ -26,6 +30,20 @@ var (
|
||||
|
||||
type Channels = []*Channel
|
||||
|
||||
type GettableChannels = []*Channel
|
||||
|
||||
// TODO: the oneOf emitted by JSONSchema is not the shape OpenAPI wants for a
|
||||
// discriminated union. OpenAPI's discriminator requires every oneOf branch to
|
||||
// be a $ref to a named component and a sibling property whose value selects
|
||||
// the variant. Our payload instead uses the *presence* of one of the 18
|
||||
// *_configs arrays to imply the type, so no discriminator can be attached.
|
||||
// Refactor PostableChannel into a {name, type, config} envelope (see
|
||||
// ruletypes.RuleThresholdData for the pattern) so each notification kind
|
||||
// becomes a named component and the discriminator can be wired up properly.
|
||||
type PostableChannel struct {
|
||||
Receiver
|
||||
}
|
||||
|
||||
// Channel represents a single receiver of the alertmanager config.
|
||||
type Channel struct {
|
||||
bun.BaseModel `bun:"table:notification_channel"`
|
||||
@@ -37,51 +55,45 @@ type Channel struct {
|
||||
// reference, so it keeps the v1 wire tag and Name stays off the v1 contract.
|
||||
Name string `json:"-" bun:"name"`
|
||||
DisplayName string `json:"name" required:"true" bun:"display_name"`
|
||||
// TODO: type this as ChannelKind once v1 is gone.
|
||||
Type string `json:"type" required:"true" bun:"type"`
|
||||
Data string `json:"data" required:"true" bun:"data"`
|
||||
OrgID string `json:"orgId" required:"true" bun:"org_id"`
|
||||
|
||||
// Spec is the v2 spec a read returns, of the kind Type names. A v2 write
|
||||
// stores it as the caller wrote it and a v1 write derives it from the
|
||||
// defaulted receiver. Only a row the migration could not backfill has none.
|
||||
// StoredSpec is the column, written by fillSpec and decoded by AfterScanRow.
|
||||
Spec ChannelSpec `json:"-" bun:"-"`
|
||||
StoredSpec string `json:"-" bun:"spec,type:text,nullzero"`
|
||||
Type string `json:"type" required:"true" bun:"type"`
|
||||
Data string `json:"data" required:"true" bun:"data"`
|
||||
OrgID string `json:"orgId" required:"true" bun:"org_id"`
|
||||
}
|
||||
|
||||
var _ bun.AfterScanRowHook = (*Channel)(nil)
|
||||
|
||||
// AfterScanRow decodes the stored spec under Type, which bun cannot do column by
|
||||
// column because the spec's Go type depends on it.
|
||||
func (c *Channel) AfterScanRow(context.Context) error {
|
||||
if c.StoredSpec == "" {
|
||||
c.Spec = nil
|
||||
return nil
|
||||
// NewChannelFromReceiver creates a new Channel from a Receiver.
|
||||
// It can return nil if the receiver is the default receiver.
|
||||
// A receiver carries no internal name, so one is generated from its name.
|
||||
func NewChannelFromReceiver(receiver *Receiver, orgID string) (*Channel, error) {
|
||||
if receiver.Name == DefaultReceiverName {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeAlertmanagerChannelInvalid, "cannot use %s name as a channel name", receiver.Name)
|
||||
}
|
||||
|
||||
channelKind, ok := parseChannelKind(c.Type)
|
||||
if !ok {
|
||||
return errors.NewInternalf(errors.CodeInternal, "channel %q stores a spec under unmodelled type %q", c.DisplayName, c.Type)
|
||||
// Initialize channel with common fields
|
||||
channel := Channel{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: valuer.GenerateUUID(),
|
||||
},
|
||||
TimeAuditable: types.TimeAuditable{
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
},
|
||||
Name: generateChannelName(receiver.Name),
|
||||
DisplayName: receiver.Name,
|
||||
OrgID: orgID,
|
||||
}
|
||||
|
||||
spec, _ := buildEmptyChannelSpecForKind(channelKind)
|
||||
if err := json.Unmarshal([]byte(c.StoredSpec), spec); err != nil {
|
||||
return errors.WrapInternalf(err, errors.CodeInternal, "unmarshal channel %q spec", c.DisplayName)
|
||||
}
|
||||
c.Spec = spec
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Channel) fillSpec(spec ChannelSpec) error {
|
||||
stored, err := json.Marshal(spec)
|
||||
data, err := json.Marshal(receiver)
|
||||
if err != nil {
|
||||
return errors.WrapInternalf(err, errors.CodeInternal, "marshal channel %q spec", c.DisplayName)
|
||||
return nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "marshal receiver")
|
||||
}
|
||||
c.Spec, c.StoredSpec = spec, string(stored)
|
||||
channel.Data = string(data)
|
||||
|
||||
return nil
|
||||
channel.Type = receiverChannelType(receiver)
|
||||
if channel.Type == "" {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeAlertmanagerChannelInvalid, "channel '%s' must have at least one notification configuration (e.g., email_configs, webhook_configs, slack_configs)", receiver.Name)
|
||||
}
|
||||
|
||||
return &channel, nil
|
||||
}
|
||||
|
||||
const channelNameSuffixLen = 8
|
||||
@@ -125,6 +137,57 @@ func generateChannelName(displayName string) string {
|
||||
return prefix + "-" + string(suffix)
|
||||
}
|
||||
|
||||
// NewChannelFromReceiverWithName overrides the name that NewChannelFromReceiver
|
||||
// generates.
|
||||
func NewChannelFromReceiverWithName(receiver *Receiver, name string, orgID string) (*Channel, error) {
|
||||
channel, err := NewChannelFromReceiver(receiver, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
channel.Name = name
|
||||
|
||||
return channel, nil
|
||||
}
|
||||
|
||||
// receiverChannelType returns the channel.Type discriminator. Walks
|
||||
// Receiver's own fields first (native), then the embed (upstream); first
|
||||
// non-empty *_configs slice wins.
|
||||
func receiverChannelType(receiver *Receiver) string {
|
||||
if t := nonEmptyConfigsField(reflect.ValueOf(*receiver)); t != "" {
|
||||
return t
|
||||
}
|
||||
if t := nonEmptyConfigsField(reflect.ValueOf(*receiver.Receiver)); t != "" {
|
||||
return t
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func nonEmptyConfigsField(v reflect.Value) string {
|
||||
t := v.Type()
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
field := t.Field(i)
|
||||
fieldVal := v.Field(i)
|
||||
|
||||
if fieldVal.Kind() != reflect.Slice || fieldVal.Len() == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
yamlTag := field.Tag.Get("yaml")
|
||||
if yamlTag == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract the base type name (e.g., "email_configs" -> "email").
|
||||
matches := receiverTypeRegex.FindStringSubmatch(yamlTag)
|
||||
if len(matches) != 2 {
|
||||
continue
|
||||
}
|
||||
return matches[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func NewConfigFromChannels(globalConfig GlobalConfig, routeConfig RouteConfig, channels Channels, orgID string) (*Config, error) {
|
||||
cfg, err := NewDefaultConfig(
|
||||
globalConfig,
|
||||
@@ -165,3 +228,64 @@ func NewStatsFromChannels(channels Channels) map[string]any {
|
||||
stats["alertmanager.channel.count"] = int64(len(channels))
|
||||
return stats
|
||||
}
|
||||
|
||||
func (c *Channel) Update(receiver *Receiver) error {
|
||||
channel, err := NewChannelFromReceiverWithName(receiver, c.Name, c.OrgID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if c.DisplayName != channel.DisplayName {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeAlertmanagerChannelNameMismatch, "cannot update channel name")
|
||||
}
|
||||
|
||||
// Unreachable while the name is passed in above rather than derived from the
|
||||
// receiver, which is why this is internal rather than invalid input.
|
||||
if c.Name != channel.Name {
|
||||
return errors.NewInternalf(ErrCodeAlertmanagerChannelNameMismatch, "cannot update channel internal name")
|
||||
}
|
||||
|
||||
c.Type = channel.Type
|
||||
c.Data = channel.Data
|
||||
c.UpdatedAt = time.Now()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (PostableChannel) JSONSchema() (jsonschema.Schema, error) {
|
||||
type alias PostableChannel
|
||||
reflector := &jsonschema.Reflector{}
|
||||
|
||||
schema, err := reflector.Reflect(alias{}, jsonschema.DefinitionsPrefix("#/components/schemas/"))
|
||||
if err != nil {
|
||||
return jsonschema.Schema{}, err
|
||||
}
|
||||
|
||||
schema.WithRequired("name")
|
||||
|
||||
var oneOf []jsonschema.SchemaOrBool
|
||||
seen := map[string]struct{}{}
|
||||
// Walk both halves: native fields on Receiver, upstream on the embed. A native
|
||||
// field can shadow an upstream one with the same tag (e.g. jira_configs), so
|
||||
// dedupe to avoid emitting two identical oneOf branches.
|
||||
collect := func(t reflect.Type) {
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
jsonTag := strings.Split(t.Field(i).Tag.Get("json"), ",")[0]
|
||||
if !strings.HasSuffix(jsonTag, "_configs") {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[jsonTag]; ok {
|
||||
continue
|
||||
}
|
||||
seen[jsonTag] = struct{}{}
|
||||
branch := (&jsonschema.Schema{}).WithRequired(jsonTag)
|
||||
oneOf = append(oneOf, branch.ToSchemaOrBool())
|
||||
}
|
||||
}
|
||||
collect(reflect.TypeOf(Receiver{}))
|
||||
collect(reflect.TypeOf(config.Receiver{}))
|
||||
|
||||
schema.WithOneOf(oneOf...)
|
||||
|
||||
return schema, nil
|
||||
}
|
||||
|
||||
@@ -16,20 +16,8 @@ import (
|
||||
type ChannelEmailConfig struct {
|
||||
SendResolved *bool `json:"sendResolved,omitempty"`
|
||||
To string `json:"to" required:"true"`
|
||||
HTML valuer.UnsetOrNonEmptyString `json:"html,omitzero"`
|
||||
Headers map[string]string `json:"headers,omitzero"`
|
||||
}
|
||||
|
||||
func (c *ChannelEmailConfig) UnmarshalJSON(data []byte) error {
|
||||
type alias ChannelEmailConfig
|
||||
if err := decodeStrict(data, (*alias)(c)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fillSendResolved(&c.SendResolved, config.DefaultEmailConfig.VSendResolved)
|
||||
c.HTML.SetIfUnset(config.DefaultEmailConfig.HTML)
|
||||
|
||||
return c.Validate()
|
||||
HTML valuer.UnsetOrNonEmptyString `json:"html"`
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
}
|
||||
|
||||
func (c ChannelEmailConfig) Validate() error {
|
||||
|
||||
@@ -10,21 +10,8 @@ import (
|
||||
type ChannelGoogleChatConfig struct {
|
||||
SendResolved *bool `json:"sendResolved,omitempty"`
|
||||
WebhookURL string `json:"webhookUrl" required:"true" format:"password"`
|
||||
Title valuer.UnsetOrNonEmptyString `json:"title,omitzero"`
|
||||
Text valuer.UnsetOrNonEmptyString `json:"text,omitzero"`
|
||||
}
|
||||
|
||||
func (c *ChannelGoogleChatConfig) UnmarshalJSON(data []byte) error {
|
||||
type alias ChannelGoogleChatConfig
|
||||
if err := decodeStrict(data, (*alias)(c)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fillSendResolved(&c.SendResolved, DefaultGoogleChatReceiverConfig.VSendResolved)
|
||||
c.Title.SetIfUnset(DefaultGoogleChatReceiverConfig.Title)
|
||||
c.Text.SetIfUnset(DefaultGoogleChatReceiverConfig.Text)
|
||||
|
||||
return c.Validate()
|
||||
Title valuer.UnsetOrNonEmptyString `json:"title"`
|
||||
Text valuer.UnsetOrNonEmptyString `json:"text"`
|
||||
}
|
||||
|
||||
func (c ChannelGoogleChatConfig) Validate() error {
|
||||
|
||||
@@ -13,22 +13,9 @@ type ChannelIncidentIOConfig struct {
|
||||
SendResolved *bool `json:"sendResolved,omitempty"`
|
||||
URL string `json:"url" required:"true"`
|
||||
Token string `json:"token" required:"true" format:"password"`
|
||||
Title valuer.UnsetOrNonEmptyString `json:"title,omitzero"`
|
||||
Description valuer.UnsetOrNonEmptyString `json:"description,omitzero"`
|
||||
Metadata map[string]string `json:"metadata,omitzero"`
|
||||
}
|
||||
|
||||
func (c *ChannelIncidentIOConfig) UnmarshalJSON(data []byte) error {
|
||||
type alias ChannelIncidentIOConfig
|
||||
if err := decodeStrict(data, (*alias)(c)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fillSendResolved(&c.SendResolved, DefaultIncidentIOReceiverConfig.VSendResolved)
|
||||
c.Title.SetIfUnset(DefaultIncidentIOReceiverConfig.Title)
|
||||
c.Description.SetIfUnset(DefaultIncidentIOReceiverConfig.Description)
|
||||
|
||||
return c.Validate()
|
||||
Title valuer.UnsetOrNonEmptyString `json:"title"`
|
||||
Description valuer.UnsetOrNonEmptyString `json:"description"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
func (c ChannelIncidentIOConfig) Validate() error {
|
||||
|
||||
@@ -19,35 +19,20 @@ type ChannelJiraConfig struct {
|
||||
Site string `json:"site" required:"true"`
|
||||
Project string `json:"project" required:"true"`
|
||||
IssueType string `json:"issueType" required:"true"`
|
||||
Summary valuer.UnsetOrNonEmptyString `json:"summary,omitzero"`
|
||||
Description valuer.UnsetOrNonEmptyString `json:"description,omitzero"`
|
||||
Summary valuer.UnsetOrNonEmptyString `json:"summary"`
|
||||
Description valuer.UnsetOrNonEmptyString `json:"description"`
|
||||
Priority string `json:"priority"`
|
||||
Labels []string `json:"labels,omitzero"`
|
||||
Labels []string `json:"labels,omitempty"`
|
||||
ResolveTransition string `json:"resolveTransition"`
|
||||
ReopenTransition string `json:"reopenTransition"`
|
||||
ReopenDuration valuer.UnsetOrNonEmptyString `json:"reopenDuration,omitzero"`
|
||||
ReopenDuration valuer.UnsetOrNonEmptyString `json:"reopenDuration"`
|
||||
WontFixResolution string `json:"wontFixResolution"`
|
||||
CustomFields map[string]any `json:"customFields,omitzero"`
|
||||
CustomFields map[string]any `json:"customFields,omitempty"`
|
||||
|
||||
Email string `json:"email" required:"true"`
|
||||
APIToken string `json:"apiToken" required:"true" format:"password"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON seeds send_resolved off, as JiraReceiverConfig does.
|
||||
func (c *ChannelJiraConfig) UnmarshalJSON(data []byte) error {
|
||||
type alias ChannelJiraConfig
|
||||
if err := decodeStrict(data, (*alias)(c)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fillSendResolved(&c.SendResolved, false)
|
||||
c.Summary.SetIfUnset(DefaultJiraSummaryTemplate)
|
||||
c.Description.SetIfUnset(DefaultJiraDescriptionTemplate)
|
||||
c.ReopenDuration.SetIfUnset(defaultJiraReopenDuration.String())
|
||||
|
||||
return c.Validate()
|
||||
}
|
||||
|
||||
func (c ChannelJiraConfig) Validate() error {
|
||||
for _, required := range []struct {
|
||||
value string
|
||||
|
||||
@@ -12,25 +12,11 @@ import (
|
||||
type ChannelJSMOpsConfig struct {
|
||||
SendResolved *bool `json:"sendResolved,omitempty"`
|
||||
APIKey string `json:"apiKey" required:"true" format:"password"`
|
||||
Message valuer.UnsetOrNonEmptyString `json:"message,omitzero"`
|
||||
Description valuer.UnsetOrNonEmptyString `json:"description,omitzero"`
|
||||
Message valuer.UnsetOrNonEmptyString `json:"message"`
|
||||
Description valuer.UnsetOrNonEmptyString `json:"description"`
|
||||
Priority string `json:"priority"`
|
||||
// Tags is the comma-separated list JSM Ops attaches to the alert.
|
||||
Tags valuer.UnsetOrNonEmptyString `json:"tags,omitzero"`
|
||||
}
|
||||
|
||||
func (c *ChannelJSMOpsConfig) UnmarshalJSON(data []byte) error {
|
||||
type alias ChannelJSMOpsConfig
|
||||
if err := decodeStrict(data, (*alias)(c)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fillSendResolved(&c.SendResolved, DefaultJSMOpsReceiverConfig.VSendResolved)
|
||||
c.Message.SetIfUnset(DefaultJSMOpsReceiverConfig.Message)
|
||||
c.Description.SetIfUnset(DefaultJSMOpsReceiverConfig.Description)
|
||||
c.Tags.SetIfUnset(DefaultJSMOpsReceiverConfig.Tags)
|
||||
|
||||
return c.Validate()
|
||||
Tags valuer.UnsetOrNonEmptyString `json:"tags"`
|
||||
}
|
||||
|
||||
func (c ChannelJSMOpsConfig) Validate() error {
|
||||
|
||||
@@ -9,21 +9,8 @@ import (
|
||||
type ChannelMSTeamsConfig struct {
|
||||
SendResolved *bool `json:"sendResolved,omitempty"`
|
||||
WebhookURL string `json:"webhookUrl" required:"true" format:"password"`
|
||||
Title valuer.UnsetOrNonEmptyString `json:"title,omitzero"`
|
||||
Text valuer.UnsetOrNonEmptyString `json:"text,omitzero"`
|
||||
}
|
||||
|
||||
func (c *ChannelMSTeamsConfig) UnmarshalJSON(data []byte) error {
|
||||
type alias ChannelMSTeamsConfig
|
||||
if err := decodeStrict(data, (*alias)(c)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fillSendResolved(&c.SendResolved, config.DefaultMSTeamsV2Config.VSendResolved)
|
||||
c.Title.SetIfUnset(config.DefaultMSTeamsV2Config.Title)
|
||||
c.Text.SetIfUnset(config.DefaultMSTeamsV2Config.Text)
|
||||
|
||||
return c.Validate()
|
||||
Title valuer.UnsetOrNonEmptyString `json:"title"`
|
||||
Text valuer.UnsetOrNonEmptyString `json:"text"`
|
||||
}
|
||||
|
||||
func (c ChannelMSTeamsConfig) Validate() error {
|
||||
|
||||
@@ -10,27 +10,13 @@ type ChannelOpsgenieConfig struct {
|
||||
SendResolved *bool `json:"sendResolved,omitempty"`
|
||||
APIKey string `json:"apiKey" required:"true" format:"password"`
|
||||
APIURL string `json:"apiUrl"`
|
||||
Message valuer.UnsetOrNonEmptyString `json:"message,omitzero"`
|
||||
Description valuer.UnsetOrNonEmptyString `json:"description,omitzero"`
|
||||
Source valuer.UnsetOrNonEmptyString `json:"source,omitzero"`
|
||||
Details map[string]string `json:"details,omitzero"`
|
||||
Message valuer.UnsetOrNonEmptyString `json:"message"`
|
||||
Description valuer.UnsetOrNonEmptyString `json:"description"`
|
||||
Source valuer.UnsetOrNonEmptyString `json:"source"`
|
||||
Details map[string]string `json:"details,omitempty"`
|
||||
Priority string `json:"priority"`
|
||||
}
|
||||
|
||||
func (c *ChannelOpsgenieConfig) UnmarshalJSON(data []byte) error {
|
||||
type alias ChannelOpsgenieConfig
|
||||
if err := decodeStrict(data, (*alias)(c)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fillSendResolved(&c.SendResolved, config.DefaultOpsGenieConfig.VSendResolved)
|
||||
c.Message.SetIfUnset(config.DefaultOpsGenieConfig.Message)
|
||||
c.Description.SetIfUnset(config.DefaultOpsGenieConfig.Description)
|
||||
c.Source.SetIfUnset(config.DefaultOpsGenieConfig.Source)
|
||||
|
||||
return c.Validate()
|
||||
}
|
||||
|
||||
func (c ChannelOpsgenieConfig) Validate() error {
|
||||
if c.APIKey == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.apiKey is required for an opsgenie channel")
|
||||
|
||||
@@ -10,39 +10,15 @@ type ChannelPagerdutyConfig struct {
|
||||
SendResolved *bool `json:"sendResolved,omitempty"`
|
||||
RoutingKey string `json:"routingKey" required:"true" format:"password"`
|
||||
URL string `json:"url"`
|
||||
Source valuer.UnsetOrNonEmptyString `json:"source,omitzero"`
|
||||
Client valuer.UnsetOrNonEmptyString `json:"client,omitzero"`
|
||||
ClientURL valuer.UnsetOrNonEmptyString `json:"clientUrl,omitzero"`
|
||||
Description valuer.UnsetOrNonEmptyString `json:"description,omitzero"`
|
||||
Source valuer.UnsetOrNonEmptyString `json:"source"`
|
||||
Client valuer.UnsetOrNonEmptyString `json:"client"`
|
||||
ClientURL valuer.UnsetOrNonEmptyString `json:"clientUrl"`
|
||||
Description valuer.UnsetOrNonEmptyString `json:"description"`
|
||||
Severity string `json:"severity"`
|
||||
Component string `json:"component"`
|
||||
Group string `json:"group"`
|
||||
Class string `json:"class"`
|
||||
Details map[string]string `json:"details,omitzero"`
|
||||
}
|
||||
|
||||
func (c *ChannelPagerdutyConfig) UnmarshalJSON(data []byte) error {
|
||||
type alias ChannelPagerdutyConfig
|
||||
if err := decodeStrict(data, (*alias)(c)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fillSendResolved(&c.SendResolved, config.DefaultPagerdutyConfig.VSendResolved)
|
||||
c.Description.SetIfUnset(config.DefaultPagerdutyConfig.Description)
|
||||
c.Client.SetIfUnset(config.DefaultPagerdutyConfig.Client)
|
||||
c.ClientURL.SetIfUnset(config.DefaultPagerdutyConfig.ClientURL)
|
||||
c.Source.SetIfUnset(c.Client.StringValue())
|
||||
|
||||
if c.Details == nil {
|
||||
c.Details = make(map[string]string, len(config.DefaultPagerdutyDetails))
|
||||
for key, value := range config.DefaultPagerdutyDetails {
|
||||
if template, ok := value.(string); ok {
|
||||
c.Details[key] = template
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return c.Validate()
|
||||
Details map[string]string `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
func (c ChannelPagerdutyConfig) Validate() error {
|
||||
@@ -114,32 +90,3 @@ func newChannelPagerdutyConfigFromReceiver(name string, receiver *Receiver) (Cha
|
||||
Details: details,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newUpstreamDetails(details map[string]string) map[string]any {
|
||||
if details == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
upstream := make(map[string]any, len(details))
|
||||
for key, value := range details {
|
||||
upstream[key] = value
|
||||
}
|
||||
|
||||
return upstream
|
||||
}
|
||||
|
||||
func extractStringDetails(name string, details map[string]any) (map[string]string, error) {
|
||||
extracted := make(map[string]string, len(details))
|
||||
for key, value := range details {
|
||||
stringValue, ok := value.(string)
|
||||
if !ok {
|
||||
return nil, errors.NewInvalidInputf(
|
||||
ErrCodeAlertmanagerChannelInvalid,
|
||||
"channel %q sets a non-string value for details.%s, which is not supported", name, key,
|
||||
)
|
||||
}
|
||||
extracted[key] = stringValue
|
||||
}
|
||||
|
||||
return extracted, nil
|
||||
}
|
||||
|
||||
@@ -10,15 +10,15 @@ type ChannelSlackConfig struct {
|
||||
SendResolved *bool `json:"sendResolved,omitempty"`
|
||||
APIURL string `json:"apiUrl" required:"true" format:"password"`
|
||||
Channel string `json:"channel"`
|
||||
Title valuer.UnsetOrNonEmptyString `json:"title,omitzero"`
|
||||
Text valuer.UnsetOrNonEmptyString `json:"text,omitzero"`
|
||||
Color valuer.UnsetOrNonEmptyString `json:"color,omitzero"`
|
||||
TitleLink valuer.UnsetOrNonEmptyString `json:"titleLink,omitzero"`
|
||||
Pretext valuer.UnsetOrNonEmptyString `json:"pretext,omitzero"`
|
||||
Fallback valuer.UnsetOrNonEmptyString `json:"fallback,omitzero"`
|
||||
Footer valuer.UnsetOrNonEmptyString `json:"footer,omitzero"`
|
||||
Fields []ChannelSlackField `json:"fields,omitzero"`
|
||||
Actions []ChannelSlackAction `json:"actions,omitzero"`
|
||||
Title valuer.UnsetOrNonEmptyString `json:"title"`
|
||||
Text valuer.UnsetOrNonEmptyString `json:"text"`
|
||||
Color valuer.UnsetOrNonEmptyString `json:"color"`
|
||||
TitleLink valuer.UnsetOrNonEmptyString `json:"titleLink"`
|
||||
Pretext valuer.UnsetOrNonEmptyString `json:"pretext"`
|
||||
Fallback valuer.UnsetOrNonEmptyString `json:"fallback"`
|
||||
Footer valuer.UnsetOrNonEmptyString `json:"footer"`
|
||||
Fields []ChannelSlackField `json:"fields,omitempty"`
|
||||
Actions []ChannelSlackAction `json:"actions,omitempty"`
|
||||
}
|
||||
|
||||
type ChannelSlackField struct {
|
||||
@@ -46,24 +46,6 @@ type ChannelSlackConfirmation struct {
|
||||
DismissText string `json:"dismissText"`
|
||||
}
|
||||
|
||||
func (c *ChannelSlackConfig) UnmarshalJSON(data []byte) error {
|
||||
type alias ChannelSlackConfig
|
||||
if err := decodeStrict(data, (*alias)(c)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fillSendResolved(&c.SendResolved, config.DefaultSlackConfig.VSendResolved)
|
||||
c.Title.SetIfUnset(config.DefaultSlackConfig.Title)
|
||||
c.Text.SetIfUnset(config.DefaultSlackConfig.Text)
|
||||
c.Color.SetIfUnset(config.DefaultSlackConfig.Color)
|
||||
c.TitleLink.SetIfUnset(config.DefaultSlackConfig.TitleLink)
|
||||
c.Pretext.SetIfUnset(config.DefaultSlackConfig.Pretext)
|
||||
c.Fallback.SetIfUnset(config.DefaultSlackConfig.Fallback)
|
||||
c.Footer.SetIfUnset(config.DefaultSlackConfig.Footer)
|
||||
|
||||
return c.Validate()
|
||||
}
|
||||
|
||||
func (c ChannelSlackConfig) Validate() error {
|
||||
if c.APIURL == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.apiUrl is required for a slack channel")
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
package alertmanagertypes
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
)
|
||||
|
||||
// bearerAuthorizationType is the scheme SigNoz writes for token auth.
|
||||
const bearerAuthorizationType = "Bearer"
|
||||
|
||||
// ChannelWebhookConfig splits apart the two authentication modes the legacy API
|
||||
// overloaded onto one password field, where an empty username meant the password
|
||||
// was really a bearer token. Username or Password may be set without the other,
|
||||
@@ -23,17 +18,6 @@ type ChannelWebhookConfig struct {
|
||||
BearerToken string `json:"bearerToken" format:"password"`
|
||||
}
|
||||
|
||||
func (c *ChannelWebhookConfig) UnmarshalJSON(data []byte) error {
|
||||
type alias ChannelWebhookConfig
|
||||
if err := decodeStrict(data, (*alias)(c)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fillSendResolved(&c.SendResolved, config.DefaultWebhookConfig.VSendResolved)
|
||||
|
||||
return c.Validate()
|
||||
}
|
||||
|
||||
func (c ChannelWebhookConfig) Validate() error {
|
||||
if c.URL == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.url is required for a webhook channel")
|
||||
@@ -112,16 +96,3 @@ func newChannelWebhookConfigFromReceiver(name string, receiver *Receiver) (Chann
|
||||
|
||||
return webhook, nil
|
||||
}
|
||||
|
||||
func rejectHTTPAuthorizationBeyondBearer(channelName string, httpConfig *commoncfg.HTTPClientConfig) error {
|
||||
if httpConfig == nil || httpConfig.Authorization == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
authorization := httpConfig.Authorization
|
||||
if !strings.EqualFold(authorization.Type, bearerAuthorizationType) || *authorization != (commoncfg.Authorization{Type: authorization.Type, Credentials: authorization.Credentials}) {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "channel %q sets http_config.authorization with fields other than a bearer token, which is not supported", channelName)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,50 +1,104 @@
|
||||
package alertmanagertypes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
"github.com/swaggest/jsonschema-go"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCodeChannelUnsupportedKind = errors.MustNewCode("channel_unsupported_kind")
|
||||
)
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// Kind
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// ChannelKind selects which ChannelSpec a channel carries and which notifier
|
||||
// integration is built for it.
|
||||
type ChannelKind struct {
|
||||
valuer.String
|
||||
}
|
||||
|
||||
var (
|
||||
ChannelKindSlack = ChannelKind{valuer.NewString("slack")}
|
||||
ChannelKindEmail = ChannelKind{valuer.NewString("email")}
|
||||
ChannelKindWebhook = ChannelKind{valuer.NewString("webhook")}
|
||||
ChannelKindPagerduty = ChannelKind{valuer.NewString("pagerduty")}
|
||||
ChannelKindOpsgenie = ChannelKind{valuer.NewString("opsgenie")}
|
||||
ChannelKindMSTeams = ChannelKind{valuer.NewString("msteams")}
|
||||
ChannelKindGoogleChat = ChannelKind{valuer.NewString("googlechat")}
|
||||
ChannelKindJira = ChannelKind{valuer.NewString("jira")}
|
||||
ChannelKindJSMOps = ChannelKind{valuer.NewString("jsmops")}
|
||||
ChannelKindIncidentIO = ChannelKind{valuer.NewString("incidentio")}
|
||||
)
|
||||
|
||||
func (ChannelKind) Enum() []any {
|
||||
kinds := make([]any, 0, len(channelKinds))
|
||||
for _, channelKind := range channelKinds {
|
||||
kinds = append(kinds, channelKind.kind)
|
||||
}
|
||||
return kinds
|
||||
}
|
||||
|
||||
func (t ChannelKind) IsValid() bool {
|
||||
return slices.ContainsFunc(t.Enum(), func(v any) bool { return v == t })
|
||||
}
|
||||
|
||||
// ToStoredType returns the Channel.Type a channel of this kind is stored under,
|
||||
// which matches the kind for all but msteams.
|
||||
func (t ChannelKind) ToStoredType() string {
|
||||
if t == ChannelKindMSTeams {
|
||||
return "msteamsv2"
|
||||
}
|
||||
|
||||
return t.StringValue()
|
||||
}
|
||||
|
||||
// parseStoredChannelType inverts ToStoredType. It reports false for the notifier
|
||||
// kinds v1 accepted but v2 does not model.
|
||||
func parseStoredChannelType(stored string) (ChannelKind, bool) {
|
||||
for _, channelKind := range channelKinds {
|
||||
if channelKind.kind.ToStoredType() == stored {
|
||||
return channelKind.kind, true
|
||||
}
|
||||
}
|
||||
|
||||
return ChannelKind{}, false
|
||||
}
|
||||
|
||||
func ErrUnsupportedChannelKind(s string) error {
|
||||
return errors.Newf(
|
||||
errors.TypeInvalidInput,
|
||||
ErrCodeChannelUnsupportedKind,
|
||||
"unknown notification channel kind %q; allowed values: %s",
|
||||
s, allowedValuesForChannelKind(),
|
||||
)
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// Union
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// ChannelConfig is the discriminated union of per-kind configurations. The
|
||||
// envelope sits on config rather than the resource root, so clients narrow on
|
||||
// config.kind instead of every request and response flavor becoming a oneOf.
|
||||
type ChannelConfig struct {
|
||||
Kind ChannelKind `json:"kind" required:"true"`
|
||||
Spec any `json:"spec" required:"true"`
|
||||
}
|
||||
|
||||
func (c *ChannelConfig) UnmarshalJSON(data []byte) error {
|
||||
var envelope struct {
|
||||
Kind ChannelKind `json:"kind"`
|
||||
|
||||
// json.RawMessage keeps spec bytes unparsed until Kind is known. Once
|
||||
// Kind is known, this Spec can be decoded into the correct type.
|
||||
Spec json.RawMessage `json:"spec"`
|
||||
}
|
||||
if err := decodeStrict(data, &envelope); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(envelope.Spec) == 0 {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec is required")
|
||||
}
|
||||
|
||||
spec, ok := buildEmptyChannelSpecForKind(envelope.Kind)
|
||||
if !ok {
|
||||
return ErrUnsupportedChannelKind(envelope.Kind.StringValue())
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(envelope.Spec, spec); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.Kind = envelope.Kind
|
||||
c.Spec = spec
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c ChannelConfig) Validate() error {
|
||||
expected, ok := buildEmptyChannelSpecForKind(c.Kind)
|
||||
newSpec, ok := newChannelSpec(c.Kind)
|
||||
if !ok {
|
||||
return ErrUnsupportedChannelKind(c.Kind.StringValue())
|
||||
}
|
||||
@@ -53,21 +107,44 @@ func (c ChannelConfig) Validate() error {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec is required")
|
||||
}
|
||||
|
||||
spec, err := c.toChannelSpec()
|
||||
if err != nil {
|
||||
return err
|
||||
spec, ok := c.Spec.(ChannelSpec)
|
||||
if !ok {
|
||||
return errors.NewInternalf(errors.CodeInternal, "config.spec was not decoded into a known type")
|
||||
}
|
||||
|
||||
// Only a config assembled in Go gets here, and one can pair a spec with the
|
||||
// wrong kind. The conversion to a receiver dispatches on the spec, so the
|
||||
// mismatch would silently outrank the declared kind.
|
||||
if reflect.TypeOf(spec) != reflect.TypeOf(expected) {
|
||||
// A decoded config cannot disagree, because UnmarshalJSON builds the spec
|
||||
// from the kind. A caller assembling the struct can, and the conversion to a
|
||||
// receiver dispatches on the spec, so a mismatch would silently outrank the
|
||||
// declared kind.
|
||||
if reflect.TypeOf(spec) != reflect.TypeOf(newSpec()) {
|
||||
return errors.NewInternalf(errors.CodeInternal, "config.spec does not match kind %q", c.Kind.StringValue())
|
||||
}
|
||||
|
||||
return spec.Validate()
|
||||
}
|
||||
|
||||
func (c *ChannelConfig) UnmarshalJSON(data []byte) error {
|
||||
channelKindString, specJSON, err := extractKindAndSpec(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
factory, ok := newChannelSpec(ChannelKind{valuer.NewString(channelKindString)})
|
||||
if !ok {
|
||||
return ErrUnsupportedChannelKind(channelKindString)
|
||||
}
|
||||
|
||||
spec, err := decodeChannelSpec(specJSON, factory(), channelKindString)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.Kind = ChannelKind{valuer.NewString(channelKindString)}
|
||||
c.Spec = *spec
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ChannelConfigVariant names one branch of the union. Each instantiation becomes
|
||||
// its own OpenAPI component with kind pinned to the one value it accepts.
|
||||
type ChannelConfigVariant[S any] struct {
|
||||
@@ -116,6 +193,311 @@ func (ChannelConfig) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
})
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// Specs
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
type ChannelSpec interface {
|
||||
Validate() error
|
||||
toUndefaultedReceiver(displayName string) (*Receiver, error)
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// Helpers
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// bearerAuthorizationType is the scheme SigNoz writes for token auth.
|
||||
const bearerAuthorizationType = "Bearer"
|
||||
|
||||
// parseSecretURL and parseUpstreamURL wrap the two URL types upstream uses for
|
||||
// notifier endpoints. Callers holding an optional URL skip the call on an empty
|
||||
// string, so the field stays nil and is omitted rather than stored as an empty URL.
|
||||
func parseSecretURL(raw string) (*config.SecretURL, error) {
|
||||
parsed, err := parseUpstreamURL(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return (*config.SecretURL)(parsed), nil
|
||||
}
|
||||
|
||||
func parseUpstreamURL(raw string) (*config.URL, error) {
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInvalidInputf(err, ErrCodeAlertmanagerChannelInvalid, "parse url %q", raw)
|
||||
}
|
||||
|
||||
return &config.URL{URL: parsed}, nil
|
||||
}
|
||||
|
||||
func formatSecretURL(secretURL *config.SecretURL) string {
|
||||
if secretURL == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return formatUpstreamURL((*config.URL)(secretURL))
|
||||
}
|
||||
|
||||
func formatUpstreamURL(upstreamURL *config.URL) string {
|
||||
if upstreamURL == nil || upstreamURL.URL == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return upstreamURL.String()
|
||||
}
|
||||
|
||||
// PagerDuty is the one notifier whose details upstream types as map[string]any.
|
||||
func newUpstreamDetails(details map[string]string) map[string]any {
|
||||
if details == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
upstream := make(map[string]any, len(details))
|
||||
for key, value := range details {
|
||||
upstream[key] = value
|
||||
}
|
||||
|
||||
return upstream
|
||||
}
|
||||
|
||||
func extractStringDetails(name string, details map[string]any) (map[string]string, error) {
|
||||
extracted := make(map[string]string, len(details))
|
||||
for key, value := range details {
|
||||
stringValue, ok := value.(string)
|
||||
if !ok {
|
||||
return nil, errors.NewInvalidInputf(
|
||||
ErrCodeAlertmanagerChannelInvalid,
|
||||
"channel %q sets a non-string value for details.%s, which is not supported", name, key,
|
||||
)
|
||||
}
|
||||
extracted[key] = stringValue
|
||||
}
|
||||
|
||||
return extracted, nil
|
||||
}
|
||||
|
||||
func rejectAnyHTTPAuth(channelName string, httpConfig *commoncfg.HTTPClientConfig) error {
|
||||
if httpConfig == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if httpConfig.BasicAuth != nil {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "channel %q sets http_config.basic_auth, which is not supported", channelName)
|
||||
}
|
||||
|
||||
if httpConfig.Authorization != nil {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "channel %q sets http_config.authorization, which is not supported", channelName)
|
||||
}
|
||||
|
||||
return rejectUnsupportedHTTPConfig(channelName, httpConfig)
|
||||
}
|
||||
|
||||
func rejectUnsupportedHTTPConfig(channelName string, httpConfig *commoncfg.HTTPClientConfig) error {
|
||||
if httpConfig == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, field := range []struct {
|
||||
fieldName string
|
||||
isFieldConfigured bool
|
||||
}{
|
||||
{"oauth2", httpConfig.OAuth2 != nil},
|
||||
{"bearer_token", httpConfig.BearerToken != ""},
|
||||
{"bearer_token_file", httpConfig.BearerTokenFile != ""},
|
||||
{"proxy_url", httpConfig.ProxyURL.URL != nil && httpConfig.ProxyURL.String() != ""},
|
||||
{"no_proxy", httpConfig.NoProxy != ""},
|
||||
{"proxy_from_environment", httpConfig.ProxyFromEnvironment},
|
||||
{"http_headers", httpConfig.HTTPHeaders != nil},
|
||||
{"tls_config", httpConfig.TLSConfig != (commoncfg.TLSConfig{})},
|
||||
{"follow_redirects", !httpConfig.FollowRedirects},
|
||||
{"enable_http2", !httpConfig.EnableHTTP2},
|
||||
} {
|
||||
if field.isFieldConfigured {
|
||||
return errors.NewInvalidInputf(
|
||||
ErrCodeAlertmanagerChannelInvalid,
|
||||
"channel %q sets http_config.%s, which is not supported", channelName, field.fieldName,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func rejectHTTPBasicAuthBeyondPassword(channelName string, httpConfig *commoncfg.HTTPClientConfig) error {
|
||||
if httpConfig == nil || httpConfig.BasicAuth == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
basicAuth := httpConfig.BasicAuth
|
||||
if *basicAuth != (commoncfg.BasicAuth{Username: basicAuth.Username, Password: basicAuth.Password}) {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "channel %q sets http_config.basic_auth with fields other than username and password, which is not supported", channelName)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func rejectHTTPAuthorizationBeyondBearer(channelName string, httpConfig *commoncfg.HTTPClientConfig) error {
|
||||
if httpConfig == nil || httpConfig.Authorization == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
authorization := httpConfig.Authorization
|
||||
if !strings.EqualFold(authorization.Type, bearerAuthorizationType) || *authorization != (commoncfg.Authorization{Type: authorization.Type, Credentials: authorization.Credentials}) {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "channel %q sets http_config.authorization with fields other than a bearer token, which is not supported", channelName)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// channelKinds registers each notification kind with the spec constructor
|
||||
// UnmarshalJSON picks by kind and the extractor that reads a stored receiver
|
||||
// back. The ChannelKind enum derives from it; the JSON schema hooks stay
|
||||
// literal lists so each branch reads as one line.
|
||||
var channelKinds = []channelKindEntry{
|
||||
{
|
||||
kind: ChannelKindSlack,
|
||||
newSpec: func() ChannelSpec { return new(ChannelSlackConfig) },
|
||||
countConfigs: func(receiver *Receiver) int { return len(receiver.SlackConfigs) },
|
||||
extractSpec: newChannelSlackConfigFromReceiver,
|
||||
},
|
||||
{
|
||||
kind: ChannelKindEmail,
|
||||
newSpec: func() ChannelSpec { return new(ChannelEmailConfig) },
|
||||
countConfigs: func(receiver *Receiver) int { return len(receiver.EmailConfigs) },
|
||||
extractSpec: newChannelEmailConfigFromReceiver,
|
||||
},
|
||||
{
|
||||
kind: ChannelKindWebhook,
|
||||
newSpec: func() ChannelSpec { return new(ChannelWebhookConfig) },
|
||||
countConfigs: func(receiver *Receiver) int { return len(receiver.WebhookConfigs) },
|
||||
extractSpec: newChannelWebhookConfigFromReceiver,
|
||||
},
|
||||
{
|
||||
kind: ChannelKindPagerduty,
|
||||
newSpec: func() ChannelSpec { return new(ChannelPagerdutyConfig) },
|
||||
countConfigs: func(receiver *Receiver) int { return len(receiver.PagerdutyConfigs) },
|
||||
extractSpec: newChannelPagerdutyConfigFromReceiver,
|
||||
},
|
||||
{
|
||||
kind: ChannelKindOpsgenie,
|
||||
newSpec: func() ChannelSpec { return new(ChannelOpsgenieConfig) },
|
||||
countConfigs: func(receiver *Receiver) int { return len(receiver.OpsGenieConfigs) },
|
||||
extractSpec: newChannelOpsgenieConfigFromReceiver,
|
||||
},
|
||||
{
|
||||
kind: ChannelKindMSTeams,
|
||||
newSpec: func() ChannelSpec { return new(ChannelMSTeamsConfig) },
|
||||
countConfigs: func(receiver *Receiver) int { return len(receiver.MSTeamsV2Configs) },
|
||||
extractSpec: newChannelMSTeamsConfigFromReceiver,
|
||||
},
|
||||
{
|
||||
kind: ChannelKindGoogleChat,
|
||||
newSpec: func() ChannelSpec { return new(ChannelGoogleChatConfig) },
|
||||
countConfigs: func(receiver *Receiver) int { return len(receiver.GoogleChatConfigs) },
|
||||
extractSpec: newChannelGoogleChatConfigFromReceiver,
|
||||
},
|
||||
{
|
||||
kind: ChannelKindJira,
|
||||
newSpec: func() ChannelSpec { return new(ChannelJiraConfig) },
|
||||
countConfigs: func(receiver *Receiver) int { return len(receiver.JiraConfigs) },
|
||||
extractSpec: newChannelJiraConfigFromReceiver,
|
||||
},
|
||||
{
|
||||
kind: ChannelKindJSMOps,
|
||||
newSpec: func() ChannelSpec { return new(ChannelJSMOpsConfig) },
|
||||
countConfigs: func(receiver *Receiver) int { return len(receiver.JSMOpsConfigs) },
|
||||
extractSpec: newChannelJSMOpsConfigFromReceiver,
|
||||
},
|
||||
{
|
||||
kind: ChannelKindIncidentIO,
|
||||
newSpec: func() ChannelSpec { return new(ChannelIncidentIOConfig) },
|
||||
countConfigs: func(receiver *Receiver) int { return len(receiver.IncidentIOConfigs) },
|
||||
extractSpec: newChannelIncidentIOConfigFromReceiver,
|
||||
},
|
||||
}
|
||||
|
||||
type channelKindEntry struct {
|
||||
kind ChannelKind
|
||||
newSpec func() ChannelSpec
|
||||
// countConfigs guards extractSpec, which reads the receiver's first config of
|
||||
// this kind and so must not be called when there is none.
|
||||
countConfigs func(receiver *Receiver) int
|
||||
extractSpec func(name string, receiver *Receiver) (ChannelSpec, error)
|
||||
}
|
||||
|
||||
func newChannelSpec(kind ChannelKind) (func() ChannelSpec, bool) {
|
||||
for _, channelKind := range channelKinds {
|
||||
if channelKind.kind == kind {
|
||||
return channelKind.newSpec, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// resolveSendResolved falls back to the notifier's own upstream default, because
|
||||
// send_resolved has no omitempty: a zero value would marshal as an explicit false
|
||||
// and overwrite the default rather than leave it in place.
|
||||
func resolveSendResolved(sendResolved *bool, upstreamDefault bool) bool {
|
||||
if sendResolved == nil {
|
||||
return upstreamDefault
|
||||
}
|
||||
|
||||
return *sendResolved
|
||||
}
|
||||
|
||||
func allowedValuesForChannelKind() string {
|
||||
return formatAllowedValues((ChannelKind{}).Enum())
|
||||
}
|
||||
|
||||
func formatAllowedValues(enum []any) string {
|
||||
values := make([]string, 0, len(enum))
|
||||
for _, value := range enum {
|
||||
stringValuer, ok := value.(interface{ StringValue() string })
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
values = append(values, "`"+stringValuer.StringValue()+"`")
|
||||
}
|
||||
slices.Sort(values)
|
||||
|
||||
return strings.Join(values, ", ")
|
||||
}
|
||||
|
||||
// extractKindAndSpec parses a {"kind": "...", "spec": {...}} envelope. Unknown
|
||||
// keys are rejected here rather than by the caller's decoder: a custom
|
||||
// UnmarshalJSON receives raw bytes, so DisallowUnknownFields on the request body
|
||||
// does not reach inside config.
|
||||
func extractKindAndSpec(data []byte) (string, []byte, error) {
|
||||
var head struct {
|
||||
Kind string `json:"kind"`
|
||||
Spec json.RawMessage `json:"spec"`
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(data))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&head); err != nil {
|
||||
return "", nil, errors.WrapInvalidInputf(err, ErrCodeAlertmanagerChannelInvalid, "invalid channel config envelope")
|
||||
}
|
||||
return head.Kind, head.Spec, nil
|
||||
}
|
||||
|
||||
// decodeChannelSpec rejects unknown fields so a spec meant for another kind is an
|
||||
// error rather than a silently empty struct, and validates before returning.
|
||||
func decodeChannelSpec[T ChannelSpec](specJSON []byte, target T, channelType string) (*T, error) {
|
||||
if len(specJSON) == 0 {
|
||||
return nil, errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "type %q: spec is required", channelType)
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(specJSON))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(target); err != nil {
|
||||
return nil, errors.WrapInvalidInputf(err, ErrCodeAlertmanagerChannelInvalid, "type %q: invalid spec JSON", channelType)
|
||||
}
|
||||
if err := target.Validate(); err != nil {
|
||||
return nil, errors.WrapInvalidInputf(err, ErrCodeAlertmanagerChannelInvalid, "type %q: %s", channelType, err.Error())
|
||||
}
|
||||
return &target, nil
|
||||
}
|
||||
|
||||
// signozDiscriminatorKey is the extension key that signoz.attachDiscriminators
|
||||
// promotes into a native OpenAPI 3 discriminator after reflection.
|
||||
const signozDiscriminatorKey = "x-signoz-discriminator"
|
||||
@@ -132,18 +514,6 @@ func channelVariantRef(spec string) string {
|
||||
return schemaRef("AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypes" + spec)
|
||||
}
|
||||
|
||||
// toChannelSpec asserts what UnmarshalJSON decoded. Spec is any rather than
|
||||
// ChannelSpec because the OpenAPI reflector turns an interface field into an
|
||||
// empty component.
|
||||
func (c ChannelConfig) toChannelSpec() (ChannelSpec, error) {
|
||||
spec, ok := c.Spec.(ChannelSpec)
|
||||
if !ok {
|
||||
return nil, errors.NewInternalf(errors.CodeInternal, "config.spec was not decoded into a known type")
|
||||
}
|
||||
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
// markDiscriminator tags a oneOf schema with x-signoz-discriminator, keyed on
|
||||
// propertyName with the given value -> schema-ref mapping, so generated clients
|
||||
// get a discriminated DTO instead of an intersection.
|
||||
|
||||
@@ -3,11 +3,8 @@ package alertmanagertypes
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
)
|
||||
|
||||
@@ -15,47 +12,13 @@ import (
|
||||
// API -> storage
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// ToChannel returns the receiver alongside because the alertmanager config is
|
||||
// updated from it, not from the channel.
|
||||
func (p *PostableNotificationChannel) ToChannel(orgID string) (*Channel, *Receiver, error) {
|
||||
receiver, err := p.ToReceiver()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
data, err := json.Marshal(receiver)
|
||||
if err != nil {
|
||||
return nil, nil, errors.WrapInternalf(err, errors.CodeInternal, "marshal receiver")
|
||||
}
|
||||
|
||||
spec, err := p.Config.toChannelSpec()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
channel := &Channel{
|
||||
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
|
||||
TimeAuditable: types.TimeAuditable{CreatedAt: time.Now(), UpdatedAt: time.Now()},
|
||||
Name: p.Name,
|
||||
DisplayName: p.DisplayName,
|
||||
Type: p.Config.Kind.StringValue(),
|
||||
Data: string(data),
|
||||
OrgID: orgID,
|
||||
}
|
||||
if err := channel.fillSpec(spec); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return channel, receiver, nil
|
||||
}
|
||||
|
||||
// ToReceiver hands the assembled receiver to newDefaultedReceiver, which is the
|
||||
// only place upstream applies a notifier's defaults and validation — several
|
||||
// integrations panic without them.
|
||||
func (p *PostableNotificationChannel) ToReceiver() (*Receiver, error) {
|
||||
spec, err := p.Config.toChannelSpec()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
spec, ok := p.Config.Spec.(ChannelSpec)
|
||||
if !ok {
|
||||
return nil, errors.NewInternalf(errors.CodeInternal, "config.spec was not decoded into a known type")
|
||||
}
|
||||
|
||||
receiver, err := spec.toUndefaultedReceiver(p.DisplayName)
|
||||
@@ -82,63 +45,22 @@ func (t *TestableNotificationChannel) ToReceiver() (*Receiver, error) {
|
||||
return postable.ToReceiver()
|
||||
}
|
||||
|
||||
func (c *Channel) UpdateFromUpdatable(updatable UpdatableNotificationChannel) (*Receiver, error) {
|
||||
receiver, err := updatable.ToReceiver(c.DisplayName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data, err := json.Marshal(receiver)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "marshal receiver")
|
||||
}
|
||||
|
||||
spec, err := updatable.Config.toChannelSpec()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := c.fillSpec(spec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.Type = updatable.Config.Kind.StringValue()
|
||||
c.Data = string(data)
|
||||
c.UpdatedAt = time.Now()
|
||||
|
||||
return receiver, nil
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// Storage -> API
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// toChannelConfig pairs the scanned spec with the kind Type names. Only a row
|
||||
// the migration could not backfill has none, so deriving it here reports why.
|
||||
func (c *Channel) toChannelConfig() (ChannelConfig, error) {
|
||||
if c.Spec == nil {
|
||||
return c.deriveChannelConfig()
|
||||
}
|
||||
|
||||
channelKind, ok := parseChannelKind(c.Type)
|
||||
if !ok {
|
||||
return ChannelConfig{}, errors.NewInternalf(errors.CodeInternal, "channel %q carries a spec under unmodelled type %q", c.DisplayName, c.Type)
|
||||
}
|
||||
|
||||
return ChannelConfig{Kind: channelKind, Spec: c.Spec}, nil
|
||||
}
|
||||
|
||||
// deriveChannelConfig derives the kind from the config the receiver actually
|
||||
// carries rather than from Channel.Type, so a row written with several notifier
|
||||
// kinds is rejected instead of reported under whichever one receiverChannelType
|
||||
// happened to pick.
|
||||
func (c *Channel) deriveChannelConfig() (ChannelConfig, error) {
|
||||
// toPostableNotificationChannel derives the kind from the config the receiver
|
||||
// actually carries rather than from Channel.Type, so a row written with several
|
||||
// notifier kinds is rejected instead of reported under whichever one
|
||||
// receiverChannelType happened to pick.
|
||||
func (c *Channel) toPostableNotificationChannel() (*PostableNotificationChannel, error) {
|
||||
receiver := &Receiver{Receiver: &config.Receiver{}}
|
||||
if err := json.Unmarshal([]byte(c.Data), receiver); err != nil {
|
||||
return ChannelConfig{}, errors.WrapInternalf(err, errors.CodeInternal, "unmarshal channel %q", c.DisplayName)
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "unmarshal channel %q", c.DisplayName)
|
||||
}
|
||||
|
||||
if total := countNotifierConfigs(receiver); total > 1 {
|
||||
return ChannelConfig{}, errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "channel %q carries %d notifier configurations; only one per channel is supported", c.DisplayName, total)
|
||||
return nil, errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "channel %q carries %d notifier configurations; only one per channel is supported", c.DisplayName, total)
|
||||
}
|
||||
|
||||
for _, channelKind := range channelKinds {
|
||||
@@ -148,21 +70,17 @@ func (c *Channel) deriveChannelConfig() (ChannelConfig, error) {
|
||||
|
||||
spec, err := channelKind.extractSpec(c.DisplayName, receiver)
|
||||
if err != nil {
|
||||
return ChannelConfig{}, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// The derived config is stored and decoded back through the same
|
||||
// validation a request goes through, so one that would not decode is
|
||||
// unrepresentable rather than stored.
|
||||
channelConfig := ChannelConfig{Kind: channelKind.kind, Spec: spec}
|
||||
if err := channelConfig.Validate(); err != nil {
|
||||
return ChannelConfig{}, errors.WrapInvalidInputf(err, ErrCodeAlertmanagerChannelInvalid, "channel %q: %s", c.DisplayName, err.Error())
|
||||
}
|
||||
|
||||
return channelConfig, nil
|
||||
return &PostableNotificationChannel{
|
||||
Name: c.Name,
|
||||
DisplayName: c.DisplayName,
|
||||
Config: ChannelConfig{Kind: channelKind.kind, Spec: spec},
|
||||
}, nil
|
||||
}
|
||||
|
||||
return ChannelConfig{}, errors.NewInvalidInputf(ErrCodeChannelUnsupportedKind, "channel %q carries no supported notifier configuration", c.DisplayName)
|
||||
return nil, errors.NewInvalidInputf(ErrCodeChannelUnsupportedKind, "channel %q carries no supported notifier configuration", c.DisplayName)
|
||||
}
|
||||
|
||||
// countNotifierConfigs totals every *_configs entry on the receiver, including
|
||||
@@ -193,15 +111,15 @@ func countConfigsFields(v reflect.Value) int {
|
||||
}
|
||||
|
||||
func (c *Channel) ToGettableNotificationChannel() (*GettableNotificationChannel, error) {
|
||||
channelConfig, err := c.toChannelConfig()
|
||||
postable, err := c.toPostableNotificationChannel()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &GettableNotificationChannel{
|
||||
Name: c.Name,
|
||||
DisplayName: c.DisplayName,
|
||||
Config: channelConfig,
|
||||
Name: postable.Name,
|
||||
DisplayName: postable.DisplayName,
|
||||
Config: postable.Config,
|
||||
ID: c.ID,
|
||||
CreatedAt: c.CreatedAt,
|
||||
UpdatedAt: c.UpdatedAt,
|
||||
@@ -212,7 +130,7 @@ func (c *Channel) ToGettableNotificationChannel() (*GettableNotificationChannel,
|
||||
// no ChannelKind models, which v1 allowed because it accepted every upstream
|
||||
// notifier kind. One such row must not fail the whole page.
|
||||
func (c *Channel) ToListedNotificationChannel() *ListedNotificationChannel {
|
||||
channelKind, _ := parseChannelKind(c.Type)
|
||||
channelKind, _ := parseStoredChannelType(c.Type)
|
||||
|
||||
return &ListedNotificationChannel{
|
||||
ID: c.ID,
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
// field fails rather than going unasserted. Webhook is covered by
|
||||
// TestPostableChannelToReceiverRoundTripsWebhookAuthModes, whose auth modes are
|
||||
// mutually exclusive and so cannot all be set at once.
|
||||
func TestDeriveChannelConfigRoundTripsEveryFieldOfEveryKind(t *testing.T) {
|
||||
func TestChannelToPostableChannelRoundTripsEveryFieldOfEveryKind(t *testing.T) {
|
||||
sendResolved := true
|
||||
short := true
|
||||
|
||||
@@ -269,14 +269,18 @@ func TestDeriveChannelConfigRoundTripsEveryFieldOfEveryKind(t *testing.T) {
|
||||
}
|
||||
require.NoError(t, postable.Validate())
|
||||
|
||||
channel, _, err := postable.ToChannel("org-1")
|
||||
receiver, err := postable.ToReceiver()
|
||||
require.NoError(t, err)
|
||||
|
||||
derived, err := channel.deriveChannelConfig()
|
||||
channel, err := NewChannelFromReceiverWithName(receiver, postable.Name, "org-1")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, testCase.kind, derived.Kind)
|
||||
assert.Equal(t, testCase.expectedRoundTrip, derived.Spec)
|
||||
roundTripped, err := channel.toPostableNotificationChannel()
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, postable.Name, roundTripped.Name)
|
||||
assert.Equal(t, testCase.kind, roundTripped.Config.Kind)
|
||||
assert.Equal(t, testCase.expectedRoundTrip, roundTripped.Config.Spec)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -294,7 +298,10 @@ func TestPostableChannelToReceiverOmitsEmailTransportCredentials(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
channel, _, err := postable.ToChannel("org-1")
|
||||
receiver, err := postable.ToReceiver()
|
||||
require.NoError(t, err)
|
||||
|
||||
channel, err := NewChannelFromReceiverWithName(receiver, postable.Name, "org-1")
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, credentialKey := range []string{"auth_username", "auth_password", "auth_secret", "tls_config"} {
|
||||
@@ -346,13 +353,16 @@ func TestPostableChannelToReceiverRoundTripsWebhookAuthModes(t *testing.T) {
|
||||
}
|
||||
require.NoError(t, postable.Validate())
|
||||
|
||||
channel, _, err := postable.ToChannel("org-1")
|
||||
receiver, err := postable.ToReceiver()
|
||||
require.NoError(t, err)
|
||||
|
||||
channel, err := NewChannelFromReceiverWithName(receiver, postable.Name, "org-1")
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, channel.Data, testCase.expectedInData)
|
||||
|
||||
derived, err := channel.deriveChannelConfig()
|
||||
roundTripped, err := channel.toPostableNotificationChannel()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testCase.expectedRoundTrip, derived.Spec)
|
||||
assert.Equal(t, testCase.expectedRoundTrip, roundTripped.Config.Spec)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -365,7 +375,7 @@ func TestRejectUnrepresentableHTTPConfigCoversEveryUpstreamMember(t *testing.T)
|
||||
assert.Equal(t, 5, reflect.TypeFor[commoncfg.ProxyConfig]().NumField())
|
||||
}
|
||||
|
||||
func TestDeriveChannelConfigRejectsUnrepresentableChannels(t *testing.T) {
|
||||
func TestChannelToPostableChannelRejectsUnrepresentableChannels(t *testing.T) {
|
||||
testCases := []struct {
|
||||
description string
|
||||
channel Channel
|
||||
@@ -527,7 +537,7 @@ func TestDeriveChannelConfigRejectsUnrepresentableChannels(t *testing.T) {
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.description, func(t *testing.T) {
|
||||
_, err := testCase.channel.deriveChannelConfig()
|
||||
_, err := testCase.channel.toPostableNotificationChannel()
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
@@ -535,7 +545,7 @@ func TestDeriveChannelConfigRejectsUnrepresentableChannels(t *testing.T) {
|
||||
|
||||
// The HTTP auth scheme is case-insensitive (RFC 7235) and Alertmanager sends
|
||||
// the stored spelling verbatim, so a hand-written receiver may carry any casing.
|
||||
func TestDeriveChannelConfigReadsWebhookBearerSchemeCaseInsensitively(t *testing.T) {
|
||||
func TestChannelToPostableChannelReadsWebhookBearerSchemeCaseInsensitively(t *testing.T) {
|
||||
sendResolved := config.DefaultWebhookConfig.VSendResolved
|
||||
|
||||
testCases := []struct {
|
||||
@@ -564,10 +574,10 @@ func TestDeriveChannelConfigReadsWebhookBearerSchemeCaseInsensitively(t *testing
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
channel := Channel{DisplayName: "hook", Data: testCase.storedChannelData}
|
||||
|
||||
derived, err := channel.deriveChannelConfig()
|
||||
postable, err := channel.toPostableNotificationChannel()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ChannelKindWebhook, derived.Kind)
|
||||
assert.Equal(t, testCase.expectedWebhookSpec, derived.Spec)
|
||||
assert.Equal(t, ChannelKindWebhook, postable.Config.Kind)
|
||||
assert.Equal(t, testCase.expectedWebhookSpec, postable.Config.Spec)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
package alertmanagertypes
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCodeChannelUnsupportedKind = errors.MustNewCode("channel_unsupported_kind")
|
||||
)
|
||||
|
||||
// ChannelKind selects which ChannelSpec a channel carries and which notifier
|
||||
// integration is built for it.
|
||||
type ChannelKind struct {
|
||||
valuer.String
|
||||
}
|
||||
|
||||
var (
|
||||
ChannelKindSlack = ChannelKind{valuer.NewString("slack")}
|
||||
ChannelKindEmail = ChannelKind{valuer.NewString("email")}
|
||||
ChannelKindWebhook = ChannelKind{valuer.NewString("webhook")}
|
||||
ChannelKindPagerduty = ChannelKind{valuer.NewString("pagerduty")}
|
||||
ChannelKindOpsgenie = ChannelKind{valuer.NewString("opsgenie")}
|
||||
ChannelKindMSTeams = ChannelKind{valuer.NewString("msteams")}
|
||||
ChannelKindGoogleChat = ChannelKind{valuer.NewString("googlechat")}
|
||||
ChannelKindJira = ChannelKind{valuer.NewString("jira")}
|
||||
ChannelKindJSMOps = ChannelKind{valuer.NewString("jsmops")}
|
||||
ChannelKindIncidentIO = ChannelKind{valuer.NewString("incidentio")}
|
||||
)
|
||||
|
||||
func (ChannelKind) Enum() []any {
|
||||
kinds := make([]any, 0, len(channelKinds))
|
||||
for _, channelKind := range channelKinds {
|
||||
kinds = append(kinds, channelKind.kind)
|
||||
}
|
||||
return kinds
|
||||
}
|
||||
|
||||
func (t ChannelKind) IsValid() bool {
|
||||
return slices.ContainsFunc(t.Enum(), func(v any) bool { return v == t })
|
||||
}
|
||||
|
||||
func ErrUnsupportedChannelKind(s string) error {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeChannelUnsupportedKind, "unknown notification channel kind %q; allowed values: %s", s, allowedValuesForChannelKind())
|
||||
}
|
||||
|
||||
// parseChannelKind reads a stored type. It reports false for the notifier
|
||||
// kinds v1 accepted but v2 does not model.
|
||||
func parseChannelKind(storedType string) (ChannelKind, bool) {
|
||||
channelKind := ChannelKind{valuer.NewString(storedType)}
|
||||
if !channelKind.IsValid() {
|
||||
return ChannelKind{}, false
|
||||
}
|
||||
|
||||
return channelKind, true
|
||||
}
|
||||
|
||||
func allowedValuesForChannelKind() string {
|
||||
return formatAllowedValues((ChannelKind{}).Enum())
|
||||
}
|
||||
|
||||
func formatAllowedValues(enum []any) string {
|
||||
values := make([]string, 0, len(enum))
|
||||
for _, value := range enum {
|
||||
stringValuer, ok := value.(interface{ StringValue() string })
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
values = append(values, "`"+stringValuer.StringValue()+"`")
|
||||
}
|
||||
slices.Sort(values)
|
||||
|
||||
return strings.Join(values, ", ")
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
package alertmanagertypes
|
||||
|
||||
type channelKindEntry struct {
|
||||
kind ChannelKind
|
||||
newEmptySpec func() ChannelSpec
|
||||
// countConfigs guards extractSpec, which reads the receiver's first config of
|
||||
// this kind and so must not be called when there is none.
|
||||
countConfigs func(receiver *Receiver) int
|
||||
extractSpec func(name string, receiver *Receiver) (ChannelSpec, error)
|
||||
}
|
||||
|
||||
// channelKinds registers each notification kind with the spec constructor
|
||||
// UnmarshalJSON picks by kind and the extractor that reads a stored receiver
|
||||
// back. The ChannelKind enum derives from it; the JSON schema hooks stay
|
||||
// literal lists so each branch reads as one line.
|
||||
var channelKinds = []channelKindEntry{
|
||||
{
|
||||
kind: ChannelKindSlack,
|
||||
newEmptySpec: func() ChannelSpec { return new(ChannelSlackConfig) },
|
||||
countConfigs: func(receiver *Receiver) int { return len(receiver.SlackConfigs) },
|
||||
extractSpec: newChannelSlackConfigFromReceiver,
|
||||
},
|
||||
{
|
||||
kind: ChannelKindEmail,
|
||||
newEmptySpec: func() ChannelSpec { return new(ChannelEmailConfig) },
|
||||
countConfigs: func(receiver *Receiver) int { return len(receiver.EmailConfigs) },
|
||||
extractSpec: newChannelEmailConfigFromReceiver,
|
||||
},
|
||||
{
|
||||
kind: ChannelKindWebhook,
|
||||
newEmptySpec: func() ChannelSpec { return new(ChannelWebhookConfig) },
|
||||
countConfigs: func(receiver *Receiver) int { return len(receiver.WebhookConfigs) },
|
||||
extractSpec: newChannelWebhookConfigFromReceiver,
|
||||
},
|
||||
{
|
||||
kind: ChannelKindPagerduty,
|
||||
newEmptySpec: func() ChannelSpec { return new(ChannelPagerdutyConfig) },
|
||||
countConfigs: func(receiver *Receiver) int { return len(receiver.PagerdutyConfigs) },
|
||||
extractSpec: newChannelPagerdutyConfigFromReceiver,
|
||||
},
|
||||
{
|
||||
kind: ChannelKindOpsgenie,
|
||||
newEmptySpec: func() ChannelSpec { return new(ChannelOpsgenieConfig) },
|
||||
countConfigs: func(receiver *Receiver) int { return len(receiver.OpsGenieConfigs) },
|
||||
extractSpec: newChannelOpsgenieConfigFromReceiver,
|
||||
},
|
||||
{
|
||||
kind: ChannelKindMSTeams,
|
||||
newEmptySpec: func() ChannelSpec { return new(ChannelMSTeamsConfig) },
|
||||
countConfigs: func(receiver *Receiver) int { return len(receiver.MSTeamsV2Configs) },
|
||||
extractSpec: newChannelMSTeamsConfigFromReceiver,
|
||||
},
|
||||
{
|
||||
kind: ChannelKindGoogleChat,
|
||||
newEmptySpec: func() ChannelSpec { return new(ChannelGoogleChatConfig) },
|
||||
countConfigs: func(receiver *Receiver) int { return len(receiver.GoogleChatConfigs) },
|
||||
extractSpec: newChannelGoogleChatConfigFromReceiver,
|
||||
},
|
||||
{
|
||||
kind: ChannelKindJira,
|
||||
newEmptySpec: func() ChannelSpec { return new(ChannelJiraConfig) },
|
||||
countConfigs: func(receiver *Receiver) int { return len(receiver.JiraConfigs) },
|
||||
extractSpec: newChannelJiraConfigFromReceiver,
|
||||
},
|
||||
{
|
||||
kind: ChannelKindJSMOps,
|
||||
newEmptySpec: func() ChannelSpec { return new(ChannelJSMOpsConfig) },
|
||||
countConfigs: func(receiver *Receiver) int { return len(receiver.JSMOpsConfigs) },
|
||||
extractSpec: newChannelJSMOpsConfigFromReceiver,
|
||||
},
|
||||
{
|
||||
kind: ChannelKindIncidentIO,
|
||||
newEmptySpec: func() ChannelSpec { return new(ChannelIncidentIOConfig) },
|
||||
countConfigs: func(receiver *Receiver) int { return len(receiver.IncidentIOConfigs) },
|
||||
extractSpec: newChannelIncidentIOConfigFromReceiver,
|
||||
},
|
||||
}
|
||||
|
||||
func buildEmptyChannelSpecForKind(kind ChannelKind) (ChannelSpec, bool) {
|
||||
for _, channelKind := range channelKinds {
|
||||
if channelKind.kind == kind {
|
||||
return channelKind.newEmptySpec(), true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
@@ -53,3 +53,23 @@ func TestChannelToListedChannelLeavesUnmodelledKindsEmpty(t *testing.T) {
|
||||
assert.Equal(t, "tg", listed.Name)
|
||||
assert.True(t, listed.Kind.IsZero())
|
||||
}
|
||||
|
||||
// msteams is the only kind whose stored Channel.Type differs from the api kind,
|
||||
// so ToStoredType has to agree with the type the write path derives.
|
||||
func TestChannelKindMSTeamsIsStoredAsMSTeamsV2(t *testing.T) {
|
||||
assert.Equal(t, "msteamsv2", ChannelKindMSTeams.ToStoredType())
|
||||
|
||||
postable := PostableNotificationChannel{
|
||||
Name: "channel",
|
||||
DisplayName: "channel",
|
||||
Config: ChannelConfig{Kind: ChannelKindMSTeams, Spec: &ChannelMSTeamsConfig{WebhookURL: "https://a"}},
|
||||
}
|
||||
|
||||
receiver, err := postable.ToReceiver()
|
||||
require.NoError(t, err)
|
||||
|
||||
channel, err := NewChannelFromReceiverWithName(receiver, postable.Name, "org-1")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "msteamsv2", channel.Type)
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ func (c *Channel) Diagnose() *ChannelRepair {
|
||||
return repair
|
||||
}
|
||||
|
||||
if _, err := c.toChannelConfig(); err != nil {
|
||||
if _, err := c.toPostableNotificationChannel(); err != nil {
|
||||
repair.Defect, repair.Detail = ChannelDefectUnrepresentable, err.Error()
|
||||
return repair
|
||||
}
|
||||
@@ -117,10 +117,7 @@ func (c *Channel) Retype() error {
|
||||
|
||||
// SplitByNotifier turns a receiver carrying several notifier configurations into
|
||||
// one channel per configuration. The first keeps this channel's identity so
|
||||
// references to it stay valid; the rest are new channels numbered after it. A
|
||||
// part of a kind v2 does not model is kept without a spec, as the migration
|
||||
// leaves such a row, so v1 still delivers through it until its own repair
|
||||
// deletes it.
|
||||
// references to it stay valid; the rest are new channels numbered after it.
|
||||
func (c *Channel) SplitByNotifier() ([]*Channel, error) {
|
||||
receiver, err := NewReceiver(c.Data)
|
||||
if err != nil {
|
||||
@@ -132,28 +129,21 @@ func (c *Channel) SplitByNotifier() ([]*Channel, error) {
|
||||
return nil, errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "channel %q carries %d notifier configuration; nothing to split", c.DisplayName, len(singles))
|
||||
}
|
||||
|
||||
parts := make([]*Channel, 0, len(singles))
|
||||
for i, single := range singles {
|
||||
if i > 0 {
|
||||
single.Name = fmt.Sprintf("%s (%d)", c.DisplayName, i+1)
|
||||
}
|
||||
if err := c.Update(singles[0]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var part *Channel
|
||||
if hasModelledNotifier(single) {
|
||||
part, err = NewChannelFromReceiver(single, c.OrgID)
|
||||
} else {
|
||||
part, err = newChannelWithoutSpec(single, c.OrgID)
|
||||
}
|
||||
channels := []*Channel{c}
|
||||
for i, single := range singles[1:] {
|
||||
single.Name = fmt.Sprintf("%s (%d)", c.DisplayName, i+2)
|
||||
channel, err := NewChannelFromReceiver(single, c.OrgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parts = append(parts, part)
|
||||
channels = append(channels, channel)
|
||||
}
|
||||
|
||||
c.Type, c.Data, c.Spec, c.StoredSpec = parts[0].Type, parts[0].Data, parts[0].Spec, parts[0].StoredSpec
|
||||
c.UpdatedAt = time.Now()
|
||||
|
||||
return append([]*Channel{c}, parts[1:]...), nil
|
||||
return channels, nil
|
||||
}
|
||||
|
||||
func hasModelledNotifier(receiver *Receiver) bool {
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
package alertmanagertypes
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
)
|
||||
|
||||
type ChannelSpec interface {
|
||||
Validate() error
|
||||
toUndefaultedReceiver(displayName string) (*Receiver, error)
|
||||
}
|
||||
|
||||
// parseSecretURL and parseUpstreamURL wrap the two URL types upstream uses for
|
||||
// notifier endpoints. Callers holding an optional URL skip the call on an empty
|
||||
// string, so the field stays nil and is omitted rather than stored as an empty URL.
|
||||
func parseSecretURL(raw string) (*config.SecretURL, error) {
|
||||
parsed, err := parseUpstreamURL(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return (*config.SecretURL)(parsed), nil
|
||||
}
|
||||
|
||||
func parseUpstreamURL(raw string) (*config.URL, error) {
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInvalidInputf(err, ErrCodeAlertmanagerChannelInvalid, "parse url %q", raw)
|
||||
}
|
||||
|
||||
return &config.URL{URL: parsed}, nil
|
||||
}
|
||||
|
||||
func formatSecretURL(secretURL *config.SecretURL) string {
|
||||
if secretURL == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return formatUpstreamURL((*config.URL)(secretURL))
|
||||
}
|
||||
|
||||
func formatUpstreamURL(upstreamURL *config.URL) string {
|
||||
if upstreamURL == nil || upstreamURL.URL == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return upstreamURL.String()
|
||||
}
|
||||
|
||||
func rejectAnyHTTPAuth(channelName string, httpConfig *commoncfg.HTTPClientConfig) error {
|
||||
if httpConfig == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if httpConfig.BasicAuth != nil {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "channel %q sets http_config.basic_auth, which is not supported", channelName)
|
||||
}
|
||||
|
||||
if httpConfig.Authorization != nil {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "channel %q sets http_config.authorization, which is not supported", channelName)
|
||||
}
|
||||
|
||||
return rejectUnsupportedHTTPConfig(channelName, httpConfig)
|
||||
}
|
||||
|
||||
func rejectUnsupportedHTTPConfig(channelName string, httpConfig *commoncfg.HTTPClientConfig) error {
|
||||
if httpConfig == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, field := range []struct {
|
||||
fieldName string
|
||||
isFieldConfigured bool
|
||||
}{
|
||||
{"oauth2", httpConfig.OAuth2 != nil},
|
||||
{"bearer_token", httpConfig.BearerToken != ""},
|
||||
{"bearer_token_file", httpConfig.BearerTokenFile != ""},
|
||||
{"proxy_url", httpConfig.ProxyURL.URL != nil && httpConfig.ProxyURL.String() != ""},
|
||||
{"no_proxy", httpConfig.NoProxy != ""},
|
||||
{"proxy_from_environment", httpConfig.ProxyFromEnvironment},
|
||||
{"http_headers", httpConfig.HTTPHeaders != nil},
|
||||
{"tls_config", httpConfig.TLSConfig != (commoncfg.TLSConfig{})},
|
||||
{"follow_redirects", !httpConfig.FollowRedirects},
|
||||
{"enable_http2", !httpConfig.EnableHTTP2},
|
||||
} {
|
||||
if field.isFieldConfigured {
|
||||
return errors.NewInvalidInputf(
|
||||
ErrCodeAlertmanagerChannelInvalid,
|
||||
"channel %q sets http_config.%s, which is not supported", channelName, field.fieldName,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func rejectHTTPBasicAuthBeyondPassword(channelName string, httpConfig *commoncfg.HTTPClientConfig) error {
|
||||
if httpConfig == nil || httpConfig.BasicAuth == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
basicAuth := httpConfig.BasicAuth
|
||||
if *basicAuth != (commoncfg.BasicAuth{Username: basicAuth.Username, Password: basicAuth.Password}) {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "channel %q sets http_config.basic_auth with fields other than username and password, which is not supported", channelName)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// fillSendResolved gives an omitted field its notifier default, so what is
|
||||
// stored and read back is what takes effect.
|
||||
func fillSendResolved(sendResolved **bool, upstreamDefault bool) {
|
||||
if *sendResolved == nil {
|
||||
value := upstreamDefault
|
||||
*sendResolved = &value
|
||||
}
|
||||
}
|
||||
|
||||
// resolveSendResolved covers a spec assembled in code rather than decoded, whose
|
||||
// defaults were never filled. send_resolved has no omitempty, so a zero value
|
||||
// would marshal as an explicit false and overwrite the default rather than leave it.
|
||||
func resolveSendResolved(sendResolved *bool, upstreamDefault bool) bool {
|
||||
if sendResolved == nil {
|
||||
return upstreamDefault
|
||||
}
|
||||
|
||||
return *sendResolved
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
package alertmanagertypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
"github.com/swaggest/jsonschema-go"
|
||||
)
|
||||
|
||||
type PostableChannel struct {
|
||||
Receiver
|
||||
}
|
||||
|
||||
func (PostableChannel) JSONSchema() (jsonschema.Schema, error) {
|
||||
type alias PostableChannel
|
||||
reflector := &jsonschema.Reflector{}
|
||||
|
||||
schema, err := reflector.Reflect(alias{}, jsonschema.DefinitionsPrefix("#/components/schemas/"))
|
||||
if err != nil {
|
||||
return jsonschema.Schema{}, err
|
||||
}
|
||||
|
||||
schema.WithRequired("name")
|
||||
|
||||
var oneOf []jsonschema.SchemaOrBool
|
||||
seen := map[string]struct{}{}
|
||||
// Walk both halves: native fields on Receiver, upstream on the embed. A native
|
||||
// field can shadow an upstream one with the same tag (e.g. jira_configs), so
|
||||
// dedupe to avoid emitting two identical oneOf branches.
|
||||
collect := func(t reflect.Type) {
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
jsonTag := strings.Split(t.Field(i).Tag.Get("json"), ",")[0]
|
||||
if !strings.HasSuffix(jsonTag, "_configs") {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[jsonTag]; ok {
|
||||
continue
|
||||
}
|
||||
seen[jsonTag] = struct{}{}
|
||||
branch := (&jsonschema.Schema{}).WithRequired(jsonTag)
|
||||
oneOf = append(oneOf, branch.ToSchemaOrBool())
|
||||
}
|
||||
}
|
||||
collect(reflect.TypeOf(Receiver{}))
|
||||
collect(reflect.TypeOf(config.Receiver{}))
|
||||
|
||||
schema.WithOneOf(oneOf...)
|
||||
|
||||
return schema, nil
|
||||
}
|
||||
|
||||
// NewChannelFromReceiver builds the channel a v1 write carries. The receiver is
|
||||
// all there is, so the name is generated from its display name and the type and
|
||||
// spec derived from it.
|
||||
func NewChannelFromReceiver(receiver *Receiver, orgID string) (*Channel, error) {
|
||||
channel, err := newChannelWithoutSpec(receiver, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// A receiver v2 cannot represent is refused rather than stored, so every
|
||||
// row written from here on reads through v2.
|
||||
channelConfig, err := channel.deriveChannelConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
spec, err := channelConfig.toChannelSpec()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := channel.fillSpec(spec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return channel, nil
|
||||
}
|
||||
|
||||
func (c *Channel) Update(receiver *Receiver) error {
|
||||
channel, err := NewChannelFromReceiver(receiver, c.OrgID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if c.DisplayName != channel.DisplayName {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeAlertmanagerChannelNameMismatch, "cannot update channel name")
|
||||
}
|
||||
|
||||
c.Type = channel.Type
|
||||
c.Data = channel.Data
|
||||
c.Spec, c.StoredSpec = channel.Spec, channel.StoredSpec
|
||||
c.UpdatedAt = time.Now()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ToV1Channel copies the channel with its type named as v1 always has, after
|
||||
// upstream's msteamsv2_configs list rather than the kind. Goes with the v1 API.
|
||||
func (c *Channel) ToV1Channel() *Channel {
|
||||
v1 := *c
|
||||
if c.Type == ChannelKindMSTeams.StringValue() {
|
||||
v1.Type = "msteamsv2"
|
||||
}
|
||||
|
||||
return &v1
|
||||
}
|
||||
|
||||
func newChannelWithoutSpec(receiver *Receiver, orgID string) (*Channel, error) {
|
||||
if receiver.Name == DefaultReceiverName {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeAlertmanagerChannelInvalid, "cannot use %s name as a channel name", receiver.Name)
|
||||
}
|
||||
|
||||
channelType := receiverChannelType(receiver)
|
||||
if channelType == "" {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeAlertmanagerChannelInvalid, "channel '%s' must have at least one notification configuration (e.g., email_configs, webhook_configs, slack_configs)", receiver.Name)
|
||||
}
|
||||
|
||||
data, err := json.Marshal(receiver)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "cannot save channel %q notification configuration", receiver.Name)
|
||||
}
|
||||
|
||||
return &Channel{
|
||||
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
|
||||
TimeAuditable: types.TimeAuditable{CreatedAt: time.Now(), UpdatedAt: time.Now()},
|
||||
Name: generateChannelName(receiver.Name),
|
||||
DisplayName: receiver.Name,
|
||||
Type: channelType,
|
||||
Data: string(data),
|
||||
OrgID: orgID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// receiverChannelType returns the type a channel is stored under: the kind for
|
||||
// a notifier v2 models, else the notifier's own *_configs name. For the latter
|
||||
// it walks Receiver's own fields first (native), then the embed (upstream);
|
||||
// first non-empty *_configs slice wins.
|
||||
func receiverChannelType(receiver *Receiver) string {
|
||||
for _, channelKind := range channelKinds {
|
||||
if channelKind.countConfigs(receiver) > 0 {
|
||||
return channelKind.kind.StringValue()
|
||||
}
|
||||
}
|
||||
if t := nonEmptyConfigsField(reflect.ValueOf(*receiver)); t != "" {
|
||||
return t
|
||||
}
|
||||
if t := nonEmptyConfigsField(reflect.ValueOf(*receiver.Receiver)); t != "" {
|
||||
return t
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func nonEmptyConfigsField(v reflect.Value) string {
|
||||
t := v.Type()
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
field := t.Field(i)
|
||||
fieldVal := v.Field(i)
|
||||
|
||||
if fieldVal.Kind() != reflect.Slice || fieldVal.Len() == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
yamlTag := field.Tag.Get("yaml")
|
||||
if yamlTag == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract the base type name (e.g., "email_configs" -> "email").
|
||||
matches := receiverTypeRegex.FindStringSubmatch(yamlTag)
|
||||
if len(matches) != 2 {
|
||||
continue
|
||||
}
|
||||
return matches[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package cloudintegrationtypes
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
@@ -27,17 +26,6 @@ type Account struct {
|
||||
type AgentReport struct {
|
||||
TimestampMillis int64 `json:"timestampMillis" required:"true"`
|
||||
Data map[string]any `json:"data" required:"true" nullable:"true"`
|
||||
SyncState *SyncState `json:"syncState" required:"true" nullable:"true"`
|
||||
}
|
||||
|
||||
type SyncState struct {
|
||||
Version int64 `json:"version" required:"true"`
|
||||
InSync bool `json:"inSync" required:"true"`
|
||||
Regions map[string]*RegionSyncState `json:"regions" required:"true" nullable:"false"`
|
||||
}
|
||||
|
||||
type RegionSyncState struct {
|
||||
State RegionState `json:"state" required:"true"`
|
||||
}
|
||||
|
||||
type AccountConfig struct {
|
||||
@@ -162,7 +150,6 @@ func NewAccountFromStorable(storableAccount *StorableCloudIntegration) (*Account
|
||||
account.AgentReport = &AgentReport{
|
||||
TimestampMillis: storableAccount.LastAgentReport.TimestampMillis,
|
||||
Data: storableAccount.LastAgentReport.Data,
|
||||
SyncState: NewSyncStateFromStorable(storableAccount.LastAgentReport.SyncState),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,28 +308,10 @@ func NewAccountConfigFromUpdatable(provider CloudProviderType, config *Updatable
|
||||
}
|
||||
}
|
||||
|
||||
func NewAgentReport(data map[string]any, syncState *SyncState) *AgentReport {
|
||||
func NewAgentReport(data map[string]any) *AgentReport {
|
||||
return &AgentReport{
|
||||
TimestampMillis: time.Now().UnixMilli(),
|
||||
Data: data,
|
||||
SyncState: syncState,
|
||||
}
|
||||
}
|
||||
|
||||
func NewSyncStateFromStorable(storableSyncState *StorableSyncState) *SyncState {
|
||||
if storableSyncState == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
regions := make(map[string]*RegionSyncState, len(storableSyncState.Regions))
|
||||
for region, regionSyncState := range storableSyncState.Regions {
|
||||
regions[region] = &RegionSyncState{State: regionSyncState.State}
|
||||
}
|
||||
|
||||
return &SyncState{
|
||||
Version: storableSyncState.Version,
|
||||
InSync: storableSyncState.InSync,
|
||||
Regions: regions,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,40 +335,6 @@ func (account *Account) Update(provider CloudProviderType, config *AccountConfig
|
||||
return nil
|
||||
}
|
||||
|
||||
func (account *Account) UpdateAgentReport(providerAccountID *string, agentReport *AgentReport) {
|
||||
account.ProviderAccountID = providerAccountID
|
||||
account.AgentReport = agentReport
|
||||
}
|
||||
|
||||
// UpdateSyncState keeps the rest of the agent report, and is a no-op when the agent has never checked in.
|
||||
func (account *Account) UpdateSyncState(syncState *SyncState) {
|
||||
if account.AgentReport == nil {
|
||||
return
|
||||
}
|
||||
|
||||
account.AgentReport.SyncState = syncState
|
||||
}
|
||||
|
||||
// NextSyncState returns the sync state for this check-in, or nil for providers without one.
|
||||
func (account *Account) NextSyncState(syncedVersion *int64) *SyncState {
|
||||
if account.Provider != CloudProviderTypeAWS {
|
||||
return nil
|
||||
}
|
||||
|
||||
var previous *SyncState
|
||||
if account.AgentReport != nil {
|
||||
previous = account.AgentReport.SyncState
|
||||
}
|
||||
|
||||
regions := account.Config.AWS.Regions
|
||||
// Removed before the agent ever checked in: no region was sent to it, so there is nothing to clean up.
|
||||
if account.AgentReport == nil && account.RemovedAt != nil {
|
||||
regions = nil
|
||||
}
|
||||
|
||||
return newSyncState(previous, regions, account.RemovedAt != nil, syncedVersion)
|
||||
}
|
||||
|
||||
func (postableAccount *PostableAccount) UnmarshalJSON(data []byte) error {
|
||||
type Alias PostableAccount
|
||||
|
||||
@@ -471,79 +406,3 @@ func (config *AccountConfig) ToJSON() ([]byte, error) {
|
||||
func NewIngestionKeyName(provider CloudProviderType) string {
|
||||
return fmt.Sprintf("%s-integration", provider.StringValue())
|
||||
}
|
||||
|
||||
// newSyncState returns the sync state after a check-in without mutating previous.
|
||||
func newSyncState(previous *SyncState, regions []string, removed bool, syncedVersion *int64) *SyncState {
|
||||
if previous == nil {
|
||||
previous = newSyncStateFromRegions(regions)
|
||||
}
|
||||
|
||||
next := previous.copy()
|
||||
|
||||
// The agent synced this version, so its disabled regions are cleaned up and can be dropped.
|
||||
if syncedVersion != nil && *syncedVersion == next.Version {
|
||||
next.InSync = true
|
||||
maps.DeleteFunc(next.Regions, func(_ string, regionSyncState *RegionSyncState) bool {
|
||||
return regionSyncState.State == RegionStateDisabled
|
||||
})
|
||||
}
|
||||
|
||||
// Once the integration is removed, every region is disabled.
|
||||
if removed {
|
||||
regions = nil
|
||||
}
|
||||
|
||||
changed := false
|
||||
desiredRegionsMap := make(map[string]struct{}, len(regions))
|
||||
|
||||
for _, region := range regions {
|
||||
desiredRegionsMap[region] = struct{}{}
|
||||
|
||||
if regionSyncState, ok := next.Regions[region]; ok && regionSyncState.State == RegionStateEnabled {
|
||||
continue
|
||||
}
|
||||
|
||||
next.Regions[region] = &RegionSyncState{State: RegionStateEnabled}
|
||||
changed = true
|
||||
}
|
||||
|
||||
for region, regionSyncState := range next.Regions {
|
||||
_, ok := desiredRegionsMap[region]
|
||||
if ok && regionSyncState.State == RegionStateEnabled {
|
||||
continue
|
||||
}
|
||||
|
||||
if !ok && regionSyncState.State == RegionStateDisabled {
|
||||
continue
|
||||
}
|
||||
|
||||
regionSyncState.State = RegionStateDisabled
|
||||
changed = true
|
||||
}
|
||||
|
||||
if changed {
|
||||
next.Version++
|
||||
next.InSync = false
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
// newSyncStateFromRegions is used on the first check-in, when the agent has already deployed regions, so it starts in sync.
|
||||
func newSyncStateFromRegions(regions []string) *SyncState {
|
||||
syncState := &SyncState{Version: 1, InSync: true, Regions: make(map[string]*RegionSyncState, len(regions))}
|
||||
for _, region := range regions {
|
||||
syncState.Regions[region] = &RegionSyncState{State: RegionStateEnabled}
|
||||
}
|
||||
|
||||
return syncState
|
||||
}
|
||||
|
||||
func (syncState *SyncState) copy() *SyncState {
|
||||
regions := make(map[string]*RegionSyncState, len(syncState.Regions))
|
||||
for region, regionSyncState := range syncState.Regions {
|
||||
regions[region] = &RegionSyncState{State: regionSyncState.State}
|
||||
}
|
||||
|
||||
return &SyncState{Version: syncState.Version, InSync: syncState.InSync, Regions: regions}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,7 @@ type AgentCheckInRequest struct {
|
||||
ProviderAccountID string `json:"providerAccountId" required:"false"`
|
||||
CloudIntegrationID valuer.UUID `json:"cloudIntegrationId" required:"false"`
|
||||
|
||||
Data map[string]any `json:"data" required:"true" nullable:"true"`
|
||||
SyncedVersion *int64 `json:"syncedVersion" required:"false" nullable:"true"`
|
||||
Data map[string]any `json:"data" required:"true" nullable:"true"`
|
||||
}
|
||||
|
||||
type PostableAgentCheckIn struct {
|
||||
@@ -29,7 +28,6 @@ type AgentCheckInResponse struct {
|
||||
ProviderAccountID string `json:"providerAccountId" required:"true"`
|
||||
IntegrationConfig *ProviderIntegrationConfig `json:"integrationConfig" required:"true"`
|
||||
RemovedAt *time.Time `json:"removedAt" required:"true" nullable:"true"`
|
||||
SyncState *SyncState `json:"syncState" required:"true" nullable:"true"`
|
||||
}
|
||||
|
||||
type GettableAgentCheckIn struct {
|
||||
@@ -75,13 +73,12 @@ func NewGettableAgentCheckIn(provider CloudProviderType, resp *AgentCheckInRespo
|
||||
return gettable
|
||||
}
|
||||
|
||||
func NewAgentCheckInResponse(providerAccountID, cloudIntegrationID string, integrationConfig *ProviderIntegrationConfig, removedAt *time.Time, syncState *SyncState) *AgentCheckInResponse {
|
||||
func NewAgentCheckInResponse(providerAccountID, cloudIntegrationID string, integrationConfig *ProviderIntegrationConfig, removedAt *time.Time) *AgentCheckInResponse {
|
||||
return &AgentCheckInResponse{
|
||||
CloudIntegrationID: cloudIntegrationID,
|
||||
ProviderAccountID: providerAccountID,
|
||||
IntegrationConfig: integrationConfig,
|
||||
RemovedAt: removedAt,
|
||||
SyncState: syncState,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,17 +25,6 @@ var (
|
||||
ErrCodeServiceDefinitionNotFound = errors.MustNewCode("service_definition_not_found")
|
||||
)
|
||||
|
||||
var (
|
||||
RegionStateEnabled = RegionState{valuer.NewString("enabled")}
|
||||
RegionStateDisabled = RegionState{valuer.NewString("disabled")}
|
||||
)
|
||||
|
||||
type RegionState struct{ valuer.String }
|
||||
|
||||
func (RegionState) Enum() []any {
|
||||
return []any{RegionStateEnabled, RegionStateDisabled}
|
||||
}
|
||||
|
||||
// StorableCloudIntegration represents a cloud integration stored in the database.
|
||||
// This is also referred as "Account" in the context of cloud integrations.
|
||||
type StorableCloudIntegration struct {
|
||||
@@ -54,16 +43,8 @@ type StorableCloudIntegration struct {
|
||||
// StorableAgentReport represents the last heartbeat and arbitrary data sent by the agent
|
||||
// as of now there is no use case for Data field, but keeping it for backwards compatibility with older structure.
|
||||
type StorableAgentReport struct {
|
||||
TimestampMillis int64 `json:"timestamp_millis"` // backward compatibility
|
||||
Data map[string]any `json:"data"`
|
||||
SyncState *StorableSyncState `json:"sync_state,omitempty"`
|
||||
}
|
||||
|
||||
// StorableSyncState holds every region sent to the agent. A disabled region is dropped only after the agent acks Version.
|
||||
type StorableSyncState struct {
|
||||
Version int64 `json:"version"`
|
||||
InSync bool `json:"in_sync"`
|
||||
Regions map[string]*RegionSyncState `json:"regions"`
|
||||
TimestampMillis int64 `json:"timestamp_millis"` // backward compatibility
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
|
||||
// StorableCloudIntegrationService is to store service config for a cloud integration, which is a cloud provider specific configuration.
|
||||
@@ -167,30 +148,12 @@ func NewStorableCloudIntegration(account *Account) (*StorableCloudIntegration, e
|
||||
storableAccount.LastAgentReport = &StorableAgentReport{
|
||||
TimestampMillis: account.AgentReport.TimestampMillis,
|
||||
Data: account.AgentReport.Data,
|
||||
SyncState: NewStorableSyncState(account.AgentReport.SyncState),
|
||||
}
|
||||
}
|
||||
|
||||
return storableAccount, nil
|
||||
}
|
||||
|
||||
func NewStorableSyncState(syncState *SyncState) *StorableSyncState {
|
||||
if syncState == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
regions := make(map[string]*RegionSyncState, len(syncState.Regions))
|
||||
for region, regionSyncState := range syncState.Regions {
|
||||
regions[region] = &RegionSyncState{State: regionSyncState.State}
|
||||
}
|
||||
|
||||
return &StorableSyncState{
|
||||
Version: syncState.Version,
|
||||
InSync: syncState.InSync,
|
||||
Regions: regions,
|
||||
}
|
||||
}
|
||||
|
||||
// NewStorableCloudIntegrationService creates a new StorableCloudIntegrationService with
|
||||
// generated ID and timestamps from a CloudIntegrationService and its serialized config JSON.
|
||||
func NewStorableCloudIntegrationService(svc *CloudIntegrationService, configJSON string) *StorableCloudIntegrationService {
|
||||
@@ -209,7 +172,6 @@ func (account *StorableCloudIntegration) Update(providerAccountID *string, agent
|
||||
account.LastAgentReport = &StorableAgentReport{
|
||||
TimestampMillis: agentReport.TimestampMillis,
|
||||
Data: agentReport.Data,
|
||||
SyncState: NewStorableSyncState(agentReport.SyncState),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,12 +25,9 @@ type Store interface {
|
||||
// CreateAccount creates a new cloud integration account
|
||||
CreateAccount(ctx context.Context, account *StorableCloudIntegration) error
|
||||
|
||||
// UpdateAccount updates the user updatable fields (config) of an existing cloud integration account
|
||||
// UpdateAccount updates an existing cloud integration account
|
||||
UpdateAccount(ctx context.Context, account *StorableCloudIntegration) error
|
||||
|
||||
// UpdateAgentReport updates the provider account id and last agent report of an existing cloud integration account
|
||||
UpdateAgentReport(ctx context.Context, account *StorableCloudIntegration) error
|
||||
|
||||
// RemoveAccount marks a cloud integration account as removed by setting the RemovedAt field
|
||||
RemoveAccount(ctx context.Context, orgID, id valuer.UUID, provider CloudProviderType) error
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package coretypes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
@@ -12,24 +14,70 @@ const (
|
||||
PhaseResponse
|
||||
)
|
||||
|
||||
var (
|
||||
errCodeExtractorContextNotFound = errors.MustNewCode("extractor_context_not_found")
|
||||
errCodeRequestTypeUndeclared = errors.MustNewCode("request_type_undeclared")
|
||||
errCodeRequestTypeMismatch = errors.MustNewCode("request_type_mismatch")
|
||||
)
|
||||
|
||||
type ExtractPhase int
|
||||
|
||||
type extractorContextKey struct{}
|
||||
|
||||
// ExtractorContext carries everything an extractor may read: Request + RequestBody
|
||||
// are filled pre-handler, ResponseBody post-handler.
|
||||
// are filled pre-handler, ResponseBody post-handler. RequestBody is the body
|
||||
// decoded by the resource middleware into the route's declared request type.
|
||||
type ExtractorContext struct {
|
||||
Request *http.Request
|
||||
RequestBody []byte
|
||||
RequestBody any
|
||||
ResponseBody []byte
|
||||
}
|
||||
|
||||
func NewContextWithExtractorContext(ctx context.Context, ec ExtractorContext) context.Context {
|
||||
return context.WithValue(ctx, extractorContextKey{}, ec)
|
||||
}
|
||||
|
||||
func ExtractorContextFromContext(ctx context.Context) (ExtractorContext, error) {
|
||||
ec, ok := ctx.Value(extractorContextKey{}).(ExtractorContext)
|
||||
if !ok {
|
||||
return ExtractorContext{}, errors.New(errors.TypeInternal, errCodeExtractorContextNotFound, "extractor context not found in context")
|
||||
}
|
||||
|
||||
return ec, nil
|
||||
}
|
||||
|
||||
func BodyAs[T any](ec ExtractorContext) (*T, error) {
|
||||
if ec.RequestBody == nil {
|
||||
return nil, errors.New(errors.TypeInternal, errCodeRequestTypeUndeclared, "route does not declare a request type")
|
||||
}
|
||||
|
||||
typed, ok := ec.RequestBody.(*T)
|
||||
if !ok {
|
||||
return nil, errors.Newf(errors.TypeInternal, errCodeRequestTypeMismatch, "route declares request type %T, expected %T", ec.RequestBody, (*T)(nil))
|
||||
}
|
||||
|
||||
return typed, nil
|
||||
}
|
||||
|
||||
func BodyFromContext[T any](ctx context.Context) (*T, error) {
|
||||
ec, err := ExtractorContextFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return BodyAs[T](ec)
|
||||
}
|
||||
|
||||
type ResourceIDExtractor struct {
|
||||
Phase ExtractPhase
|
||||
Fn func(ExtractorContext) (string, error)
|
||||
Phase ExtractPhase
|
||||
RequiresBody bool
|
||||
Fn func(ExtractorContext) (string, error)
|
||||
}
|
||||
|
||||
type ResourceIDsExtractor struct {
|
||||
Phase ExtractPhase
|
||||
Fn func(ExtractorContext) ([]string, error)
|
||||
Phase ExtractPhase
|
||||
RequiresBody bool
|
||||
Fn func(ExtractorContext) ([]string, error)
|
||||
}
|
||||
|
||||
func NewResourceIDExtractor(phase ExtractPhase, fn func(ExtractorContext) (string, error)) ResourceIDExtractor {
|
||||
@@ -50,7 +98,7 @@ func OneID(extractor ResourceIDExtractor) ResourceIDsExtractor {
|
||||
return ResourceIDsExtractor{}
|
||||
}
|
||||
|
||||
return ResourceIDsExtractor{Phase: extractor.Phase, Fn: func(ec ExtractorContext) ([]string, error) {
|
||||
return ResourceIDsExtractor{Phase: extractor.Phase, RequiresBody: extractor.RequiresBody, Fn: func(ec ExtractorContext) ([]string, error) {
|
||||
id, err := extractor.Fn(ec)
|
||||
if err != nil || id == "" {
|
||||
return nil, err
|
||||
@@ -75,26 +123,25 @@ func PathParam(name string) ResourceIDExtractor {
|
||||
}}
|
||||
}
|
||||
|
||||
func BodyJSONPath(path string) ResourceIDExtractor {
|
||||
return ResourceIDExtractor{Phase: PhaseRequest, Fn: func(ec ExtractorContext) (string, error) {
|
||||
return gjson.GetBytes(ec.RequestBody, path).String(), nil
|
||||
func BodyField[T any](pick func(*T) string) ResourceIDExtractor {
|
||||
return ResourceIDExtractor{Phase: PhaseRequest, RequiresBody: true, Fn: func(ec ExtractorContext) (string, error) {
|
||||
req, err := BodyAs[T](ec)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return pick(req), nil
|
||||
}}
|
||||
}
|
||||
|
||||
func BodyJSONArray(path string) ResourceIDsExtractor {
|
||||
return ResourceIDsExtractor{Phase: PhaseRequest, Fn: func(ec ExtractorContext) ([]string, error) {
|
||||
result := gjson.GetBytes(ec.RequestBody, path)
|
||||
if !result.Exists() {
|
||||
return nil, nil
|
||||
func BodyFields[T any](pick func(*T) []string) ResourceIDsExtractor {
|
||||
return ResourceIDsExtractor{Phase: PhaseRequest, RequiresBody: true, Fn: func(ec ExtractorContext) ([]string, error) {
|
||||
req, err := BodyAs[T](ec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
array := result.Array()
|
||||
ids := make([]string, 0, len(array))
|
||||
for _, r := range array {
|
||||
ids = append(ids, r.String())
|
||||
}
|
||||
|
||||
return ids, nil
|
||||
return pick(req), nil
|
||||
}}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,6 @@ type SpanMapperStore interface {
|
||||
// TraceStore defines the data access interface for trace detail queries.
|
||||
type TraceStore interface {
|
||||
GetTraceSummary(ctx context.Context, traceID string) (*TraceSummary, error)
|
||||
GetTraceStats(ctx context.Context, orgID valuer.UUID, traceID string, summary *TraceSummary) (*TraceStats, error)
|
||||
GetTraceSpans(ctx context.Context, traceID string, summary *TraceSummary) ([]StorableSpan, error)
|
||||
GetMinimalSpans(ctx context.Context, traceID string, start, end time.Time) ([]MinimalSpan, error)
|
||||
GetTraceSpansByIDs(ctx context.Context, traceID string, start, end time.Time, spanIDs []string) ([]StorableSpan, error)
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
package spantypes
|
||||
|
||||
// TraceStats is the single-row result of the trace summary aggregate query.
|
||||
type TraceStats struct {
|
||||
StartNs uint64
|
||||
EndNs uint64
|
||||
RootServiceName string
|
||||
RootEntryPoint string
|
||||
RootSpanStatusCode string
|
||||
TotalSpans uint64
|
||||
TotalErrorSpans uint64
|
||||
HasMissingSpans bool
|
||||
GenAISpanCount uint64
|
||||
Tokens TraceAITokens
|
||||
TotalCost *float64
|
||||
}
|
||||
|
||||
// GettableTraceSummary is the response for the trace summary API; the trace-level
|
||||
// fields match the waterfall response.
|
||||
type GettableTraceSummary struct {
|
||||
StartTimestampMillis uint64 `json:"startTimestampMillis"`
|
||||
EndTimestampMillis uint64 `json:"endTimestampMillis"`
|
||||
RootServiceName string `json:"rootServiceName"`
|
||||
RootServiceEntryPoint string `json:"rootServiceEntryPoint"`
|
||||
RootSpanStatusCode string `json:"rootSpanStatusCode"`
|
||||
TotalSpansCount uint64 `json:"totalSpansCount"`
|
||||
TotalErrorSpansCount uint64 `json:"totalErrorSpansCount"`
|
||||
HasMissingSpans bool `json:"hasMissingSpans"`
|
||||
AI *TraceAISummary `json:"ai,omitempty"`
|
||||
}
|
||||
|
||||
// TraceAISummary is present when any span carries a gen_ai gate key.
|
||||
type TraceAISummary struct {
|
||||
Tokens TraceAITokens `json:"tokens"`
|
||||
// TotalCost is null when no span carries a cost attribute.
|
||||
TotalCost *float64 `json:"totalCost" nullable:"true"`
|
||||
}
|
||||
|
||||
type TraceAITokens struct {
|
||||
Input uint64 `json:"input"`
|
||||
Output uint64 `json:"output"`
|
||||
CacheRead uint64 `json:"cacheRead"`
|
||||
CacheWrite uint64 `json:"cacheWrite"`
|
||||
Reasoning uint64 `json:"reasoning"`
|
||||
}
|
||||
|
||||
func NewGettableTraceSummary(stats *TraceStats) *GettableTraceSummary {
|
||||
summary := &GettableTraceSummary{
|
||||
StartTimestampMillis: stats.StartNs / 1_000_000,
|
||||
EndTimestampMillis: stats.EndNs / 1_000_000,
|
||||
RootServiceName: stats.RootServiceName,
|
||||
RootServiceEntryPoint: stats.RootEntryPoint,
|
||||
RootSpanStatusCode: stats.RootSpanStatusCode,
|
||||
TotalSpansCount: stats.TotalSpans,
|
||||
TotalErrorSpansCount: stats.TotalErrorSpans,
|
||||
HasMissingSpans: stats.HasMissingSpans,
|
||||
}
|
||||
if stats.GenAISpanCount > 0 {
|
||||
summary.AI = &TraceAISummary{Tokens: stats.Tokens, TotalCost: stats.TotalCost}
|
||||
}
|
||||
return summary
|
||||
}
|
||||
@@ -44,12 +44,6 @@ func (enum UnsetOrNonEmptyString) IsZero() bool {
|
||||
return enum.val == ""
|
||||
}
|
||||
|
||||
func (enum *UnsetOrNonEmptyString) SetIfUnset(val string) {
|
||||
if enum.IsZero() {
|
||||
enum.val = val
|
||||
}
|
||||
}
|
||||
|
||||
func (enum UnsetOrNonEmptyString) StringValue() string {
|
||||
return enum.val
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/licensing"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/zeustypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
@@ -94,8 +95,8 @@ func (h *handler) PutHost(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
req := new(zeustypes.PostableHost)
|
||||
if err := binding.JSON.BindBody(r.Body, req); err != nil {
|
||||
req, err := coretypes.BodyFromContext[zeustypes.PostableHost](r.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
5
tests/fixtures/cloudintegrations.py
vendored
5
tests/fixtures/cloudintegrations.py
vendored
@@ -34,8 +34,6 @@ class ProviderAccountSpec:
|
||||
expected_config: Callable[[dict], dict]
|
||||
# only the suites that exercise updates need to supply it.
|
||||
updated_params: dict = field(default_factory=dict)
|
||||
# params -> the agentReport.syncState the API is expected to return after the first check-in.
|
||||
expected_sync_state: Callable[[dict], dict | None] = lambda p: None
|
||||
# id shown in parametrized test names; defaults to the provider slug.
|
||||
id: str = field(default="")
|
||||
|
||||
@@ -317,7 +315,6 @@ def simulate_agent_checkin(
|
||||
account_id: str,
|
||||
cloud_account_id: str,
|
||||
data: dict | None = None,
|
||||
synced_version: int | None = None,
|
||||
) -> requests.Response:
|
||||
endpoint = f"/api/v1/cloud_integrations/{cloud_provider}/accounts/check_in"
|
||||
|
||||
@@ -326,8 +323,6 @@ def simulate_agent_checkin(
|
||||
"providerAccountId": cloud_account_id,
|
||||
"data": data or {},
|
||||
}
|
||||
if synced_version is not None:
|
||||
checkin_payload["syncedVersion"] = synced_version
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(endpoint),
|
||||
|
||||
30
tests/fixtures/notification_channel.py
vendored
30
tests/fixtures/notification_channel.py
vendored
@@ -1,5 +1,4 @@
|
||||
# pylint: disable=line-too-long
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
@@ -9,7 +8,6 @@ import docker
|
||||
import docker.errors
|
||||
import pytest
|
||||
import requests
|
||||
from sqlalchemy import sql
|
||||
from testcontainers.core.container import Network
|
||||
from wiremock.testing.testcontainer import WireMockContainer
|
||||
|
||||
@@ -48,34 +46,6 @@ def assert_email_channel_payload_clean(payload: str) -> None:
|
||||
assert SMTP_TEST_FROM not in payload
|
||||
|
||||
|
||||
def rewrite_channel_as_legacy_receiver(signoz: types.SigNoz, channel_id: str, receiver: dict) -> None:
|
||||
"""Overwrite a channel row, and its receiver in the org's alertmanager config,
|
||||
the way the spec migration leaves a row it cannot fill. Neither API writes
|
||||
such rows any more, so tests that need one seed it here. The receiver's name
|
||||
must be the channel's display name. The alertmanager picks the swapped
|
||||
receiver up on its next poll of the stored config."""
|
||||
configs_key = next(key for key in receiver if key.endswith("_configs"))
|
||||
# Storage names the kind; only msteams differs from its upstream configs list.
|
||||
notifier_type = "msteams" if configs_key == "msteamsv2_configs" else configs_key.removesuffix("_configs")
|
||||
with signoz.sqlstore.conn.connect() as conn:
|
||||
conn.execute(
|
||||
sql.text("UPDATE notification_channel SET type = :type, data = :data, spec = NULL WHERE id = :id"),
|
||||
{"id": channel_id, "type": notifier_type, "data": json.dumps(receiver)},
|
||||
)
|
||||
org_id, stored = conn.execute(
|
||||
sql.text("SELECT c.org_id, c.config FROM alertmanager_config c JOIN notification_channel n ON n.org_id = c.org_id WHERE n.id = :id"),
|
||||
{"id": channel_id},
|
||||
).one()
|
||||
config = json.loads(stored)
|
||||
config["receivers"] = [receiver if existing["name"] == receiver["name"] else existing for existing in config["receivers"]]
|
||||
raw = json.dumps(config)
|
||||
conn.execute(
|
||||
sql.text("UPDATE alertmanager_config SET config = :config, hash = :hash WHERE org_id = :org_id"),
|
||||
{"config": raw, "hash": hashlib.md5(raw.encode()).hexdigest(), "org_id": org_id},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
"""
|
||||
Default notification channel configs shared across alertmanager tests.
|
||||
"""
|
||||
|
||||
1
tests/fixtures/traces.py
vendored
1
tests/fixtures/traces.py
vendored
@@ -895,7 +895,6 @@ _TRACES_TABLES_TO_TRUNCATE = [
|
||||
"span_attributes_keys",
|
||||
"signoz_error_index_v2",
|
||||
"top_level_operations",
|
||||
"trace_summary",
|
||||
]
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,6 @@ from fixtures.auth import (
|
||||
USER_ADMIN_EMAIL,
|
||||
USER_ADMIN_PASSWORD,
|
||||
)
|
||||
from fixtures.notification_channel import rewrite_channel_as_legacy_receiver
|
||||
|
||||
TIMEOUT = 10
|
||||
|
||||
@@ -50,45 +49,24 @@ def test_repair_reports_nothing_for_a_readable_channel(
|
||||
assert [channel["id"] for channel in repair["channels"]] == [channel_id]
|
||||
|
||||
|
||||
def test_repair_deletes_a_legacy_channel_of_an_unmodelled_kind(
|
||||
def test_repair_deletes_a_v1_channel_of_an_unmodelled_kind(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
cleanup_notification_channels: list[str],
|
||||
) -> None:
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
name = f"legacy-telegram-{uuid.uuid4().hex[:8]}"
|
||||
name = f"v1-telegram-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(V2_BASE_URL),
|
||||
json={"name": name, "config": {"kind": "slack", "spec": {"apiUrl": "https://hooks.slack.test/services/T/B/X"}}},
|
||||
signoz.self.host_configs["8080"].get("/api/v1/channels"),
|
||||
json={"name": name, "telegram_configs": [{"chat": 12345, "token": "telegram-bot-token"}]},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
channel_id = response.json()["data"]["id"]
|
||||
cleanup_notification_channels.append(channel_id)
|
||||
rewrite_channel_as_legacy_receiver(signoz, channel_id, {"name": name, "telegram_configs": [{"chat": 12345, "token": "telegram-bot-token"}]})
|
||||
|
||||
# v2 lists the row with an empty kind and refuses to read it.
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(V2_BASE_URL),
|
||||
params={"query": name},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
listed = response.json()["data"]
|
||||
assert listed["total"] == 1
|
||||
assert listed["channels"][0]["displayName"] == name
|
||||
assert listed["channels"][0]["kind"] == ""
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{channel_id}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{channel_id}/repair"),
|
||||
@@ -133,33 +111,29 @@ def test_repair_deletes_a_legacy_channel_of_an_unmodelled_kind(
|
||||
assert response.json()["data"]["total"] == 0
|
||||
|
||||
|
||||
def test_repair_splits_a_legacy_channel_carrying_several_notifiers(
|
||||
def test_repair_splits_a_v1_channel_carrying_several_notifiers(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
cleanup_notification_channels: list[str],
|
||||
) -> None:
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
name = f"legacy-fanout-{uuid.uuid4().hex[:8]}"
|
||||
name = f"v1-fanout-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Only v1 accepts a receiver with more than one notifier configuration.
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(V2_BASE_URL),
|
||||
json={"name": name, "config": {"kind": "slack", "spec": {"apiUrl": "https://hooks.slack.test/services/T/B/X", "channel": "#alerts"}}},
|
||||
signoz.self.host_configs["8080"].get("/api/v1/channels"),
|
||||
json={
|
||||
"name": name,
|
||||
"slack_configs": [{"api_url": "https://hooks.slack.test/services/T/B/X", "channel": "#alerts"}],
|
||||
"webhook_configs": [{"url": "https://webhook.test/hook"}],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
channel_id = response.json()["data"]["id"]
|
||||
cleanup_notification_channels.append(channel_id)
|
||||
rewrite_channel_as_legacy_receiver(
|
||||
signoz,
|
||||
channel_id,
|
||||
{
|
||||
"name": name,
|
||||
"slack_configs": [{"api_url": "https://hooks.slack.test/services/T/B/X", "channel": "#alerts"}],
|
||||
"webhook_configs": [{"url": "https://webhook.test/hook"}],
|
||||
},
|
||||
)
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{channel_id}/repair"),
|
||||
@@ -215,65 +189,3 @@ def test_repair_splits_a_legacy_channel_carrying_several_notifiers(
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["data"]["config"]["kind"] == "slack"
|
||||
|
||||
|
||||
def test_repair_splits_off_an_unmodelled_notifier_for_its_own_delete(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
cleanup_notification_channels: list[str],
|
||||
) -> None:
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
name = f"legacy-mixed-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(V2_BASE_URL),
|
||||
json={"name": name, "config": {"kind": "slack", "spec": {"apiUrl": "https://hooks.slack.test/services/T/B/X", "channel": "#alerts"}}},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
channel_id = response.json()["data"]["id"]
|
||||
cleanup_notification_channels.append(channel_id)
|
||||
rewrite_channel_as_legacy_receiver(
|
||||
signoz,
|
||||
channel_id,
|
||||
{
|
||||
"name": name,
|
||||
"slack_configs": [{"api_url": "https://hooks.slack.test/services/T/B/X", "channel": "#alerts"}],
|
||||
"telegram_configs": [{"chat": 12345, "token": "telegram-bot-token"}],
|
||||
},
|
||||
)
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{channel_id}/repair"),
|
||||
params={"apply": "true"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
repair = response.json()["data"]
|
||||
assert repair["defect"] == "multiple_notifiers"
|
||||
assert repair["applied"] is True
|
||||
assert [channel["displayName"] for channel in repair["channels"]] == [name, f"{name} (2)"]
|
||||
assert [channel["kind"] for channel in repair["channels"]] == ["slack", ""]
|
||||
telegram_id = repair["channels"][1]["id"]
|
||||
cleanup_notification_channels.append(telegram_id)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{channel_id}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["data"]["config"]["kind"] == "slack"
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{telegram_id}/repair"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
repair = response.json()["data"]
|
||||
assert repair["defect"] == "unsupported_notifier"
|
||||
assert repair["action"] == "delete"
|
||||
|
||||
@@ -1,555 +0,0 @@
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import (
|
||||
USER_ADMIN_EMAIL,
|
||||
USER_ADMIN_PASSWORD,
|
||||
)
|
||||
|
||||
TIMEOUT = 10
|
||||
|
||||
V2_BASE_URL = "/api/v2/notification_channels"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"clashing_field,message_fragment",
|
||||
[
|
||||
pytest.param("name", "with name", id="name"),
|
||||
pytest.param("displayName", "with display name", id="display_name"),
|
||||
],
|
||||
)
|
||||
def test_create_rejects_a_duplicate_with_conflict( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
cleanup_notification_channels: list[str],
|
||||
clashing_field: str,
|
||||
message_fragment: str,
|
||||
) -> None:
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
shared = f"v2-dup-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
first = {"name": f"{shared}-first", "displayName": f"{shared} first", "config": {"kind": "email", "spec": {"to": "first@integration.test", "html": "<p>body</p>"}}}
|
||||
first[clashing_field] = shared
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(V2_BASE_URL),
|
||||
json=first,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
cleanup_notification_channels.append(response.json()["data"]["id"])
|
||||
|
||||
second = {"name": f"{shared}-second", "displayName": f"{shared} second", "config": {"kind": "email", "spec": {"to": "second@integration.test", "html": "<p>body</p>"}}}
|
||||
second[clashing_field] = shared
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(V2_BASE_URL),
|
||||
json=second,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CONFLICT, response.text
|
||||
# Both v2 conflicts share a status and an error code, so only the message
|
||||
# separates a clashing display name from a clashing name.
|
||||
assert message_fragment in response.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"body",
|
||||
[
|
||||
pytest.param(
|
||||
{
|
||||
"name": "Not_A_Label",
|
||||
"config": {"kind": "email", "spec": {"to": "a@integration.test", "html": "<p>body</p>"}},
|
||||
},
|
||||
id="name_not_dns1123_label",
|
||||
),
|
||||
pytest.param(
|
||||
{"config": {"kind": "email", "spec": {"to": "a@integration.test", "html": "<p>body</p>"}}},
|
||||
id="no_name_and_no_generate_name",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"name": "explicit",
|
||||
"generateName": True,
|
||||
"displayName": "Explicit",
|
||||
"config": {"kind": "email", "spec": {"to": "a@integration.test", "html": "<p>body</p>"}},
|
||||
},
|
||||
id="name_with_generate_name",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"generateName": True,
|
||||
"config": {"kind": "email", "spec": {"to": "a@integration.test", "html": "<p>body</p>"}},
|
||||
},
|
||||
id="generate_name_without_display_name",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"name": "default-receiver",
|
||||
"config": {"kind": "email", "spec": {"to": "a@integration.test", "html": "<p>body</p>"}},
|
||||
},
|
||||
id="reserved_receiver_name",
|
||||
),
|
||||
pytest.param({"name": "rejected"}, id="no_config"),
|
||||
pytest.param(
|
||||
{"name": "rejected", "config": {"kind": "telegram", "spec": {"chatId": 1}}},
|
||||
id="unmodelled_kind",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"name": "rejected",
|
||||
"config": {
|
||||
"kind": "slack",
|
||||
"spec": {
|
||||
"apiUrl": "https://hooks.slack.test/services/T/B/X",
|
||||
"channel": "#a",
|
||||
"text": "body",
|
||||
"iconEmoji": ":tada:",
|
||||
},
|
||||
},
|
||||
},
|
||||
id="unknown_spec_field",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"name": "rejected",
|
||||
"config": {"kind": "slack", "spec": {"to": "a@integration.test", "html": "<p>body</p>"}},
|
||||
},
|
||||
id="spec_of_another_kind",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"name": "rejected",
|
||||
"config": {"kind": "slack", "spec": {"channel": "#alerts", "title": "Alert", "text": "body"}},
|
||||
},
|
||||
id="slack_without_api_url",
|
||||
),
|
||||
pytest.param(
|
||||
{"name": "rejected", "config": {"kind": "email", "spec": {"html": "<p>body</p>"}}},
|
||||
id="email_without_to",
|
||||
),
|
||||
pytest.param(
|
||||
{"name": "rejected", "config": {"kind": "webhook", "spec": {}}},
|
||||
id="webhook_without_url",
|
||||
),
|
||||
pytest.param(
|
||||
{"name": "rejected", "config": {"kind": "pagerduty", "spec": {"description": "body"}}},
|
||||
id="pagerduty_without_routing_key",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"name": "rejected",
|
||||
"config": {"kind": "opsgenie", "spec": {"message": "subject", "description": "body"}},
|
||||
},
|
||||
id="opsgenie_without_api_key",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"name": "rejected",
|
||||
"config": {"kind": "msteams", "spec": {"title": "Alert", "text": "body"}},
|
||||
},
|
||||
id="msteams_without_webhook_url",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"name": "rejected",
|
||||
"config": {"kind": "googlechat", "spec": {"title": "Alert", "text": "body"}},
|
||||
},
|
||||
id="googlechat_without_webhook_url",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"name": "rejected",
|
||||
"config": {
|
||||
"kind": "jira",
|
||||
"spec": {
|
||||
"project": "OPS",
|
||||
"issueType": "Bug",
|
||||
"email": "oncall@integration.test",
|
||||
"apiToken": "jira-api-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
id="jira_without_site",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"name": "rejected",
|
||||
"config": {
|
||||
"kind": "jira",
|
||||
"spec": {
|
||||
"site": "https://acme.atlassian.net",
|
||||
"issueType": "Bug",
|
||||
"email": "oncall@integration.test",
|
||||
"apiToken": "jira-api-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
id="jira_without_project",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"name": "rejected",
|
||||
"config": {
|
||||
"kind": "jira",
|
||||
"spec": {
|
||||
"site": "https://acme.atlassian.net",
|
||||
"project": "OPS",
|
||||
"email": "oncall@integration.test",
|
||||
"apiToken": "jira-api-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
id="jira_without_issue_type",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"name": "rejected",
|
||||
"config": {
|
||||
"kind": "jira",
|
||||
"spec": {
|
||||
"site": "https://acme.atlassian.net",
|
||||
"project": "OPS",
|
||||
"issueType": "Bug",
|
||||
"apiToken": "jira-api-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
id="jira_without_email",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"name": "rejected",
|
||||
"config": {
|
||||
"kind": "jira",
|
||||
"spec": {
|
||||
"site": "https://acme.atlassian.net",
|
||||
"project": "OPS",
|
||||
"issueType": "Bug",
|
||||
"email": "oncall@integration.test",
|
||||
},
|
||||
},
|
||||
},
|
||||
id="jira_without_api_token",
|
||||
),
|
||||
pytest.param(
|
||||
{"name": "rejected", "config": {"kind": "jsmops", "spec": {}}},
|
||||
id="jsmops_without_api_key",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"name": "rejected",
|
||||
"config": {"kind": "incidentio", "spec": {"token": "incidentio-token"}},
|
||||
},
|
||||
id="incidentio_without_url",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"name": "rejected",
|
||||
"config": {
|
||||
"kind": "incidentio",
|
||||
"spec": {"url": "https://api.incident.io/v2/alert_events/http/01ABCDEF"},
|
||||
},
|
||||
},
|
||||
id="incidentio_without_token",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"name": "rejected",
|
||||
"config": {
|
||||
"kind": "slack",
|
||||
"spec": {"apiUrl": "https://hooks.slack.test/services/T/B/X", "fields": [{"title": "Severity"}]},
|
||||
},
|
||||
},
|
||||
id="slack_field_without_value",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"name": "rejected",
|
||||
"config": {
|
||||
"kind": "slack",
|
||||
"spec": {
|
||||
"apiUrl": "https://hooks.slack.test/services/T/B/X",
|
||||
"actions": [{"type": "button", "url": "https://signoz.test"}],
|
||||
},
|
||||
},
|
||||
},
|
||||
id="slack_action_without_text",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"name": "rejected",
|
||||
"config": {
|
||||
"kind": "slack",
|
||||
"spec": {
|
||||
"apiUrl": "https://hooks.slack.test/services/T/B/X",
|
||||
"actions": [{"type": "button", "text": "Open"}],
|
||||
},
|
||||
},
|
||||
},
|
||||
id="slack_action_without_url_or_name",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"name": "rejected",
|
||||
"config": {
|
||||
"kind": "slack",
|
||||
"spec": {
|
||||
"apiUrl": "https://hooks.slack.test/services/T/B/X",
|
||||
"actions": [{"type": "button", "text": "Ack", "name": "ack", "confirm": {"title": "Sure?"}}],
|
||||
},
|
||||
},
|
||||
},
|
||||
id="slack_action_confirm_without_text",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"name": "rejected",
|
||||
"config": {"kind": "email", "spec": {"to": "a@integration.test", "html": "<p>body</p>"}},
|
||||
"type": "this key is not a valid",
|
||||
},
|
||||
id="unknown_envelope_field",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"name": "rejected",
|
||||
"config": {
|
||||
"kind": "webhook",
|
||||
"spec": {"url": "https://webhook.test/hook", "username": "u", "password": "p", "bearerToken": "t"},
|
||||
},
|
||||
},
|
||||
id="webhook_basic_auth_with_bearer_token",
|
||||
),
|
||||
# The next two break a rule of the notifier rather than of the request
|
||||
# shape, and still surface as a 400.
|
||||
pytest.param(
|
||||
{
|
||||
"name": "rejected",
|
||||
"config": {
|
||||
"kind": "jira",
|
||||
"spec": {
|
||||
"site": "https://acme.atlassian.net",
|
||||
"project": "OPS",
|
||||
"issueType": "Bug",
|
||||
"email": "a@integration.test",
|
||||
"apiToken": "t",
|
||||
"summary": "Alert",
|
||||
"description": "body",
|
||||
"reopenDuration": "30s",
|
||||
},
|
||||
},
|
||||
},
|
||||
id="jira_reopen_duration_below_a_minute",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"name": "rejected",
|
||||
"config": {
|
||||
"kind": "incidentio",
|
||||
"spec": {
|
||||
"url": "https://api.incident.io/v2/alert_events/http/01ABCDEF",
|
||||
"token": "Bearer incidentio-token",
|
||||
"title": "Alert",
|
||||
"description": "body",
|
||||
},
|
||||
},
|
||||
},
|
||||
id="incidentio_token_with_bearer_prefix",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"name": "rejected",
|
||||
"config": {
|
||||
"kind": "slack",
|
||||
"spec": {"apiUrl": "https://hooks.slack.test/services/T/B/X", "title": ""},
|
||||
},
|
||||
},
|
||||
id="slack_title_empty_instead_of_omitted",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"name": "rejected",
|
||||
"config": {"kind": "jsmops", "spec": {"apiKey": "jsm-api-key", "tags": ""}},
|
||||
},
|
||||
id="jsmops_tags_empty_instead_of_omitted",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"name": "rejected",
|
||||
"config": {
|
||||
"kind": "jira",
|
||||
"spec": {
|
||||
"site": "https://acme.atlassian.net",
|
||||
"project": "OPS",
|
||||
"issueType": "Bug",
|
||||
"email": "a@integration.test",
|
||||
"apiToken": "t",
|
||||
"reopenDuration": "72h",
|
||||
},
|
||||
},
|
||||
},
|
||||
id="jira_reopen_duration_not_as_reported",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"name": "rejected",
|
||||
"config": {"kind": "email", "spec": {"to": "a@integration.test", "headers": {"subject": "must be written in canonical form, Subject"}}},
|
||||
},
|
||||
id="email_header_name_not_canonical",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_create_rejects_invalid_bodies(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
body: dict,
|
||||
) -> None:
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(V2_BASE_URL),
|
||||
json=body,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"params",
|
||||
[
|
||||
pytest.param({"sort": "data"}, id="sort_outside_the_enum"),
|
||||
pytest.param({"order": "sideways"}, id="order_outside_the_enum"),
|
||||
pytest.param({"kind": "telegram"}, id="kind_outside_the_enum"),
|
||||
pytest.param({"limit": -1}, id="negative_limit"),
|
||||
pytest.param({"offset": -1}, id="negative_offset"),
|
||||
],
|
||||
)
|
||||
def test_list_rejects_invalid_params(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
params: dict,
|
||||
) -> None:
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(V2_BASE_URL),
|
||||
params=params,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
|
||||
|
||||
def test_get_unknown_id(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/0199a1b2-c3d4-7000-8000-000000000000"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND, response.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"body",
|
||||
[
|
||||
pytest.param(
|
||||
{
|
||||
"name": "renamed",
|
||||
"config": {"kind": "slack", "spec": {"apiUrl": "https://hooks.slack.test/services/T/B/X"}},
|
||||
},
|
||||
id="name_in_body",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"displayName": "Renamed",
|
||||
"config": {"kind": "slack", "spec": {"apiUrl": "https://hooks.slack.test/services/T/B/X"}},
|
||||
},
|
||||
id="display_name_in_body",
|
||||
),
|
||||
pytest.param(
|
||||
{"config": {"kind": "slack", "spec": {}}},
|
||||
id="spec_missing_required_field",
|
||||
),
|
||||
pytest.param({}, id="no_config"),
|
||||
],
|
||||
)
|
||||
def test_update_rejects_invalid_bodies( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
cleanup_notification_channels: list[str],
|
||||
body: dict,
|
||||
) -> None:
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
name = f"v2-badupdate-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(V2_BASE_URL),
|
||||
json={"name": name, "config": {"kind": "slack", "spec": {"apiUrl": "https://hooks.slack.test/services/T/B/X"}}},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
channel_id = response.json()["data"]["id"]
|
||||
cleanup_notification_channels.append(channel_id)
|
||||
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{channel_id}"),
|
||||
json=body,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"body",
|
||||
[
|
||||
pytest.param(
|
||||
{
|
||||
"name": "test-send",
|
||||
"config": {"kind": "slack", "spec": {"apiUrl": "https://hooks.slack.test/services/T/B/X"}},
|
||||
},
|
||||
id="name_in_body",
|
||||
),
|
||||
pytest.param(
|
||||
{"config": {"kind": "slack", "spec": {}}},
|
||||
id="spec_missing_required_field",
|
||||
),
|
||||
pytest.param(
|
||||
{"config": {"kind": "telegram", "spec": {"chatId": 1}}},
|
||||
id="unmodelled_kind",
|
||||
),
|
||||
pytest.param({}, id="no_config"),
|
||||
],
|
||||
)
|
||||
def test_test_rejects_invalid_bodies(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
body: dict,
|
||||
) -> None:
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/test"),
|
||||
json=body,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
@@ -1,239 +0,0 @@
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from wiremock.client import HttpMethods, Mapping, MappingRequest, MappingResponse
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.alerts import update_rule_channel_name, verify_notification_expectation
|
||||
from fixtures.auth import (
|
||||
USER_ADMIN_EMAIL,
|
||||
USER_ADMIN_PASSWORD,
|
||||
)
|
||||
from fixtures.fs import get_testdata_file_path
|
||||
from fixtures.notification_channel import rewrite_channel_as_legacy_receiver
|
||||
|
||||
TIMEOUT = 10
|
||||
|
||||
V2_BASE_URL = "/api/v2/notification_channels"
|
||||
|
||||
|
||||
def test_get_reflects_a_v2_update_after_a_v1_create(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
cleanup_notification_channels: list[str],
|
||||
) -> None:
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
display_name = f"V1 then V2 {uuid.uuid4().hex[:8]}"
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/channels"),
|
||||
json={"name": display_name, "slack_configs": [{"api_url": "https://hooks.slack.test/services/T/B/V1", "channel": "#from-v1"}]},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
channel_id = response.json()["data"]["id"]
|
||||
cleanup_notification_channels.append(channel_id)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{channel_id}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["data"]["config"]["spec"]["channel"] == "#from-v1"
|
||||
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{channel_id}"),
|
||||
json={"config": {"kind": "slack", "spec": {"apiUrl": "https://hooks.slack.test/services/T/B/V2", "channel": "#from-v2"}}},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["data"]["config"]["spec"]["channel"] == "#from-v2"
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{channel_id}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
fetched = response.json()["data"]
|
||||
assert fetched["displayName"] == display_name
|
||||
assert fetched["config"]["spec"]["apiUrl"] == "https://hooks.slack.test/services/T/B/V2"
|
||||
assert fetched["config"]["spec"]["channel"] == "#from-v2"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"receiver",
|
||||
[
|
||||
pytest.param(
|
||||
{"telegram_configs": [{"chat": 12345, "token": "telegram-bot-token"}]},
|
||||
id="kind_v2_does_not_model",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"slack_configs": [{"api_url": "https://hooks.slack.test/services/T/B/X", "channel": "#alerts"}],
|
||||
"webhook_configs": [{"url": "https://webhook.test/hook"}],
|
||||
},
|
||||
id="several_notifiers",
|
||||
),
|
||||
pytest.param(
|
||||
{"webhook_configs": [{"url": "https://webhook.test/hook", "http_config": {"proxy_url": "http://proxy.test:3128"}}]},
|
||||
id="unsupported_http_config",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_v1_rejects_a_receiver_v2_cannot_represent(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
receiver: dict,
|
||||
) -> None:
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/channels"),
|
||||
json={"name": f"v1-rejected-{uuid.uuid4().hex[:8]}", **receiver},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
|
||||
|
||||
def test_update_retypes_a_legacy_channel_of_an_unmodelled_kind(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
cleanup_notification_channels: list[str],
|
||||
) -> None:
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
name = f"legacy-telegram-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(V2_BASE_URL),
|
||||
json={"name": name, "config": {"kind": "slack", "spec": {"apiUrl": "https://hooks.slack.test/services/T/B/X"}}},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
channel_id = response.json()["data"]["id"]
|
||||
cleanup_notification_channels.append(channel_id)
|
||||
rewrite_channel_as_legacy_receiver(signoz, channel_id, {"name": name, "telegram_configs": [{"chat": 12345, "token": "telegram-bot-token"}]})
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{channel_id}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
|
||||
# A v2 update needs nothing from the stored config, so it can rewrite a
|
||||
# channel v2 cannot read.
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{channel_id}"),
|
||||
json={"config": {"kind": "slack", "spec": {"apiUrl": "https://hooks.slack.test/services/T/B/X", "channel": "#retyped"}}},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["data"]["config"]["kind"] == "slack"
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{channel_id}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
fetched = response.json()["data"]
|
||||
assert fetched["displayName"] == name
|
||||
assert fetched["config"]["kind"] == "slack"
|
||||
assert fetched["config"]["spec"]["channel"] == "#retyped"
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(V2_BASE_URL),
|
||||
params={"query": name},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["data"]["channels"][0]["kind"] == "slack"
|
||||
|
||||
|
||||
def test_alerts_still_reach_a_legacy_channel_v2_cannot_read( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
notification_channel: types.TestContainerDocker,
|
||||
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
|
||||
cleanup_notification_channels: list[str],
|
||||
create_alert_rule: Callable[[dict], str],
|
||||
insert_alert_data: Callable[[list[types.AlertData], datetime], None],
|
||||
maildev: types.TestContainerDocker,
|
||||
) -> None:
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
name = f"legacy-delivery-{uuid.uuid4().hex[:8]}"
|
||||
slack_path = f"/services/T/B/{name}"
|
||||
webhook_path = f"/webhook/{name}"
|
||||
make_http_mocks(
|
||||
notification_channel,
|
||||
[Mapping(request=MappingRequest(method=HttpMethods.POST, url=path), response=MappingResponse(status=200, json_body={}), persistent=False) for path in (slack_path, webhook_path)],
|
||||
)
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(V2_BASE_URL),
|
||||
json={"name": name, "config": {"kind": "webhook", "spec": {"url": notification_channel.container_configs["8080"].get(webhook_path)}}},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
channel_id = response.json()["data"]["id"]
|
||||
cleanup_notification_channels.append(channel_id)
|
||||
rewrite_channel_as_legacy_receiver(
|
||||
signoz,
|
||||
channel_id,
|
||||
{
|
||||
"name": name,
|
||||
"slack_configs": [{"api_url": notification_channel.container_configs["8080"].get(slack_path), "channel": "#legacy"}],
|
||||
"webhook_configs": [{"url": notification_channel.container_configs["8080"].get(webhook_path)}],
|
||||
},
|
||||
)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{channel_id}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
|
||||
# The rule must not fire before the alertmanager has polled the swapped receiver.
|
||||
time.sleep(12)
|
||||
|
||||
insert_alert_data(
|
||||
[types.AlertData(type="metrics", data_path="ruler/test_scenarios/threshold_above_at_least_once/alert_data.jsonl")],
|
||||
base_time=datetime.now(tz=UTC) - timedelta(minutes=5),
|
||||
)
|
||||
with open(get_testdata_file_path("ruler/test_scenarios/threshold_above_at_least_once/rule.json"), encoding="utf-8") as f:
|
||||
rule_data = json.load(f)
|
||||
update_rule_channel_name(rule_data, name)
|
||||
create_alert_rule(rule_data)
|
||||
|
||||
verify_notification_expectation(
|
||||
notification_channel,
|
||||
maildev,
|
||||
types.AMNotificationExpectation(
|
||||
should_notify=True,
|
||||
wait_time_seconds=120,
|
||||
notification_validations=[
|
||||
types.NotificationValidation(destination_type="webhook", validation_data={"path": slack_path, "json_body": {"channel": "#legacy"}}),
|
||||
types.NotificationValidation(destination_type="webhook", validation_data={"path": webhook_path, "json_body": {"status": "firing", "receiver": name}}),
|
||||
],
|
||||
),
|
||||
)
|
||||
@@ -3,7 +3,6 @@ from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, add_license
|
||||
@@ -153,230 +152,3 @@ def test_duplicate_cloud_account_checkins(
|
||||
# Second check-in: account2 tries to claim the same provider account ID → 409
|
||||
response = simulate_agent_checkin(signoz, admin_token, spec.provider, account2["id"], same_provider_account_id)
|
||||
assert response.status_code == HTTPStatus.CONFLICT, f"Expected 409 for duplicate providerAccountId, got {response.status_code}: {response.text}"
|
||||
|
||||
|
||||
def test_sync_state_drops_removed_region_after_ack(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
) -> None:
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
account_id = create_cloud_integration_account(admin_token, "aws", regions=["us-east-1", "us-west-2"])["id"]
|
||||
provider_account_id = str(uuid.uuid4())
|
||||
|
||||
response = simulate_agent_checkin(signoz, admin_token, "aws", account_id, provider_account_id)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/aws/accounts/{account_id}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
json={"config": {"aws": {"regions": ["us-east-1"]}}},
|
||||
timeout=10,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
response = simulate_agent_checkin(signoz, admin_token, "aws", account_id, provider_account_id)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["data"]["syncState"] == {
|
||||
"version": 2,
|
||||
"inSync": False,
|
||||
"regions": {"us-east-1": {"state": "enabled"}, "us-west-2": {"state": "disabled"}},
|
||||
}, "removed region should be marked disabled and the version bumped"
|
||||
|
||||
response = simulate_agent_checkin(signoz, admin_token, "aws", account_id, provider_account_id, synced_version=2)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["data"]["syncState"] == {
|
||||
"version": 2,
|
||||
"inSync": True,
|
||||
"regions": {"us-east-1": {"state": "enabled"}},
|
||||
}, "acked removed region should be dropped"
|
||||
|
||||
|
||||
def test_sync_state_keeps_removed_region_without_ack(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
) -> None:
|
||||
"""The agent failed to clean up or crashed, so it never acks: the removed region stays and the version stays put."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
account_id = create_cloud_integration_account(admin_token, "aws", regions=["us-east-1", "us-west-2"])["id"]
|
||||
provider_account_id = str(uuid.uuid4())
|
||||
|
||||
response = simulate_agent_checkin(signoz, admin_token, "aws", account_id, provider_account_id)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/aws/accounts/{account_id}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
json={"config": {"aws": {"regions": ["us-east-1"]}}},
|
||||
timeout=10,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
for _ in range(3):
|
||||
response = simulate_agent_checkin(signoz, admin_token, "aws", account_id, provider_account_id)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["data"]["syncState"] == {
|
||||
"version": 2,
|
||||
"inSync": False,
|
||||
"regions": {"us-east-1": {"state": "enabled"}, "us-west-2": {"state": "disabled"}},
|
||||
}, "unacked removed region should stay without bumping the version"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("synced_version", [2, 9], ids=["stale", "ahead"])
|
||||
def test_sync_state_ignores_mismatched_ack(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
synced_version: int,
|
||||
) -> None:
|
||||
"""An ack for any version other than the current one (v3) is ignored,
|
||||
so us-west-2, removed at v2 and still unacked, is not dropped.
|
||||
"""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
account_id = create_cloud_integration_account(admin_token, "aws", regions=["us-east-1", "us-west-2"])["id"]
|
||||
provider_account_id = str(uuid.uuid4())
|
||||
|
||||
response = simulate_agent_checkin(signoz, admin_token, "aws", account_id, provider_account_id)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
for regions in (["us-east-1"], ["us-east-1", "eu-west-1"]):
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/aws/accounts/{account_id}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
json={"config": {"aws": {"regions": regions}}},
|
||||
timeout=10,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
response = simulate_agent_checkin(signoz, admin_token, "aws", account_id, provider_account_id)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
expected_sync_state = {
|
||||
"version": 3,
|
||||
"inSync": False,
|
||||
"regions": {"us-east-1": {"state": "enabled"}, "us-west-2": {"state": "disabled"}, "eu-west-1": {"state": "enabled"}},
|
||||
}
|
||||
assert response.json()["data"]["syncState"] == expected_sync_state
|
||||
|
||||
response = simulate_agent_checkin(signoz, admin_token, "aws", account_id, provider_account_id, synced_version=synced_version)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["data"]["syncState"] == expected_sync_state, "an ack for another version should be ignored"
|
||||
|
||||
|
||||
def test_sync_state_applies_ack_before_config_change(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
) -> None:
|
||||
"""The user changes regions while the agent syncs: the ack for the version it synced still lands."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
account_id = create_cloud_integration_account(admin_token, "aws", regions=["us-east-1", "us-west-2"])["id"]
|
||||
provider_account_id = str(uuid.uuid4())
|
||||
|
||||
response = simulate_agent_checkin(signoz, admin_token, "aws", account_id, provider_account_id)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
for regions, synced_version in ((["us-east-1"], None), (["us-east-1", "eu-west-1"], 2)):
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/aws/accounts/{account_id}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
json={"config": {"aws": {"regions": regions}}},
|
||||
timeout=10,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
response = simulate_agent_checkin(signoz, admin_token, "aws", account_id, provider_account_id, synced_version=synced_version)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
assert response.json()["data"]["syncState"] == {
|
||||
"version": 3,
|
||||
"inSync": False,
|
||||
"regions": {"us-east-1": {"state": "enabled"}, "eu-west-1": {"state": "enabled"}},
|
||||
}, "ack should drop the removed region before the new region bumps the version"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("synced_version", [1, None], ids=["agent_acks_synced_version", "agent_crashed"])
|
||||
def test_sync_state_region_removed_during_sync(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
synced_version: int | None,
|
||||
) -> None:
|
||||
"""The user removes a region while the agent syncs v1; whether the agent acks v1 or crashed, the region must not be lost."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
account_id = create_cloud_integration_account(admin_token, "aws", regions=["us-east-1", "us-west-2"])["id"]
|
||||
provider_account_id = str(uuid.uuid4())
|
||||
|
||||
response = simulate_agent_checkin(signoz, admin_token, "aws", account_id, provider_account_id)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["data"]["syncState"] == {
|
||||
"version": 1,
|
||||
"inSync": True,
|
||||
"regions": {"us-east-1": {"state": "enabled"}, "us-west-2": {"state": "enabled"}},
|
||||
}
|
||||
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/aws/accounts/{account_id}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
json={"config": {"aws": {"regions": ["us-east-1"]}}},
|
||||
timeout=10,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
response = simulate_agent_checkin(signoz, admin_token, "aws", account_id, provider_account_id, synced_version=synced_version)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["data"]["syncState"] == {
|
||||
"version": 2,
|
||||
"inSync": False,
|
||||
"regions": {"us-east-1": {"state": "enabled"}, "us-west-2": {"state": "disabled"}},
|
||||
}, "region removed mid-sync should be marked disabled"
|
||||
|
||||
response = simulate_agent_checkin(signoz, admin_token, "aws", account_id, provider_account_id, synced_version=2)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["data"]["syncState"] == {
|
||||
"version": 2,
|
||||
"inSync": True,
|
||||
"regions": {"us-east-1": {"state": "enabled"}},
|
||||
}
|
||||
|
||||
|
||||
def test_sync_state_after_disconnect(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
) -> None:
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
account_id = create_cloud_integration_account(admin_token, "aws", regions=["us-east-1", "us-west-2"])["id"]
|
||||
provider_account_id = str(uuid.uuid4())
|
||||
|
||||
response = simulate_agent_checkin(signoz, admin_token, "aws", account_id, provider_account_id)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/aws/accounts/{account_id}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
for _ in range(2):
|
||||
response = simulate_agent_checkin(signoz, admin_token, "aws", account_id, provider_account_id)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["data"]["removedAt"] is not None, "removedAt should be set after disconnect"
|
||||
assert response.json()["data"]["syncState"] == {
|
||||
"version": 2,
|
||||
"inSync": False,
|
||||
"regions": {"us-east-1": {"state": "disabled"}, "us-west-2": {"state": "disabled"}},
|
||||
}, "every region should be disabled once, without bumping the version on later check-ins"
|
||||
|
||||
for _ in range(2):
|
||||
response = simulate_agent_checkin(signoz, admin_token, "aws", account_id, provider_account_id, synced_version=2)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["data"]["syncState"] == {"version": 2, "inSync": True, "regions": {}}, "acked removal should leave no regions"
|
||||
|
||||
@@ -21,11 +21,6 @@ AWS_ACCOUNT_SPEC = ProviderAccountSpec(
|
||||
updated_params={"deployment_region": "us-east-1", "regions": ["us-east-1", "us-west-2", "eu-west-1"]},
|
||||
build_config=lambda p: {"aws": {"deploymentRegion": p["deployment_region"], "regions": p["regions"]}},
|
||||
expected_config=lambda p: {"regions": p["regions"]},
|
||||
expected_sync_state=lambda p: {
|
||||
"version": 1,
|
||||
"inSync": True,
|
||||
"regions": {region: {"state": "enabled"} for region in p["regions"]},
|
||||
},
|
||||
)
|
||||
|
||||
GCP_ACCOUNT_SPEC = ProviderAccountSpec(
|
||||
@@ -133,7 +128,6 @@ def test_list_accounts_after_checkin(
|
||||
assert found["providerAccountId"] == provider_account_id, "providerAccountId should match"
|
||||
assert found["config"][spec.provider] == spec.expected_config(spec.initial_params), "config should match account config"
|
||||
assert found["agentReport"] is not None, "agentReport should be present after check-in"
|
||||
assert found["agentReport"]["syncState"] == spec.expected_sync_state(spec.initial_params), "syncState should be seeded from the account regions on first check-in"
|
||||
assert found["removedAt"] is None, "removedAt should be null for a live account"
|
||||
|
||||
|
||||
@@ -288,7 +282,6 @@ def test_update_account_after_checkin_preserves_connected_status(
|
||||
assert found_after is not None, "Account must still be listed after config update (account_id should not be reset)"
|
||||
assert found_after["providerAccountId"] == provider_account_id, "providerAccountId should be preserved after update"
|
||||
assert found_after["agentReport"] is not None, "agentReport should be preserved after update"
|
||||
assert found_after["agentReport"]["syncState"] == found_before["agentReport"]["syncState"], "config update must not change syncState"
|
||||
assert found_after["config"][spec.provider] == spec.expected_config(spec.updated_params), "Config should reflect the update"
|
||||
assert found_after["removedAt"] is None, "removedAt should still be null"
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime
|
||||
from http import HTTPStatus
|
||||
from uuid import uuid4
|
||||
|
||||
import requests
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.metrics import Metrics
|
||||
from fixtures.querier import get_all_series, make_query_request
|
||||
|
||||
MINUTE_MS = 60_000
|
||||
QUERY_TIMEOUT = 30
|
||||
|
||||
|
||||
# Valid PromQL: the [30m:5m] subquery evaluates the or expression every 5m,
|
||||
# yielding sum(flicker) while the flicker metric has data and sum(steady)
|
||||
# after it stops. sum() drops __name__ from both, so every evaluation shares
|
||||
# one labelset and the result is a single series. query_range must return it —
|
||||
# not a "vector cannot contain metrics with the same labelset" error — and
|
||||
# must agree with /prometheus/api/v1/query on the same expression.
|
||||
def test_or_arms_merge_under_subquery_name_drop(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_metrics: Callable[[list[Metrics]], None],
|
||||
) -> None:
|
||||
now_ms = int(datetime.now(tz=UTC).timestamp() * 1000)
|
||||
base_ms = (now_ms // (5 * MINUTE_MS)) * (5 * MINUTE_MS) - 45 * MINUTE_MS
|
||||
|
||||
flicker = f"or_flicker_arm_{uuid4().hex[:8]}"
|
||||
steady = f"or_steady_arm_{uuid4().hex[:8]}"
|
||||
insert_metrics(
|
||||
[
|
||||
Metrics(
|
||||
metric_name=name,
|
||||
labels={"host": "server-01"},
|
||||
timestamp=datetime.fromtimestamp((base_ms + minute * MINUTE_MS) / 1000, tz=UTC),
|
||||
value=1.0,
|
||||
)
|
||||
# The flicker arm stops at minute 9, so every 30m subquery window
|
||||
# below sees it present at some 5m-aligned steps and absent (past
|
||||
# lookback) at others, with the steady arm filling the gaps.
|
||||
for name, minutes in ((flicker, range(10)), (steady, range(36)))
|
||||
for minute in minutes
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
query = f"max_over_time((sum({flicker}) or sum({steady}))[30m:5m])"
|
||||
start_ms = base_ms + 20 * MINUTE_MS
|
||||
end_ms = base_ms + 35 * MINUTE_MS
|
||||
|
||||
# A range query is semantically the instant evaluation of the same
|
||||
# expression at each grid timestamp. Each one must yield the single
|
||||
# nameless series; together they are the reference for query_range.
|
||||
instant_values: dict[int, float] = {}
|
||||
for ts_ms in range(start_ms, end_ms + 1, 5 * MINUTE_MS):
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/prometheus/api/v1/query"),
|
||||
params={"query": query, "time": ts_ms / 1000},
|
||||
timeout=QUERY_TIMEOUT,
|
||||
headers={"authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text[:300]
|
||||
body = response.json()
|
||||
assert body.get("status") == "success", body
|
||||
result = body["data"]["result"]
|
||||
assert [series["metric"] for series in result] == [{}], (ts_ms, result)
|
||||
instant_values[ts_ms] = float(result[0]["value"][1])
|
||||
assert set(instant_values.values()) == {1.0}, instant_values
|
||||
|
||||
# query_range over the same grid must agree point for point.
|
||||
spec = {"name": "A", "query": query, "step": 300}
|
||||
response = make_query_request(signoz, token, start_ms, end_ms, [{"type": "promql", "spec": spec}])
|
||||
assert response.status_code == HTTPStatus.OK, response.text[:300]
|
||||
series = get_all_series(response.json(), "A")
|
||||
assert len(series) == 1, f"both or arms must merge into one series: {series}"
|
||||
points = {point["timestamp"]: point["value"] for point in series[0].get("values") or []}
|
||||
assert points == instant_values, (points, instant_values)
|
||||
@@ -2,6 +2,8 @@ from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
|
||||
import requests
|
||||
|
||||
from fixtures import querier, types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, change_user_role, create_active_user
|
||||
from fixtures.querier import make_query_request
|
||||
@@ -150,3 +152,27 @@ def test_managed_viewer_meter_and_clickhouse_allowed_audit_denied(
|
||||
# audit-logs builder queries remain admin-only.
|
||||
audit = make_query_request(signoz, token, start, end, audit_query, request_type=querier.RequestType.RAW)
|
||||
assert audit.status_code == HTTPStatus.FORBIDDEN, audit.text
|
||||
|
||||
|
||||
def test_duplicate_signal_key_is_checked_on_the_bound_value(
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC)
|
||||
start, end = int((now - timedelta(hours=1)).timestamp() * 1000), int(now.timestamp() * 1000)
|
||||
|
||||
# raw string: json= would collapse the duplicate "signal" key
|
||||
body = (
|
||||
f'{{"schemaVersion":"v1","start":{start},"end":{end},"requestType":"scalar",'
|
||||
'"compositeQuery":{"queries":[{"type":"builder_query","spec":{"name":"A","signal":"traces","signal":"logs",'
|
||||
'"disabled":false,"filter":{"expression":"signoz.workspace.key.id = \'key-a\'"},'
|
||||
'"aggregations":[{"expression":"count()"}]}}]},"noCache":true}'
|
||||
)
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v5/query_range"),
|
||||
timeout=querier.QUERY_TIMEOUT,
|
||||
headers={"authorization": f"Bearer {get_token(key_a_email, user_password)}", "content-type": "application/json"},
|
||||
data=body,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN, response.text
|
||||
|
||||
@@ -246,6 +246,15 @@ def test_attach_detach_dual_scoped(
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN, f"assign viewer to target: expected 403, got {resp.status_code}: {resp.text}"
|
||||
|
||||
# duplicate roleId: the server binds the last one (viewer) -> forbidden. Raw string, json= would collapse the key.
|
||||
resp = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/service_account_roles"),
|
||||
data=f'{{"serviceAccountId": "{target_id}", "roleId": "{editor_role_id}", "roleId": "{viewer_role_id}"}}',
|
||||
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN, f"assign duplicate roleId to target: expected 403, got {resp.status_code}: {resp.text}"
|
||||
|
||||
# Both SA-detach (target id) and role-detach (editor) present -> remove allowed.
|
||||
resp = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/service_account_roles/{editor_entry_id}"),
|
||||
|
||||
@@ -1,283 +0,0 @@
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.querierai import root_span
|
||||
from fixtures.traces import TraceIdGenerator, Traces, TracesKind, TracesStatusCode
|
||||
|
||||
WATERFALL_FIELDS = (
|
||||
"startTimestampMillis",
|
||||
"endTimestampMillis",
|
||||
"rootServiceName",
|
||||
"rootServiceEntryPoint",
|
||||
"totalSpansCount",
|
||||
"totalErrorSpansCount",
|
||||
"hasMissingSpans",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("attribute_backend", ["map", "json"])
|
||||
def test_summary_ai_trace(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
use_attribute_backend: Callable[[str], None],
|
||||
attribute_backend: str,
|
||||
) -> None:
|
||||
"""The summary carries the waterfall's trace-level fields and, for a trace with gen_ai
|
||||
spans, token totals over every LLM span and the cost summed over the spans that carry it.
|
||||
Spans are written to one layout only, so a read from the wrong column sums to zero."""
|
||||
use_attribute_backend(attribute_backend)
|
||||
write_mode = "json_only" if attribute_backend == "json" else "legacy_only"
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
service = f"td-summary-{attribute_backend}"
|
||||
resources = {"service.name": service}
|
||||
trace_id = TraceIdGenerator.trace_id()
|
||||
root_id = TraceIdGenerator.span_id()
|
||||
|
||||
insert_traces(
|
||||
[
|
||||
root_span(now=now, trace_id=trace_id, span_id=root_id, resources=resources, duration_s=4),
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=4),
|
||||
duration=timedelta(seconds=1),
|
||||
trace_id=trace_id,
|
||||
span_id=TraceIdGenerator.span_id(),
|
||||
parent_span_id=root_id,
|
||||
name="chat gpt-4o-mini",
|
||||
kind=TracesKind.SPAN_KIND_CLIENT,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
resources=resources,
|
||||
attributes={
|
||||
"gen_ai.request.model": "gpt-4o-mini",
|
||||
"gen_ai.usage.input_tokens": 100,
|
||||
"gen_ai.usage.output_tokens": 20,
|
||||
"gen_ai.usage.cache_read.input_tokens": 7,
|
||||
"signoz.gen_ai.usage.tokens.cost": 0.01,
|
||||
},
|
||||
attribute_write_mode=write_mode,
|
||||
),
|
||||
# a failed LLM call: counted in tokens and errors, but priced by nobody
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=3),
|
||||
duration=timedelta(seconds=0.5),
|
||||
trace_id=trace_id,
|
||||
span_id=TraceIdGenerator.span_id(),
|
||||
parent_span_id=root_id,
|
||||
name="chat gpt-4o-mini",
|
||||
kind=TracesKind.SPAN_KIND_CLIENT,
|
||||
status_code=TracesStatusCode.STATUS_CODE_ERROR,
|
||||
resources=resources,
|
||||
attributes={
|
||||
"gen_ai.request.model": "gpt-4o-mini",
|
||||
"gen_ai.usage.input_tokens": 50,
|
||||
"gen_ai.usage.output_tokens": 5,
|
||||
},
|
||||
attribute_write_mode=write_mode,
|
||||
),
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=2),
|
||||
duration=timedelta(seconds=0.5),
|
||||
trace_id=trace_id,
|
||||
span_id=TraceIdGenerator.span_id(),
|
||||
parent_span_id=root_id,
|
||||
name="execute_tool",
|
||||
kind=TracesKind.SPAN_KIND_INTERNAL,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
resources=resources,
|
||||
attributes={"gen_ai.tool.name": "get_weather"},
|
||||
attribute_write_mode=write_mode,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
headers = {"authorization": f"Bearer {token}", "content-type": "application/json"}
|
||||
|
||||
summary = requests.get(signoz.self.host_configs["8080"].get(f"/api/v1/traces/{trace_id}/summary"), timeout=10, headers=headers)
|
||||
assert summary.status_code == HTTPStatus.OK, summary.text
|
||||
summary = summary.json()["data"]
|
||||
|
||||
waterfall = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v4/traces/{trace_id}/waterfall"),
|
||||
timeout=10,
|
||||
headers=headers,
|
||||
json={"selectedSpanId": "", "uncollapsedSpans": []},
|
||||
)
|
||||
assert waterfall.status_code == HTTPStatus.OK, waterfall.text
|
||||
waterfall = waterfall.json()["data"]
|
||||
|
||||
assert {k: summary[k] for k in WATERFALL_FIELDS} == {k: waterfall[k] for k in WATERFALL_FIELDS}
|
||||
assert summary["rootServiceName"] == service
|
||||
assert summary["rootServiceEntryPoint"] == "POST /api/chat"
|
||||
assert summary["totalSpansCount"] == 4
|
||||
assert summary["totalErrorSpansCount"] == 1
|
||||
assert summary["hasMissingSpans"] is False
|
||||
|
||||
assert summary["ai"]["tokens"] == {"input": 150, "output": 25, "cacheRead": 7, "cacheWrite": 0, "reasoning": 0}
|
||||
assert summary["ai"]["totalCost"] == pytest.approx(0.01)
|
||||
|
||||
|
||||
def test_summary_ai_trace_across_json_rollout(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
seed_attribute_evolution: Callable[[str, datetime], None],
|
||||
) -> None:
|
||||
"""A trace that straddles the attribute JSON rollout has LLM spans written only to the legacy
|
||||
maps before it and to the JSON column after it. The summary window covers both, so the gen_ai
|
||||
reads must fall back across columns and sum every span."""
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
rollout = now - timedelta(minutes=30)
|
||||
seed_attribute_evolution("traces", rollout)
|
||||
|
||||
service = "td-summary-rollout"
|
||||
resources = {"service.name": service}
|
||||
trace_id = TraceIdGenerator.trace_id()
|
||||
root_id = TraceIdGenerator.span_id()
|
||||
|
||||
insert_traces(
|
||||
[
|
||||
Traces(
|
||||
timestamp=rollout - timedelta(minutes=10),
|
||||
duration=timedelta(minutes=15),
|
||||
trace_id=trace_id,
|
||||
span_id=root_id,
|
||||
parent_span_id="",
|
||||
name="long agent run",
|
||||
kind=TracesKind.SPAN_KIND_SERVER,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
resources=resources,
|
||||
attributes={"http.response.status_code": 200},
|
||||
attribute_write_mode="legacy_only",
|
||||
),
|
||||
Traces(
|
||||
timestamp=rollout - timedelta(minutes=5),
|
||||
duration=timedelta(seconds=1),
|
||||
trace_id=trace_id,
|
||||
span_id=TraceIdGenerator.span_id(),
|
||||
parent_span_id=root_id,
|
||||
name="chat gpt-4o-mini",
|
||||
kind=TracesKind.SPAN_KIND_CLIENT,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
resources=resources,
|
||||
attributes={"gen_ai.request.model": "gpt-4o-mini", "gen_ai.usage.input_tokens": 100, "gen_ai.usage.output_tokens": 20, "signoz.gen_ai.usage.tokens.cost": 0.01},
|
||||
attribute_write_mode="legacy_only",
|
||||
),
|
||||
Traces(
|
||||
timestamp=rollout + timedelta(minutes=4),
|
||||
duration=timedelta(seconds=1),
|
||||
trace_id=trace_id,
|
||||
span_id=TraceIdGenerator.span_id(),
|
||||
parent_span_id=root_id,
|
||||
name="chat gpt-4o-mini",
|
||||
kind=TracesKind.SPAN_KIND_CLIENT,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
resources=resources,
|
||||
attributes={"gen_ai.request.model": "gpt-4o-mini", "gen_ai.usage.input_tokens": 50, "gen_ai.usage.output_tokens": 5, "signoz.gen_ai.usage.tokens.cost": 0.02},
|
||||
attribute_write_mode="json_only",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
summary = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/traces/{trace_id}/summary"),
|
||||
timeout=10,
|
||||
headers={"authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert summary.status_code == HTTPStatus.OK, summary.text
|
||||
summary = summary.json()["data"]
|
||||
|
||||
assert summary["totalSpansCount"] == 3
|
||||
assert summary["rootServiceEntryPoint"] == "long agent run"
|
||||
assert summary["rootSpanStatusCode"] == "200"
|
||||
assert summary["ai"]["tokens"] == {"input": 150, "output": 25, "cacheRead": 0, "cacheWrite": 0, "reasoning": 0}
|
||||
assert summary["ai"]["totalCost"] == pytest.approx(0.03)
|
||||
|
||||
|
||||
def test_summary_non_ai_trace_with_missing_root(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> None:
|
||||
"""A trace whose recorded spans all hang off an unrecorded parent reports the synthetic
|
||||
"Missing Span" root exactly as the waterfall does, and a trace without gen_ai spans has
|
||||
no `ai` block."""
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
resources = {"service.name": "td-summary-orphan"}
|
||||
trace_id = TraceIdGenerator.trace_id()
|
||||
missing_parent_id = TraceIdGenerator.span_id()
|
||||
|
||||
insert_traces(
|
||||
[
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=5),
|
||||
duration=timedelta(seconds=2),
|
||||
trace_id=trace_id,
|
||||
span_id=TraceIdGenerator.span_id(),
|
||||
parent_span_id=missing_parent_id,
|
||||
name="SELECT users",
|
||||
kind=TracesKind.SPAN_KIND_CLIENT,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
resources=resources,
|
||||
),
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=4),
|
||||
duration=timedelta(seconds=1),
|
||||
trace_id=trace_id,
|
||||
span_id=TraceIdGenerator.span_id(),
|
||||
parent_span_id=missing_parent_id,
|
||||
name="publish event",
|
||||
kind=TracesKind.SPAN_KIND_PRODUCER,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
resources=resources,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
headers = {"authorization": f"Bearer {token}", "content-type": "application/json"}
|
||||
|
||||
summary = requests.get(signoz.self.host_configs["8080"].get(f"/api/v1/traces/{trace_id}/summary"), timeout=10, headers=headers)
|
||||
assert summary.status_code == HTTPStatus.OK, summary.text
|
||||
summary = summary.json()["data"]
|
||||
|
||||
waterfall = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v4/traces/{trace_id}/waterfall"),
|
||||
timeout=10,
|
||||
headers=headers,
|
||||
json={"selectedSpanId": "", "uncollapsedSpans": []},
|
||||
)
|
||||
assert waterfall.status_code == HTTPStatus.OK, waterfall.text
|
||||
waterfall = waterfall.json()["data"]
|
||||
|
||||
assert {k: summary[k] for k in WATERFALL_FIELDS} == {k: waterfall[k] for k in WATERFALL_FIELDS}
|
||||
assert summary["hasMissingSpans"] is True
|
||||
assert summary["rootServiceName"] == ""
|
||||
assert summary["rootServiceEntryPoint"] == "Missing Span"
|
||||
assert summary["rootSpanStatusCode"] == ""
|
||||
assert summary["totalSpansCount"] == 2
|
||||
assert "ai" not in summary
|
||||
|
||||
|
||||
def test_summary_unknown_trace(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/traces/{TraceIdGenerator.trace_id()}/summary"),
|
||||
timeout=10,
|
||||
headers={"authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND, response.text
|
||||
Reference in New Issue
Block a user