mirror of
https://github.com/SigNoz/signoz.git
synced 2026-11-01 22:11:12 +00:00
Compare commits
6 Commits
nv/cache-g
...
feat/deplo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f34b6f678 | ||
|
|
21a4dee575 | ||
|
|
33405eebd6 | ||
|
|
fdf21fda60 | ||
|
|
daa51df45b | ||
|
|
b37231f31a |
@@ -1689,6 +1689,108 @@ components:
|
||||
required:
|
||||
- config
|
||||
type: object
|
||||
AnnotationtypesAnnotation:
|
||||
discriminator:
|
||||
mapping:
|
||||
deployment: '#/components/schemas/AnnotationtypesAnnotationDeployment'
|
||||
propertyName: kind
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/AnnotationtypesAnnotationDeployment'
|
||||
type: object
|
||||
AnnotationtypesAnnotationDeployment:
|
||||
properties:
|
||||
kind:
|
||||
$ref: '#/components/schemas/AnnotationtypesAnnotationKind'
|
||||
spec:
|
||||
$ref: '#/components/schemas/AnnotationtypesDeploymentSpec'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
AnnotationtypesAnnotationKind:
|
||||
enum:
|
||||
- deployment
|
||||
type: string
|
||||
AnnotationtypesAnnotations:
|
||||
properties:
|
||||
historyAvailableFrom:
|
||||
format: int64
|
||||
type: integer
|
||||
records:
|
||||
items:
|
||||
$ref: '#/components/schemas/AnnotationtypesAnnotation'
|
||||
nullable: true
|
||||
type: array
|
||||
total:
|
||||
type: integer
|
||||
required:
|
||||
- records
|
||||
- total
|
||||
- historyAvailableFrom
|
||||
type: object
|
||||
AnnotationtypesDeploymentSpec:
|
||||
properties:
|
||||
environment:
|
||||
type: string
|
||||
firstSeen:
|
||||
format: int64
|
||||
type: integer
|
||||
id:
|
||||
type: string
|
||||
logId:
|
||||
type: string
|
||||
resourceAttributes:
|
||||
additionalProperties:
|
||||
type: string
|
||||
nullable: true
|
||||
type: object
|
||||
service:
|
||||
type: string
|
||||
signal:
|
||||
$ref: '#/components/schemas/TelemetrytypesSignal'
|
||||
spanId:
|
||||
type: string
|
||||
traceId:
|
||||
type: string
|
||||
version:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- signal
|
||||
- service
|
||||
- environment
|
||||
- version
|
||||
- firstSeen
|
||||
- traceId
|
||||
- spanId
|
||||
- logId
|
||||
- resourceAttributes
|
||||
type: object
|
||||
AnnotationtypesPostableAnnotations:
|
||||
properties:
|
||||
end:
|
||||
format: int64
|
||||
type: integer
|
||||
filter:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Filter'
|
||||
kind:
|
||||
$ref: '#/components/schemas/AnnotationtypesAnnotationKind'
|
||||
limit:
|
||||
type: integer
|
||||
signals:
|
||||
items:
|
||||
$ref: '#/components/schemas/TelemetrytypesSignal'
|
||||
nullable: true
|
||||
type: array
|
||||
start:
|
||||
format: int64
|
||||
type: integer
|
||||
required:
|
||||
- kind
|
||||
- start
|
||||
- end
|
||||
- limit
|
||||
type: object
|
||||
AuthtypesAttributeMapping:
|
||||
properties:
|
||||
email:
|
||||
@@ -13550,6 +13652,73 @@ paths:
|
||||
tags:
|
||||
- alerts
|
||||
x-signoz-stability: alpha
|
||||
/api/v1/annotations:
|
||||
post:
|
||||
deprecated: false
|
||||
description: Returns annotation markers of the requested kind inside the time
|
||||
range. For kind 'deployment', a marker is the first time a (service, environment,
|
||||
version) tuple was seen in the requested signals (traces, logs, or both when
|
||||
omitted). Each record carries the signal that produced it, the first seen
|
||||
time in epoch milliseconds, the trace and span ids or the log id of the first
|
||||
row, and the resource attributes of that row minus the identity keys. Supports
|
||||
an optional resource-attribute filter expression. historyAvailableFrom is
|
||||
the earliest time the result can be trusted, bounded by the signal retention.
|
||||
operationId: ListAnnotations
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AnnotationtypesPostableAnnotations'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/AnnotationtypesAnnotations'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- traces:read
|
||||
- logs:read
|
||||
- tokenizer:
|
||||
- traces:read
|
||||
- logs:read
|
||||
summary: List annotations
|
||||
tags:
|
||||
- annotations
|
||||
x-signoz-stability: alpha
|
||||
/api/v1/authz/check:
|
||||
post:
|
||||
deprecated: false
|
||||
|
||||
105
frontend/src/api/generated/services/annotations/index.ts
Normal file
105
frontend/src/api/generated/services/annotations/index.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import { useMutation } from 'react-query';
|
||||
import type {
|
||||
MutationFunction,
|
||||
UseMutationOptions,
|
||||
UseMutationResult,
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
AnnotationtypesPostableAnnotationsDTO,
|
||||
ListAnnotations200,
|
||||
RenderErrorResponseDTO,
|
||||
} from '../sigNoz.schemas';
|
||||
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
|
||||
|
||||
/**
|
||||
* Returns annotation markers of the requested kind inside the time range. For kind 'deployment', a marker is the first time a (service, environment, version) tuple was seen in the requested signals (traces, logs, or both when omitted). Each record carries the signal that produced it, the first seen time in epoch milliseconds, the trace and span ids or the log id of the first row, and the resource attributes of that row minus the identity keys. Supports an optional resource-attribute filter expression. historyAvailableFrom is the earliest time the result can be trusted, bounded by the signal retention.
|
||||
* @summary List annotations
|
||||
*/
|
||||
export const listAnnotations = (
|
||||
annotationtypesPostableAnnotationsDTO?: BodyType<AnnotationtypesPostableAnnotationsDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<ListAnnotations200>({
|
||||
url: `/api/v1/annotations`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: annotationtypesPostableAnnotationsDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListAnnotationsMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof listAnnotations>>,
|
||||
TError,
|
||||
{ data?: BodyType<AnnotationtypesPostableAnnotationsDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof listAnnotations>>,
|
||||
TError,
|
||||
{ data?: BodyType<AnnotationtypesPostableAnnotationsDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['listAnnotations'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof listAnnotations>>,
|
||||
{ data?: BodyType<AnnotationtypesPostableAnnotationsDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return listAnnotations(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type ListAnnotationsMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listAnnotations>>
|
||||
>;
|
||||
export type ListAnnotationsMutationBody =
|
||||
| BodyType<AnnotationtypesPostableAnnotationsDTO>
|
||||
| undefined;
|
||||
export type ListAnnotationsMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary List annotations
|
||||
*/
|
||||
export const useListAnnotations = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof listAnnotations>>,
|
||||
TError,
|
||||
{ data?: BodyType<AnnotationtypesPostableAnnotationsDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof listAnnotations>>,
|
||||
TError,
|
||||
{ data?: BodyType<AnnotationtypesPostableAnnotationsDTO> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getListAnnotationsMutationOptions(options));
|
||||
};
|
||||
@@ -3217,6 +3217,127 @@ export interface AlertmanagertypesUpdatableNotificationChannelDTO {
|
||||
config: AlertmanagertypesChannelConfigDTO;
|
||||
}
|
||||
|
||||
export enum AnnotationtypesAnnotationDeploymentDTOKind {
|
||||
deployment = 'deployment',
|
||||
}
|
||||
export enum TelemetrytypesSignalDTO {
|
||||
traces = 'traces',
|
||||
logs = 'logs',
|
||||
metrics = 'metrics',
|
||||
'' = '',
|
||||
}
|
||||
export type AnnotationtypesDeploymentSpecDTOResourceAttributesAnyOf = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type AnnotationtypesDeploymentSpecDTOResourceAttributes = AnnotationtypesDeploymentSpecDTOResourceAttributesAnyOf | null;
|
||||
|
||||
export interface AnnotationtypesDeploymentSpecDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
environment: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
firstSeen: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
logId: string;
|
||||
/**
|
||||
* @type object,null
|
||||
*/
|
||||
resourceAttributes: AnnotationtypesDeploymentSpecDTOResourceAttributes;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
service: string;
|
||||
signal: TelemetrytypesSignalDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
spanId: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
traceId: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface AnnotationtypesAnnotationDeploymentDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @enum deployment
|
||||
*/
|
||||
kind: AnnotationtypesAnnotationDeploymentDTOKind;
|
||||
spec: AnnotationtypesDeploymentSpecDTO;
|
||||
}
|
||||
|
||||
export type AnnotationtypesAnnotationDTO =
|
||||
AnnotationtypesAnnotationDeploymentDTO;
|
||||
|
||||
export enum AnnotationtypesAnnotationKindDTO {
|
||||
deployment = 'deployment',
|
||||
}
|
||||
export interface AnnotationtypesAnnotationsDTO {
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
historyAvailableFrom: number;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
records: AnnotationtypesAnnotationDTO[] | null;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5FilterDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @description A filter in the query filter syntax: comparisons such as =, !=, <, >, IN, LIKE, CONTAINS and EXISTS on field names, combined with AND, OR and NOT, with string values in single quotes, for example key = 'value' AND other_key > 10. Which fields exist depends on the signal or endpoint the filter is used with.
|
||||
*/
|
||||
expression?: string;
|
||||
}
|
||||
|
||||
export interface AnnotationtypesPostableAnnotationsDTO {
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
end: number;
|
||||
filter?: Querybuildertypesv5FilterDTO;
|
||||
kind: AnnotationtypesAnnotationKindDTO;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
limit: number;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
signals?: TelemetrytypesSignalDTO[] | null;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
start: number;
|
||||
}
|
||||
|
||||
export interface AuthtypesAttributeMappingDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -5012,14 +5133,6 @@ export type Querybuildertypesv5BucketOptionsDTO =
|
||||
| Querybuildertypesv5BucketOptionsLinearDTO
|
||||
| Querybuildertypesv5BucketOptionsLogDTO;
|
||||
|
||||
export interface Querybuildertypesv5FilterDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @description A filter in the query filter syntax: comparisons such as =, !=, <, >, IN, LIKE, CONTAINS and EXISTS on field names, combined with AND, OR and NOT, with string values in single quotes, for example key = 'value' AND other_key > 10. Which fields exist depends on the signal or endpoint the filter is used with.
|
||||
*/
|
||||
expression?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The argument value as a number, string or boolean, for example the threshold for cutOffMin or the duration for timeShift.
|
||||
*/
|
||||
@@ -5088,12 +5201,6 @@ export enum TelemetrytypesFieldDataTypeDTO {
|
||||
number = 'number',
|
||||
'' = '',
|
||||
}
|
||||
export enum TelemetrytypesSignalDTO {
|
||||
traces = 'traces',
|
||||
logs = 'logs',
|
||||
metrics = 'metrics',
|
||||
'' = '',
|
||||
}
|
||||
export interface Querybuildertypesv5GroupByKeyDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -14577,6 +14684,14 @@ export type GetAlerts200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListAnnotations200 = {
|
||||
data: AnnotationtypesAnnotationsDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type AuthzCheck200 = {
|
||||
/**
|
||||
* @type array
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { Dot } from '@signozhq/icons';
|
||||
import { Dot, Globe, Inbox, SquarePen } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import Noz from 'components/Noz/Noz';
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
import { selectPendingUserInputStreamCount } from 'container/AIAssistant/store/pendingInputSelectors';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
|
||||
import { Globe, Inbox, SquarePen } from '@signozhq/icons';
|
||||
|
||||
import AnnouncementsModal from './AnnouncementsModal';
|
||||
import FeedbackModal from './FeedbackModal';
|
||||
|
||||
@@ -18,11 +18,9 @@ import {
|
||||
import ROUTES from 'constants/routes';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import {
|
||||
buildExplorerNavigationUrl,
|
||||
findSavedViewInLists,
|
||||
openSavedView,
|
||||
openSavedViewByKey,
|
||||
@@ -232,23 +230,6 @@ describe('findSavedViewInLists', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildExplorerNavigationUrl', () => {
|
||||
it('encodes composite query and view selectors', () => {
|
||||
const url = buildExplorerNavigationUrl(
|
||||
ROUTES.LOGS_EXPLORER,
|
||||
{ queryType: 'builder' } as never,
|
||||
{
|
||||
[QueryParams.panelTypes]: PANEL_TYPES.LIST,
|
||||
[QueryParams.viewKey]: 'view-1',
|
||||
},
|
||||
);
|
||||
|
||||
expect(url).toContain(ROUTES.LOGS_EXPLORER);
|
||||
expect(url).toContain(`${QueryParams.compositeQuery}=`);
|
||||
expect(url).toContain(`${QueryParams.viewKey}=`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('openSavedView', () => {
|
||||
beforeEach(() => {
|
||||
mockedNavigate.mockClear();
|
||||
|
||||
@@ -12,7 +12,7 @@ import { toSavedViewSource } from 'container/SavedViews/utils/toSavedViewSource'
|
||||
import { SOURCEPAGE_VS_ROUTES } from 'container/SavedViews/constants';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { buildExplorerNavigationUrl } from 'utils/buildExplorerNavigationUrl';
|
||||
|
||||
type SavedViewSourceHint = SavedViewSourcePage;
|
||||
|
||||
@@ -72,26 +72,6 @@ export function explorerRouteForSourcePage(
|
||||
return SOURCEPAGE_VS_ROUTES[sourcePage] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an explorer URL the same way `redirectWithQueryBuilderData` does —
|
||||
* without inheriting stale query params from the current page's `urlQuery`.
|
||||
*/
|
||||
export function buildExplorerNavigationUrl(
|
||||
route: string,
|
||||
query: Query,
|
||||
searchParams: Record<string, unknown>,
|
||||
): string {
|
||||
const params = new URLSearchParams();
|
||||
params.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(query)),
|
||||
);
|
||||
Object.entries(searchParams).forEach(([key, value]) => {
|
||||
params.set(key, JSON.stringify(value));
|
||||
});
|
||||
return `${route}?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function openSavedView(view: SavedviewtypesSavedViewDTO): void {
|
||||
const route = view.source ? explorerRouteForSourcePage(view.source) : null;
|
||||
if (!route) {
|
||||
|
||||
@@ -38,8 +38,7 @@ import { useAppContext } from 'providers/App/App';
|
||||
import { UserPreference } from 'types/api/preferences/preference';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import { isIngestionActive } from 'utils/app';
|
||||
import { isModifierKeyPressed } from 'utils/app';
|
||||
import { isIngestionActive, isModifierKeyPressed } from 'utils/app';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import crackerUrl from '@/assets/Icons/cracker.svg';
|
||||
|
||||
@@ -77,6 +77,11 @@
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
.noFilters {
|
||||
color: var(--l3-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import { CalendarClock } from '@signozhq/icons';
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '@signozhq/ui/popover';
|
||||
import { useMemo } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import CopyButton from 'periscope/components/CopyButton/CopyButton';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { SavedViewReadPermission } from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
|
||||
import { SAVED_VIEW_UPDATED_AT_FORMAT } from './constants';
|
||||
import SavedViewHighlightedExpression from './SavedViewHighlightedExpression';
|
||||
import { getSavedViewQuerySummaries } from './utils/getSavedViewQuerySummaries';
|
||||
import SavedViewHoverCardDetails from './SavedViewHoverCardDetails';
|
||||
import { SavedViewHoverCardProps } from './types';
|
||||
|
||||
import styles from './SavedViewHoverCard.module.scss';
|
||||
@@ -18,23 +13,11 @@ function SavedViewHoverCard({
|
||||
onCardLeave,
|
||||
onClose,
|
||||
}: SavedViewHoverCardProps): JSX.Element {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
const view = target?.view;
|
||||
const querySummaries = useMemo(
|
||||
() => (view ? getSavedViewQuerySummaries(view) : []),
|
||||
[view],
|
||||
);
|
||||
const hasQueryNames = querySummaries.length > 1;
|
||||
const copyValue = querySummaries
|
||||
.filter(({ expression }) => expression.length > 0)
|
||||
.map(({ queryName, expression }) =>
|
||||
hasQueryNames ? `${queryName}: ${expression}` : expression,
|
||||
)
|
||||
.join('\n');
|
||||
const { allowed: canReadAll } = useAuthZ([SavedViewReadPermission]);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={!!view}
|
||||
open={!!target}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
onClose();
|
||||
@@ -55,71 +38,17 @@ function SavedViewHoverCard({
|
||||
onOpenAutoFocus={(event): void => event.preventDefault()}
|
||||
onCloseAutoFocus={(event): void => event.preventDefault()}
|
||||
>
|
||||
{view && (
|
||||
{target && (
|
||||
<div
|
||||
className={styles.content}
|
||||
onMouseEnter={onCardEnter}
|
||||
onMouseLeave={onCardLeave}
|
||||
data-testid="saved-view-hover-card"
|
||||
>
|
||||
<div className={styles.header}>
|
||||
<Typography.Text className={styles.name}>
|
||||
{view.spec.displayName}
|
||||
</Typography.Text>
|
||||
{copyValue && (
|
||||
<CopyButton
|
||||
value={copyValue}
|
||||
ariaLabel="Copy query"
|
||||
testId="saved-view-hover-card-copy"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.query} data-testid="saved-view-hover-card-query">
|
||||
{querySummaries.length === 0 ? (
|
||||
<Typography.Text className={styles.noFilters}>
|
||||
No filters
|
||||
</Typography.Text>
|
||||
) : (
|
||||
querySummaries.map(({ queryName, expression, metric }) => (
|
||||
<div key={queryName} className={styles.queryItem}>
|
||||
{hasQueryNames && (
|
||||
<Typography.Text className={styles.queryName}>
|
||||
{queryName}
|
||||
</Typography.Text>
|
||||
)}
|
||||
{metric && (
|
||||
<Typography.Text className={styles.metric}>{metric}</Typography.Text>
|
||||
)}
|
||||
{expression && (
|
||||
<Typography.Text className={styles.expression}>
|
||||
<SavedViewHighlightedExpression expression={expression} />
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.updated}>
|
||||
<Typography.Text className={styles.sectionTitle}>
|
||||
Last updated
|
||||
</Typography.Text>
|
||||
{view.updatedBy && (
|
||||
<Typography.Text className={styles.metaText}>
|
||||
{view.updatedBy}
|
||||
</Typography.Text>
|
||||
)}
|
||||
{view.updatedAt && (
|
||||
<div className={styles.meta}>
|
||||
<CalendarClock size={14} />
|
||||
<Typography.Text className={styles.metaText}>
|
||||
{formatTimezoneAdjustedTimestamp(
|
||||
view.updatedAt,
|
||||
SAVED_VIEW_UPDATED_AT_FORMAT,
|
||||
)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<SavedViewHoverCardDetails
|
||||
viewId={target.viewId}
|
||||
canReadAll={canReadAll}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
|
||||
121
frontend/src/container/SavedViews/SavedViewHoverCardDetails.tsx
Normal file
121
frontend/src/container/SavedViews/SavedViewHoverCardDetails.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { useMemo } from 'react';
|
||||
import { CalendarClock } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { useGetSavedView } from 'api/generated/services/saved-view';
|
||||
import { withAuthZContent } from 'lib/authz/components/withAuthZ/withAuthZContent';
|
||||
import { buildSavedViewReadPermission } from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
|
||||
import CopyButton from 'periscope/components/CopyButton/CopyButton';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
import {
|
||||
SAVED_VIEW_LOAD_FAILED_NAME,
|
||||
SAVED_VIEW_UPDATED_AT_FORMAT,
|
||||
} from './constants';
|
||||
import SavedViewHighlightedExpression from './SavedViewHighlightedExpression';
|
||||
import SavedViewHoverCardSkeleton from './SavedViewHoverCardSkeleton';
|
||||
import { SavedViewHoverCardDetailsProps } from './types';
|
||||
import { getSavedViewQuerySummaries } from './utils/getSavedViewQuerySummaries';
|
||||
|
||||
import styles from './SavedViewHoverCard.module.scss';
|
||||
|
||||
function SavedViewHoverCardDetails({
|
||||
viewId,
|
||||
}: SavedViewHoverCardDetailsProps): JSX.Element {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
const { data, isLoading, isError } = useGetSavedView({ id: viewId });
|
||||
const view = data?.data;
|
||||
const querySummaries = useMemo(
|
||||
() => (view ? getSavedViewQuerySummaries(view) : []),
|
||||
[view],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return <SavedViewHoverCardSkeleton />;
|
||||
}
|
||||
|
||||
if (isError || !view) {
|
||||
return (
|
||||
<Typography.Text
|
||||
className={styles.error}
|
||||
data-testid="saved-view-hover-card-error"
|
||||
>
|
||||
{SAVED_VIEW_LOAD_FAILED_NAME}
|
||||
</Typography.Text>
|
||||
);
|
||||
}
|
||||
|
||||
const hasQueryNames = querySummaries.length > 1;
|
||||
const copyValue = querySummaries
|
||||
.filter(({ expression }) => expression.length > 0)
|
||||
.map(({ queryName, expression }) =>
|
||||
hasQueryNames ? `${queryName}: ${expression}` : expression,
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.header}>
|
||||
<Typography.Text className={styles.name}>
|
||||
{view.spec.displayName}
|
||||
</Typography.Text>
|
||||
{copyValue && (
|
||||
<CopyButton
|
||||
value={copyValue}
|
||||
ariaLabel="Copy query"
|
||||
testId="saved-view-hover-card-copy"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.query} data-testid="saved-view-hover-card-query">
|
||||
{querySummaries.length === 0 ? (
|
||||
<Typography.Text className={styles.noFilters}>No filters</Typography.Text>
|
||||
) : (
|
||||
querySummaries.map(({ queryName, expression, metric }) => (
|
||||
<div key={queryName} className={styles.queryItem}>
|
||||
{hasQueryNames && (
|
||||
<Typography.Text className={styles.queryName}>
|
||||
{queryName}
|
||||
</Typography.Text>
|
||||
)}
|
||||
{metric && (
|
||||
<Typography.Text className={styles.metric}>{metric}</Typography.Text>
|
||||
)}
|
||||
{expression && (
|
||||
<Typography.Text className={styles.expression}>
|
||||
<SavedViewHighlightedExpression expression={expression} />
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.updated}>
|
||||
<Typography.Text className={styles.sectionTitle}>
|
||||
Last updated
|
||||
</Typography.Text>
|
||||
{view.updatedBy && (
|
||||
<Typography.Text className={styles.metaText}>
|
||||
{view.updatedBy}
|
||||
</Typography.Text>
|
||||
)}
|
||||
{view.updatedAt && (
|
||||
<div className={styles.meta}>
|
||||
<CalendarClock size={14} />
|
||||
<Typography.Text className={styles.metaText}>
|
||||
{formatTimezoneAdjustedTimestamp(
|
||||
view.updatedAt,
|
||||
SAVED_VIEW_UPDATED_AT_FORMAT,
|
||||
)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withAuthZContent(SavedViewHoverCardDetails, {
|
||||
checks: ({ viewId, canReadAll }) =>
|
||||
canReadAll ? [] : [buildSavedViewReadPermission(viewId)],
|
||||
fallbackOnLoading: <SavedViewHoverCardSkeleton />,
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Skeleton } from '@signozhq/ui/skeleton';
|
||||
|
||||
function SavedViewHoverCardSkeleton(): JSX.Element {
|
||||
return (
|
||||
<Skeleton
|
||||
active
|
||||
paragraph={{ rows: 3, width: ['100%', '80%', '60%'] }}
|
||||
testId="saved-view-hover-card-loading"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default SavedViewHoverCardSkeleton;
|
||||
@@ -54,9 +54,8 @@
|
||||
&.isActive {
|
||||
background-color: var(--l3-background);
|
||||
|
||||
.rowName {
|
||||
--button-variant-ghost-color: var(--l1-foreground);
|
||||
--button-variant-ghost-hover-color: var(--l1-foreground);
|
||||
.rowLink {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,18 +75,32 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.rowName {
|
||||
--button-height: 100%;
|
||||
--button-padding: 0 var(--spacing-6);
|
||||
--button-justify-content: flex-start;
|
||||
--button-font-size: var(--periscope-font-size-base);
|
||||
--button-variant-ghost-color: var(--l2-foreground);
|
||||
--button-variant-ghost-hover-color: var(--l2-foreground);
|
||||
--button-variant-ghost-hover-background-color: transparent;
|
||||
|
||||
.rowLink {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
padding: 0 var(--spacing-6);
|
||||
border-radius: var(--radius-1);
|
||||
color: var(--l2-foreground);
|
||||
text-decoration: none;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 1px solid var(--button-focus-ring);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--l2-foreground-hover);
|
||||
}
|
||||
}
|
||||
|
||||
// nowrap fills the row before the ellipsis; line clamping alone stops at the last whole word.
|
||||
.rowLinkLabel {
|
||||
min-width: 0;
|
||||
letter-spacing: -0.005em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.loading {
|
||||
|
||||
@@ -65,11 +65,11 @@ function SavedViewsList({
|
||||
|
||||
const rowHover = useMemo<SavedViewsListRowHoverProps>(
|
||||
() => ({
|
||||
onRowEnter: (view, row): void => {
|
||||
onRowEnter: (viewId, row): void => {
|
||||
const listTop = listRef.current?.getBoundingClientRect().top ?? 0;
|
||||
const rowRect = row.getBoundingClientRect();
|
||||
onHoverCardRowEnter({
|
||||
view,
|
||||
viewId,
|
||||
anchorTop: rowRect.top - listTop,
|
||||
anchorHeight: rowRect.height,
|
||||
});
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import cx from 'classnames';
|
||||
import { Undo2 } from '@signozhq/icons';
|
||||
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
|
||||
import { buildSavedViewReadPermission } from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
|
||||
import SavedViewsRowButton from './SavedViewsRowButton';
|
||||
import SavedViewsRowLink from './SavedViewsRowLink';
|
||||
import SavedViewsRowMenu from './SavedViewsRowMenu';
|
||||
import { SavedViewsListRowProps } from './types';
|
||||
|
||||
@@ -18,42 +16,17 @@ function SavedViewsListRow({
|
||||
onAction,
|
||||
hover,
|
||||
}: SavedViewsListRowProps): JSX.Element {
|
||||
const name = view.spec.displayName;
|
||||
const readPermission = buildSavedViewReadPermission(view.id);
|
||||
// Same cached check as the button; gates the hover card.
|
||||
const { isLoading, deniedPermissions } = useAuthZ([readPermission]);
|
||||
const canRead = !isLoading && deniedPermissions.length === 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(styles.row, { [styles.isActive]: isActive })}
|
||||
data-testid="saved-views-row"
|
||||
data-view-id={view.id}
|
||||
onMouseEnter={(event): void => {
|
||||
if (canRead) {
|
||||
hover.onRowEnter(view, event.currentTarget);
|
||||
}
|
||||
}}
|
||||
onMouseEnter={(event): void =>
|
||||
hover.onRowEnter(view.id, event.currentTarget)
|
||||
}
|
||||
onMouseLeave={hover.onRowLeave}
|
||||
>
|
||||
<div className={styles.rowName}>
|
||||
<AuthZButton
|
||||
checks={[readPermission]}
|
||||
size="md"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
width="100%"
|
||||
textOverflow="ellipsis"
|
||||
onClick={(): void => {
|
||||
if (!isActive) {
|
||||
onSelect(view);
|
||||
}
|
||||
}}
|
||||
testId="saved-views-row-name"
|
||||
>
|
||||
{name}
|
||||
</AuthZButton>
|
||||
</div>
|
||||
<SavedViewsRowLink view={view} isActive={isActive} onSelect={onSelect} />
|
||||
{isActive && (
|
||||
<SavedViewsRowButton
|
||||
icon={<Undo2 size={14} />}
|
||||
|
||||
@@ -5,7 +5,11 @@ import { Typography } from '@signozhq/ui/typography';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { AuthZGuardContent } from 'lib/authz/components/AuthZGuard/AuthZGuardContent';
|
||||
import { SavedViewListPermission } from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
|
||||
import {
|
||||
SavedViewListPermission,
|
||||
SavedViewReadPermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
|
||||
import SavedViewsIconButton from './SavedViewsIconButton';
|
||||
import SavedViewsList from './SavedViewsList';
|
||||
@@ -21,6 +25,8 @@ function SavedViewsPanel({
|
||||
onClose?: () => void;
|
||||
}): JSX.Element {
|
||||
const [search, setSearch] = useState('');
|
||||
// Batched with the list check, so the hover card reads it from cache.
|
||||
useAuthZ([SavedViewReadPermission]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
57
frontend/src/container/SavedViews/SavedViewsRowLink.tsx
Normal file
57
frontend/src/container/SavedViews/SavedViewsRowLink.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { MouseEvent, useMemo } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { AppLink } from 'lib/router/AppLink';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { isModifierKeyPressed } from 'utils/app';
|
||||
import { useTimeSearchParams } from 'hooks/useTimeSearchParams';
|
||||
|
||||
import { SavedViewsRowLinkProps } from './types';
|
||||
import { getSavedViewPath } from './utils/getSavedViewPath';
|
||||
|
||||
import styles from './SavedViewsList.module.scss';
|
||||
|
||||
function SavedViewsRowLink({
|
||||
view,
|
||||
isActive,
|
||||
onSelect,
|
||||
}: SavedViewsRowLinkProps): JSX.Element {
|
||||
const { pathname } = useAppLocation();
|
||||
const timeSearch = useTimeSearchParams();
|
||||
const path = useMemo(
|
||||
() => getSavedViewPath(pathname, view, timeSearch),
|
||||
[pathname, view, timeSearch],
|
||||
);
|
||||
|
||||
// Opening in a new tab or window is left to the browser.
|
||||
const handleClick = (event: MouseEvent<HTMLAnchorElement>): void => {
|
||||
if (isModifierKeyPressed(event) || event.shiftKey) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
if (!isActive) {
|
||||
onSelect(view);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AppLink
|
||||
to={path}
|
||||
className={styles.rowLink}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
onClick={handleClick}
|
||||
data-testid="saved-views-row-name"
|
||||
>
|
||||
<Typography.Text
|
||||
as="span"
|
||||
size="base"
|
||||
weight="normal"
|
||||
truncate={1}
|
||||
className={styles.rowLinkLabel}
|
||||
>
|
||||
{view.spec.displayName}
|
||||
</Typography.Text>
|
||||
</AppLink>
|
||||
);
|
||||
}
|
||||
|
||||
export default SavedViewsRowLink;
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
@@ -16,7 +18,7 @@ import {
|
||||
SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS,
|
||||
} from '../constants';
|
||||
import SavedViewsPanel from '../SavedViewsPanel';
|
||||
import { mockSavedViewsApi } from './savedViewsApiMock';
|
||||
import { API, mockSavedViewsApi } from './savedViewsApiMock';
|
||||
import {
|
||||
expectUsable,
|
||||
explorerUrl,
|
||||
@@ -139,9 +141,11 @@ function advance(ms: number): void {
|
||||
});
|
||||
}
|
||||
|
||||
function openCard(name: string): void {
|
||||
// Resolves once the card has fetched its view.
|
||||
async function openCard(name: string): Promise<HTMLElement> {
|
||||
fireEvent.mouseEnter(row(name));
|
||||
advance(SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS);
|
||||
return screen.findByTestId('saved-view-hover-card-query');
|
||||
}
|
||||
|
||||
describe('SavedViewHoverCard', () => {
|
||||
@@ -168,7 +172,8 @@ describe('SavedViewHoverCard', () => {
|
||||
expect(card()).toBeNull();
|
||||
|
||||
advance(1);
|
||||
expect(card()).toHaveTextContent('Errors');
|
||||
expect(card()).toBeInTheDocument();
|
||||
await waitFor(() => expect(card()).toHaveTextContent('Errors'));
|
||||
});
|
||||
|
||||
it('does not open when the pointer leaves before the delay', async () => {
|
||||
@@ -184,7 +189,7 @@ describe('SavedViewHoverCard', () => {
|
||||
|
||||
it('stays open while the pointer moves into the card', async () => {
|
||||
await renderPanel();
|
||||
openCard('Errors');
|
||||
await openCard('Errors');
|
||||
|
||||
fireEvent.mouseLeave(row('Errors'));
|
||||
fireEvent.mouseEnter(card() as HTMLElement);
|
||||
@@ -198,7 +203,7 @@ describe('SavedViewHoverCard', () => {
|
||||
|
||||
it('closes on leaving the row and reopens for the next row after the delay', async () => {
|
||||
await renderPanel();
|
||||
openCard('Errors');
|
||||
await openCard('Errors');
|
||||
|
||||
fireEvent.mouseLeave(row('Errors'));
|
||||
fireEvent.mouseEnter(row('Slow spans'));
|
||||
@@ -206,12 +211,12 @@ describe('SavedViewHoverCard', () => {
|
||||
expect(card()).toBeNull();
|
||||
|
||||
advance(SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS);
|
||||
expect(card()).toHaveTextContent('Slow spans');
|
||||
await waitFor(() => expect(card()).toHaveTextContent('Slow spans'));
|
||||
});
|
||||
|
||||
it('closes when the row menu is pressed', async () => {
|
||||
await renderPanel();
|
||||
openCard('Errors');
|
||||
await openCard('Errors');
|
||||
|
||||
fireEvent.click(within(row('Errors')).getByTestId('saved-views-row-menu'));
|
||||
|
||||
@@ -222,19 +227,17 @@ describe('SavedViewHoverCard', () => {
|
||||
describe('content', () => {
|
||||
it('shows the filter, the updater and the update time', async () => {
|
||||
await renderPanel();
|
||||
openCard('Errors');
|
||||
const query = await openCard('Errors');
|
||||
|
||||
const content = card() as HTMLElement;
|
||||
expect(
|
||||
within(content).getByTestId('saved-view-hover-card-query'),
|
||||
).toHaveTextContent('has_error = true');
|
||||
expect(query).toHaveTextContent('has_error = true');
|
||||
expect(content).toHaveTextContent('editor@signoz.io');
|
||||
expect(content).toHaveTextContent('Sep 28, 2026');
|
||||
});
|
||||
|
||||
it('copies the filter expression', async () => {
|
||||
await renderPanel();
|
||||
openCard('Errors');
|
||||
await openCard('Errors');
|
||||
|
||||
fireEvent.click(screen.getByTestId('saved-view-hover-card-copy'));
|
||||
|
||||
@@ -243,9 +246,8 @@ describe('SavedViewHoverCard', () => {
|
||||
|
||||
it('names each query when there are several, and copies them per line', async () => {
|
||||
await renderPanel();
|
||||
openCard('Errors and slow');
|
||||
const query = await openCard('Errors and slow');
|
||||
|
||||
const query = screen.getByTestId('saved-view-hover-card-query');
|
||||
expect(query).toHaveTextContent('A');
|
||||
expect(query).toHaveTextContent('B');
|
||||
fireEvent.click(screen.getByTestId('saved-view-hover-card-copy'));
|
||||
@@ -257,21 +259,74 @@ describe('SavedViewHoverCard', () => {
|
||||
|
||||
it('says there are no filters, with nothing to copy', async () => {
|
||||
await renderPanel();
|
||||
openCard('Everything');
|
||||
const query = await openCard('Everything');
|
||||
|
||||
expect(screen.getByTestId('saved-view-hover-card-query')).toHaveTextContent(
|
||||
'No filters',
|
||||
);
|
||||
expect(query).toHaveTextContent('No filters');
|
||||
expect(screen.queryByTestId('saved-view-hover-card-copy')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the metric of a metrics query', async () => {
|
||||
await renderPanel(SavedviewtypesSourceDTO.metrics, DataSource.METRICS);
|
||||
openCard('Request rate');
|
||||
const query = await openCard('Request rate');
|
||||
|
||||
const query = screen.getByTestId('saved-view-hover-card-query');
|
||||
expect(query).toHaveTextContent('app.requests · rate · sum');
|
||||
expect(query).not.toHaveTextContent('No filters');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetching', () => {
|
||||
it('shows the query from the view fetch, not from the list', async () => {
|
||||
server.use(
|
||||
rest.get(API, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: [{ ...errors, spec: { ...errors.spec, queries: [] } }],
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
await renderPanel();
|
||||
|
||||
const query = await openCard('Errors');
|
||||
|
||||
expect(query).toHaveTextContent('has_error = true');
|
||||
});
|
||||
|
||||
it('fetches the view again on every open', async () => {
|
||||
let fetches = 0;
|
||||
server.use(
|
||||
rest.get(`${API}/:id`, (_req, res, ctx) => {
|
||||
fetches += 1;
|
||||
return res(ctx.json({ status: 'success', data: errors }));
|
||||
}),
|
||||
);
|
||||
await renderPanel();
|
||||
|
||||
await openCard('Errors');
|
||||
fireEvent.mouseLeave(row('Errors'));
|
||||
advance(SAVED_VIEW_HOVER_CARD_CLOSE_DELAY_MS);
|
||||
expect(card()).toBeNull();
|
||||
await openCard('Errors');
|
||||
|
||||
await waitFor(() => expect(fetches).toBe(2));
|
||||
});
|
||||
|
||||
it('says the view failed to load when its fetch fails', async () => {
|
||||
server.use(
|
||||
rest.get(`${API}/:id`, (_req, res, ctx) =>
|
||||
res(ctx.status(500), ctx.json({ status: 'error' })),
|
||||
),
|
||||
);
|
||||
await renderPanel();
|
||||
|
||||
fireEvent.mouseEnter(row('Errors'));
|
||||
advance(SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS);
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('saved-view-hover-card-error'),
|
||||
).resolves.toHaveTextContent('View failed to load');
|
||||
expect(screen.queryByTestId('saved-view-hover-card-copy')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { fireEvent, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
AuthtypesTransactionDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import {
|
||||
@@ -9,34 +13,42 @@ import {
|
||||
buildSavedViewUpdatePermission,
|
||||
SavedViewCreatePermission,
|
||||
SavedViewListPermission,
|
||||
SavedViewReadPermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
|
||||
import {
|
||||
AUTHZ_CHECK_URL,
|
||||
authzMockResponse,
|
||||
setupAuthzDeny,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS } from '../constants';
|
||||
import SavedViewsHeader from '../SavedViewsHeader';
|
||||
import SavedViewsPanel from '../SavedViewsPanel';
|
||||
import { API, mockSavedViewsApi } from './savedViewsApiMock';
|
||||
import {
|
||||
expectUsable,
|
||||
explorerUrl,
|
||||
makeView,
|
||||
MENU_CHECK_TIMEOUT,
|
||||
queryWith,
|
||||
renderWithExplorerProviders,
|
||||
urlParam,
|
||||
viewUrl,
|
||||
} from './savedViewsTestUtils';
|
||||
|
||||
const PATH = ROUTES.TRACES_EXPLORER;
|
||||
const HOVER_CARD_TIMEOUT = { timeout: SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS * 3 };
|
||||
|
||||
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
|
||||
const slow = makeView({ id: 'view-2', displayName: 'Slow spans' });
|
||||
|
||||
function renderPanel(url = explorerUrl(PATH, {})): void {
|
||||
renderWithExplorerProviders(
|
||||
function renderPanel(
|
||||
url = explorerUrl(PATH, {}),
|
||||
): ReturnType<typeof renderWithExplorerProviders> {
|
||||
return renderWithExplorerProviders(
|
||||
<SavedViewsPanel
|
||||
source={SavedviewtypesSourceDTO.traces}
|
||||
onClose={jest.fn()}
|
||||
@@ -68,6 +80,37 @@ async function openRowMenu(name: string): Promise<void> {
|
||||
fireEvent.click(within(rowNamed(name)).getByTestId('saved-views-row-menu'));
|
||||
}
|
||||
|
||||
// Records every check, answered by `isGranted`.
|
||||
function recordChecks(
|
||||
isGranted: (transaction: AuthtypesTransactionDTO) => boolean = (): boolean =>
|
||||
true,
|
||||
): AuthtypesTransactionDTO[] {
|
||||
const checks: AuthtypesTransactionDTO[] = [];
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = (await req.json()) as AuthtypesTransactionDTO[];
|
||||
checks.push(...payload);
|
||||
return res(ctx.json(authzMockResponse(payload, payload.map(isGranted))));
|
||||
}),
|
||||
);
|
||||
return checks;
|
||||
}
|
||||
|
||||
const readSelectors = (checks: AuthtypesTransactionDTO[]): string[] =>
|
||||
checks
|
||||
.filter((transaction) => transaction.relation === 'read')
|
||||
.map((transaction) => transaction.object.selector);
|
||||
|
||||
async function hoverRow(name: string): Promise<HTMLElement> {
|
||||
await screen.findAllByTestId('saved-views-row');
|
||||
fireEvent.mouseEnter(rowNamed(name));
|
||||
return screen.findByTestId(
|
||||
'saved-view-hover-card',
|
||||
undefined,
|
||||
HOVER_CARD_TIMEOUT,
|
||||
);
|
||||
}
|
||||
|
||||
// Resolves once the row's check is back, so its state is final.
|
||||
async function settledRow(testId: string): Promise<HTMLElement> {
|
||||
await waitFor(
|
||||
@@ -98,21 +141,51 @@ describe('SavedViews - AuthZ', () => {
|
||||
expect(screen.queryByTestId('saved-views-search')).toBeNull();
|
||||
});
|
||||
|
||||
it('disables only the views it cannot read', async () => {
|
||||
it('runs no read check per row', async () => {
|
||||
mockSavedViewsApi([errors, slow]);
|
||||
server.use(setupAuthzDeny(buildSavedViewReadPermission(errors.id)));
|
||||
const checks = recordChecks();
|
||||
renderPanel();
|
||||
|
||||
// The menu checks go out after any check the rows made on mount.
|
||||
await openRowMenu('Errors');
|
||||
await settledRow('saved-views-row-menu-item-delete');
|
||||
expect(checks.map((transaction) => transaction.relation)).toContain('delete');
|
||||
expect(readSelectors(checks)).toStrictEqual(['*']);
|
||||
});
|
||||
|
||||
it('opens a view it cannot read, then the header says no access', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
server.use(setupAuthzDeny(buildSavedViewReadPermission(errors.id)));
|
||||
const { history } = renderWithExplorerProviders(
|
||||
<>
|
||||
<SavedViewsHeader
|
||||
source={SavedviewtypesSourceDTO.traces}
|
||||
onOpenViews={jest.fn()}
|
||||
onToggleViews={jest.fn()}
|
||||
/>
|
||||
<SavedViewsPanel
|
||||
source={SavedviewtypesSourceDTO.traces}
|
||||
onClose={jest.fn()}
|
||||
/>
|
||||
</>,
|
||||
explorerUrl(PATH, {}),
|
||||
);
|
||||
|
||||
await screen.findAllByTestId('saved-views-row');
|
||||
const nameOf = (view: string): HTMLElement =>
|
||||
within(rowNamed(view)).getByTestId('saved-views-row-name');
|
||||
const name = within(rowNamed('Errors')).getByTestId('saved-views-row-name');
|
||||
expectUsable(name);
|
||||
await userEvent.click(name);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(nameOf('Errors')).toHaveAttribute('aria-disabled', 'true'),
|
||||
expect(urlParam(history, QueryParams.viewKey)).toBe(
|
||||
JSON.stringify(errors.id),
|
||||
),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(nameOf('Slow spans')).not.toHaveAttribute('aria-busy'),
|
||||
expect(screen.getByTestId('saved-views-name')).toHaveTextContent(
|
||||
'No view access',
|
||||
),
|
||||
);
|
||||
expect(nameOf('Slow spans')).not.toHaveAttribute('aria-disabled', 'true');
|
||||
});
|
||||
|
||||
it('keeps the row menu on a view it cannot read', async () => {
|
||||
@@ -133,6 +206,58 @@ describe('SavedViews - AuthZ', () => {
|
||||
).resolves.not.toHaveAttribute('data-disabled');
|
||||
});
|
||||
|
||||
it('shows the denied callout in the hover card of a view it cannot read, without fetching it', async () => {
|
||||
let fetches = 0;
|
||||
mockSavedViewsApi([errors]);
|
||||
server.use(
|
||||
setupAuthzDeny(
|
||||
SavedViewReadPermission,
|
||||
buildSavedViewReadPermission(errors.id),
|
||||
),
|
||||
rest.get(`${API}/:id`, (_req, res, ctx) => {
|
||||
fetches += 1;
|
||||
return res(ctx.json({ status: 'success', data: errors }));
|
||||
}),
|
||||
);
|
||||
renderPanel();
|
||||
|
||||
const card = await hoverRow('Errors');
|
||||
await expect(
|
||||
within(card).findByText(/not authorized/i),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(within(card).queryByTestId('saved-view-hover-card-query')).toBeNull();
|
||||
expect(fetches).toBe(0);
|
||||
});
|
||||
|
||||
it('skips the read check of the hovered view with read on every view', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
const checks = recordChecks();
|
||||
renderPanel();
|
||||
|
||||
const card = await hoverRow('Errors');
|
||||
|
||||
await expect(
|
||||
within(card).findByTestId('saved-view-hover-card-query'),
|
||||
).resolves.toHaveTextContent('has_error = true');
|
||||
expect(readSelectors(checks)).toStrictEqual(['*']);
|
||||
});
|
||||
|
||||
it('checks read on the hovered view without read on every view', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
const checks = recordChecks(
|
||||
(transaction) =>
|
||||
transaction.relation !== 'read' || transaction.object.selector !== '*',
|
||||
);
|
||||
renderPanel();
|
||||
|
||||
const card = await hoverRow('Errors');
|
||||
|
||||
await expect(
|
||||
within(card).findByTestId('saved-view-hover-card-query'),
|
||||
).resolves.toHaveTextContent('has_error = true');
|
||||
expect(readSelectors(checks)).toStrictEqual(['*', errors.id]);
|
||||
});
|
||||
|
||||
it('disables creating without create', async () => {
|
||||
mockSavedViewsApi([]);
|
||||
server.use(setupAuthzDeny(SavedViewCreatePermission));
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import {
|
||||
fireEvent,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
within,
|
||||
} from 'tests/test-utils';
|
||||
|
||||
import SavedViewsPanel from '../SavedViewsPanel';
|
||||
import { mockSavedViewsApi } from './savedViewsApiMock';
|
||||
@@ -14,6 +19,7 @@ import {
|
||||
renderWithExplorerProviders,
|
||||
urlParam,
|
||||
viewUrl,
|
||||
withGlobalTime,
|
||||
} from './savedViewsTestUtils';
|
||||
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
@@ -53,6 +59,9 @@ const row = (name: string): HTMLElement =>
|
||||
.getAllByTestId('saved-views-row')
|
||||
.find((element) => element.textContent?.includes(name)) as HTMLElement;
|
||||
|
||||
const rowName = (name: string): HTMLElement =>
|
||||
within(row(name)).getByTestId('saved-views-row-name');
|
||||
|
||||
describe('SavedViewsPanel list', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
@@ -85,9 +94,8 @@ describe('SavedViewsPanel list', () => {
|
||||
const { history } = renderPanel();
|
||||
|
||||
await screen.findByTestId('saved-views-list');
|
||||
const name = within(row('Slow spans')).getByTestId('saved-views-row-name');
|
||||
await waitFor(() => expectUsable(name));
|
||||
await userEvent.click(name);
|
||||
await waitFor(() => expectUsable(rowName('Slow spans')));
|
||||
await userEvent.click(rowName('Slow spans'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(urlParam(history, QueryParams.viewKey)).toBe(
|
||||
@@ -96,6 +104,60 @@ describe('SavedViewsPanel list', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('links each row to its view in the current time range', async () => {
|
||||
mockSavedViewsApi([mine, alsoMine]);
|
||||
renderWithExplorerProviders(
|
||||
withGlobalTime(
|
||||
<SavedViewsPanel
|
||||
source={SavedviewtypesSourceDTO.traces}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
{ selectedTime: '1h' },
|
||||
),
|
||||
explorerUrl(PATH, {}),
|
||||
);
|
||||
|
||||
await screen.findByTestId('saved-views-list');
|
||||
const url = new URL(
|
||||
rowName('Slow spans').getAttribute('href') as string,
|
||||
window.location.origin,
|
||||
);
|
||||
|
||||
expect(url.pathname).toBe(PATH);
|
||||
expect(url.searchParams.get(QueryParams.viewKey)).toBe(
|
||||
JSON.stringify(alsoMine.id),
|
||||
);
|
||||
expect(url.searchParams.get(QueryParams.panelTypes)).toBe(
|
||||
JSON.stringify(alsoMine.spec.panelType),
|
||||
);
|
||||
expect(url.searchParams.has(QueryParams.compositeQuery)).toBe(true);
|
||||
expect(url.searchParams.get(QueryParams.relativeTime)).toBe('1h');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['Ctrl', { ctrlKey: true }],
|
||||
['Cmd', { metaKey: true }],
|
||||
['Shift', { shiftKey: true }],
|
||||
])('leaves a %s click on a row to the browser', async (_key, modifiers) => {
|
||||
mockSavedViewsApi([mine, alsoMine]);
|
||||
const { history } = renderPanel();
|
||||
await screen.findByTestId('saved-views-list');
|
||||
const before = history.location.search;
|
||||
|
||||
let defaultPrevented: boolean | undefined;
|
||||
const recordClick = (event: MouseEvent): void => {
|
||||
defaultPrevented = event.defaultPrevented;
|
||||
// jsdom cannot open a new tab.
|
||||
event.preventDefault();
|
||||
};
|
||||
document.addEventListener('click', recordClick);
|
||||
fireEvent.click(rowName('Slow spans'), modifiers);
|
||||
document.removeEventListener('click', recordClick);
|
||||
|
||||
expect(defaultPrevented).toBe(false);
|
||||
expect(history.location.search).toBe(before);
|
||||
});
|
||||
|
||||
it('marks the open view and clears it from its row', async () => {
|
||||
mockSavedViewsApi([mine, alsoMine]);
|
||||
const { history } = renderPanel(viewUrl(PATH, mine));
|
||||
@@ -120,9 +182,8 @@ describe('SavedViewsPanel list', () => {
|
||||
await screen.findByTestId('saved-views-list');
|
||||
const before = history.location.search;
|
||||
|
||||
await userEvent.click(
|
||||
within(row('Errors')).getByTestId('saved-views-row-name'),
|
||||
);
|
||||
expect(rowName('Errors')).toHaveAttribute('aria-current', 'page');
|
||||
await userEvent.click(rowName('Errors'));
|
||||
|
||||
expect(history.location.search).toBe(before);
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { toast } from '@signozhq/ui/sonner';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime';
|
||||
|
||||
import SavedViewsHeader from '../SavedViewsHeader';
|
||||
import SavedViewsPanel from '../SavedViewsPanel';
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
renderWithExplorerProviders,
|
||||
urlParam,
|
||||
viewUrl,
|
||||
withGlobalTime,
|
||||
} from './savedViewsTestUtils';
|
||||
|
||||
const mockCopy = jest.fn();
|
||||
@@ -32,6 +34,8 @@ jest.mock('@signozhq/ui/sonner', () => ({
|
||||
}));
|
||||
|
||||
const PATH = ROUTES.TRACES_EXPLORER;
|
||||
const START_MS = 1700000000000;
|
||||
const END_MS = 1700003600000;
|
||||
|
||||
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
|
||||
const slow = makeView({
|
||||
@@ -119,9 +123,22 @@ describe('SavedViewsPanel row menu', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('copies a link that opens the view with its query and tab', async () => {
|
||||
it('copies a link that opens the view with its query, tab and time range', async () => {
|
||||
mockSavedViewsApi([errors, slow]);
|
||||
renderPanelWithHeader();
|
||||
renderWithExplorerProviders(
|
||||
withGlobalTime(
|
||||
<SavedViewsPanel
|
||||
source={SavedviewtypesSourceDTO.traces}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
{
|
||||
selectedTime: 'custom',
|
||||
minTime: START_MS * NANO_SECOND_MULTIPLIER,
|
||||
maxTime: END_MS * NANO_SECOND_MULTIPLIER,
|
||||
},
|
||||
),
|
||||
explorerUrl(PATH, {}),
|
||||
);
|
||||
|
||||
await chooseFromRowMenu('Slow spans', 'Copy link');
|
||||
|
||||
@@ -137,6 +154,9 @@ describe('SavedViewsPanel row menu', () => {
|
||||
expect(
|
||||
decodeURIComponent(link.searchParams.get(QueryParams.compositeQuery) ?? ''),
|
||||
).toContain('duration_nano > 1000');
|
||||
expect(link.searchParams.get(QueryParams.startTime)).toBe(String(START_MS));
|
||||
expect(link.searchParams.get(QueryParams.endTime)).toBe(String(END_MS));
|
||||
expect(link.searchParams.has(QueryParams.relativeTime)).toBe(false);
|
||||
expect(toast.success).toHaveBeenCalledWith('Link copied', expect.anything());
|
||||
});
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ import { TestRouter } from 'tests/router';
|
||||
import { getAppContextMock } from 'tests/test-utils';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { __setSearchParamsGetterForTest } from 'utils/getUnstableCurrentSearchParams';
|
||||
|
||||
import { getSavedViewQuery } from '../utils/getSavedViewQuery';
|
||||
@@ -160,6 +161,23 @@ export function viewUrl(
|
||||
|
||||
const mockStore = configureStore([thunk]);
|
||||
|
||||
export function withGlobalTime(
|
||||
ui: ReactElement,
|
||||
globalTime: Partial<GlobalReducer>,
|
||||
): ReactElement {
|
||||
const state = store.getState();
|
||||
return (
|
||||
<Provider
|
||||
store={mockStore({
|
||||
...state,
|
||||
globalTime: { ...state.globalTime, ...globalTime },
|
||||
})}
|
||||
>
|
||||
{ui}
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// What the explorer page does around the header: writes its default query to a
|
||||
// bare url, and sets the provider's tab from the url (the provider alone seeds
|
||||
// it from the raw, still JSON-quoted param).
|
||||
|
||||
@@ -12,11 +12,10 @@ import {
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { ErrorType } from 'api/generatedAPIInstance';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { buildExplorerNavigationUrl } from 'container/AIAssistant/components/ActionsSection/utils/openSavedView';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { getAbsoluteUrl } from 'utils/basePath';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
import { useTimeSearchParams } from 'hooks/useTimeSearchParams';
|
||||
|
||||
import {
|
||||
SAVED_VIEW_SCHEMA_VERSION,
|
||||
@@ -24,7 +23,7 @@ import {
|
||||
} from '../constants';
|
||||
import { SavedViewEventKeys, SavedViewEvents } from '../events';
|
||||
import { UseSavedViewRowActionsResult } from '../types';
|
||||
import { getSavedViewQuery } from '../utils/getSavedViewQuery';
|
||||
import { getSavedViewPath } from '../utils/getSavedViewPath';
|
||||
import { invalidateSavedViews } from '../utils/invalidateSavedViews';
|
||||
import { useSavedViewLogEvent } from './useSavedViewLogEvent';
|
||||
|
||||
@@ -43,6 +42,7 @@ export function useSavedViewRowActions(
|
||||
): UseSavedViewRowActionsResult {
|
||||
const queryClient = useQueryClient();
|
||||
const { pathname } = useAppLocation();
|
||||
const timeSearch = useTimeSearchParams();
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
const logSavedViewEvent = useSavedViewLogEvent(source);
|
||||
const { mutateAsync: updateSavedView, isLoading: isRenaming } =
|
||||
@@ -52,17 +52,15 @@ export function useSavedViewRowActions(
|
||||
|
||||
const copyViewLink = useCallback(
|
||||
(view: SavedviewtypesSavedViewDTO): void => {
|
||||
const path = buildExplorerNavigationUrl(pathname, getSavedViewQuery(view), {
|
||||
[QueryParams.panelTypes]: view.spec.panelType,
|
||||
[QueryParams.viewKey]: view.id,
|
||||
});
|
||||
copyToClipboard(getAbsoluteUrl(path));
|
||||
copyToClipboard(
|
||||
getAbsoluteUrl(getSavedViewPath(pathname, view, timeSearch)),
|
||||
);
|
||||
toast.success('Link copied', { position: SAVED_VIEW_TOAST_POSITION });
|
||||
logSavedViewEvent(SavedViewEvents.LinkCopied, {
|
||||
[SavedViewEventKeys.ViewId]: view.id,
|
||||
});
|
||||
},
|
||||
[pathname, copyToClipboard, logSavedViewEvent],
|
||||
[pathname, timeSearch, copyToClipboard, logSavedViewEvent],
|
||||
);
|
||||
|
||||
const copyViewId = useCallback(
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
savedViewsListResponse,
|
||||
type SavedViewSource,
|
||||
unreadableViewId,
|
||||
withLongName,
|
||||
} from './__story_mockdata__/savedViews';
|
||||
|
||||
import SavedViewsHeader from '../SavedViewsHeader';
|
||||
@@ -73,7 +74,13 @@ const controls = {
|
||||
unreadableView: toggleControl('Unreadable view', {
|
||||
group: VIEW,
|
||||
description:
|
||||
'Denies `read` on the first view created by others alone, on top of the Access controls: its row greys out, and with Open view on `created-by-others` the header shows No view access.',
|
||||
'Denies `read` on the first view created by others, and so on `saved-view:*`, on top of the Access controls: its row still opens it, and the header then shows No view access, as with Open view on `created-by-others`. Its hover card shows the permission callout in place of the query.',
|
||||
value: false,
|
||||
}),
|
||||
viewPending: toggleControl('View fetch pending', {
|
||||
group: VIEW,
|
||||
description:
|
||||
'Holds every `GET /saved_views/:id` open, so a hover card stays on its loading skeleton.',
|
||||
value: false,
|
||||
}),
|
||||
createdByMe: countControl('Created by me', {
|
||||
@@ -87,6 +94,12 @@ const controls = {
|
||||
value: 4,
|
||||
max: SAVED_VIEWS_MAX,
|
||||
}),
|
||||
longNames: toggleControl('Long names', {
|
||||
group: LIST,
|
||||
description:
|
||||
'Lengthens every view name past the sidebar width, so rows and the open view name truncate.',
|
||||
value: false,
|
||||
}),
|
||||
};
|
||||
|
||||
type SavedViewsValues = MockControlValues<typeof controls>;
|
||||
@@ -99,7 +112,7 @@ const viewDenied = (
|
||||
): boolean =>
|
||||
transaction.relation === 'read' &&
|
||||
transaction.object.resource.kind === 'saved-view' &&
|
||||
transaction.object.selector === deniedId;
|
||||
[deniedId, '*'].includes(transaction.object.selector);
|
||||
|
||||
const definition = defineStoryMocks({
|
||||
controls,
|
||||
@@ -107,7 +120,14 @@ const definition = defineStoryMocks({
|
||||
values: SavedViewsValues & AuthzArgs,
|
||||
response,
|
||||
): RequestHandler[] => {
|
||||
const { source, openView, createdByMe, createdByOthers } = values;
|
||||
const {
|
||||
source,
|
||||
openView,
|
||||
viewPending,
|
||||
createdByMe,
|
||||
createdByOthers,
|
||||
longNames,
|
||||
} = values;
|
||||
|
||||
// What the panel writes stays in this handler set until a control changes.
|
||||
const created: SavedviewtypesSavedViewDTO[] = [];
|
||||
@@ -118,8 +138,14 @@ const definition = defineStoryMocks({
|
||||
view: SavedviewtypesSavedViewDTO,
|
||||
): SavedviewtypesSavedViewDTO => ({ ...view, ...updated.get(view.id) });
|
||||
|
||||
const named = (
|
||||
view: SavedviewtypesSavedViewDTO,
|
||||
): SavedviewtypesSavedViewDTO => (longNames ? withLongName(view) : view);
|
||||
|
||||
const findView = (id: string): SavedviewtypesSavedViewDTO | undefined => {
|
||||
const view = created.find((item) => item.id === id) ?? savedViewById(id);
|
||||
const stored = savedViewById(id);
|
||||
const view =
|
||||
created.find((item) => item.id === id) ?? (stored && named(stored));
|
||||
|
||||
return view && !deleted.has(id) ? current(view) : undefined;
|
||||
};
|
||||
@@ -143,7 +169,7 @@ const definition = defineStoryMocks({
|
||||
|
||||
return {
|
||||
...list,
|
||||
data: [...(list.data ?? []), ...created]
|
||||
data: [...(list.data ?? []).map(named), ...created]
|
||||
.filter((view) => !deleted.has(view.id))
|
||||
.map(current),
|
||||
};
|
||||
@@ -151,6 +177,10 @@ const definition = defineStoryMocks({
|
||||
),
|
||||
|
||||
rest.get(`${API}/:id`, (req, res, ctx) => {
|
||||
if (viewPending) {
|
||||
return res(ctx.delay('infinite'));
|
||||
}
|
||||
|
||||
if (openError) {
|
||||
return res(ctx.status(openError.status), ctx.json(openError.body));
|
||||
}
|
||||
@@ -274,7 +304,9 @@ const isPending = (element: HTMLElement): boolean =>
|
||||
element.hasAttribute('data-loading');
|
||||
|
||||
const isDisabled = (element: HTMLElement): boolean =>
|
||||
element.hasAttribute('disabled') || element.hasAttribute('data-disabled');
|
||||
element.hasAttribute('disabled') ||
|
||||
element.hasAttribute('data-disabled') ||
|
||||
element.getAttribute('aria-disabled') === 'true';
|
||||
|
||||
export const expectDisabled = (
|
||||
find: FindElement,
|
||||
@@ -302,6 +334,30 @@ export const rowName =
|
||||
(): HTMLElement =>
|
||||
within(row).getByTestId('saved-views-row-name');
|
||||
|
||||
const hoverRow = async (row: HTMLElement): Promise<HTMLElement> => {
|
||||
await userEvent.hover(row);
|
||||
|
||||
return screen.findByTestId('saved-view-hover-card', undefined, UNTIL_LOADED);
|
||||
};
|
||||
|
||||
const searchViews = async (
|
||||
canvasElement: HTMLElement,
|
||||
text: string,
|
||||
): Promise<void> => {
|
||||
const search = await within(canvasElement).findByTestId(
|
||||
'saved-views-search',
|
||||
undefined,
|
||||
UNTIL_LOADED,
|
||||
);
|
||||
|
||||
await userEvent.type(search, text);
|
||||
};
|
||||
|
||||
const clickWhenEnabled = async (find: FindElement): Promise<void> => {
|
||||
await expectDisabled(find, false);
|
||||
await userEvent.click(find());
|
||||
};
|
||||
|
||||
export const openRowMenu = async (row: HTMLElement): Promise<void> => {
|
||||
await userEvent.click(within(row).getByTestId('saved-views-row-menu'));
|
||||
await screen.findByRole('menu', undefined, UNTIL_LOADED);
|
||||
@@ -327,3 +383,115 @@ export const openSaveChangesMenu = async (
|
||||
|
||||
export const saveChangesItem = (key: 'save-as-new' | 'update'): FindElement =>
|
||||
byTestId(`saved-views-save-changes-item-${key}`);
|
||||
|
||||
type PlayContext = {
|
||||
canvasElement: HTMLElement;
|
||||
args: { source: SavedViewSource };
|
||||
};
|
||||
|
||||
export const hoverFirstRow = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
const [row] = await findRows(canvasElement);
|
||||
const card = await hoverRow(row);
|
||||
|
||||
await within(card).findByTestId(
|
||||
'saved-view-hover-card-query',
|
||||
undefined,
|
||||
UNTIL_LOADED,
|
||||
);
|
||||
};
|
||||
|
||||
export const hoverFirstRowLoading = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
const [row] = await findRows(canvasElement);
|
||||
const card = await hoverRow(row);
|
||||
|
||||
await within(card).findByTestId(
|
||||
'saved-view-hover-card-loading',
|
||||
undefined,
|
||||
UNTIL_LOADED,
|
||||
);
|
||||
};
|
||||
|
||||
export const searchWithNoResults = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await searchViews(canvasElement, 'nothing like this');
|
||||
await screen.findByTestId('saved-views-list-no-results');
|
||||
};
|
||||
|
||||
export const openCreateModal = async (): Promise<void> => {
|
||||
await clickWhenEnabled(byTestId('saved-views-create'));
|
||||
await screen.findByTestId('save-view-modal');
|
||||
};
|
||||
|
||||
export const openEditDetailsModal = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
const [row] = await findRows(canvasElement);
|
||||
|
||||
await openRowMenu(row);
|
||||
await clickWhenEnabled(rowMenuItem('editDetails'));
|
||||
await screen.findByTestId('save-view-modal');
|
||||
};
|
||||
|
||||
export const openSaveAsNewModal = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await openSaveChangesMenu(canvasElement);
|
||||
await clickWhenEnabled(saveChangesItem('save-as-new'));
|
||||
await screen.findByTestId('save-view-modal');
|
||||
};
|
||||
|
||||
export const openDeleteDialog = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
const [row] = await findRows(canvasElement);
|
||||
|
||||
await openRowMenu(row);
|
||||
await clickWhenEnabled(rowMenuItem('delete'));
|
||||
await screen.findByTestId('delete-saved-view-confirm');
|
||||
};
|
||||
|
||||
const findUnreadableRow = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<HTMLElement> => {
|
||||
const rows = await findRows(canvasElement);
|
||||
const deniedId = unreadableViewId(args.source);
|
||||
|
||||
return rows.find(
|
||||
(row) => row.getAttribute('data-view-id') === deniedId,
|
||||
) as HTMLElement;
|
||||
};
|
||||
|
||||
export const hoverUnreadableRow = async (
|
||||
context: PlayContext,
|
||||
): Promise<void> => {
|
||||
const card = await hoverRow(await findUnreadableRow(context));
|
||||
|
||||
await expect(
|
||||
await within(card).findByText(/not authorized/i, undefined, UNTIL_LOADED),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
within(card).queryByTestId('saved-view-hover-card-query'),
|
||||
).toBeNull();
|
||||
};
|
||||
|
||||
export const openUnreadableView = async (
|
||||
context: PlayContext,
|
||||
): Promise<void> => {
|
||||
const { canvasElement } = context;
|
||||
const denied = await findUnreadableRow(context);
|
||||
|
||||
await userEvent.click(rowName(denied)());
|
||||
await expect(
|
||||
await within(canvasElement).findByText(
|
||||
'No view access',
|
||||
undefined,
|
||||
UNTIL_LOADED,
|
||||
),
|
||||
).toBeVisible();
|
||||
};
|
||||
|
||||
@@ -6,10 +6,17 @@ import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
import SavedViewsPanel from '../SavedViewsPanel';
|
||||
import {
|
||||
findRows,
|
||||
hoverFirstRow,
|
||||
hoverFirstRowLoading,
|
||||
openCreateModal,
|
||||
openDeleteDialog,
|
||||
openEditDetailsModal,
|
||||
openRowMenu,
|
||||
openSaveAsNewModal,
|
||||
openSaveChangesMenu,
|
||||
SavedViewsSidebar,
|
||||
savedViewsMocks,
|
||||
searchWithNoResults,
|
||||
sidebarCanvas,
|
||||
} from './SavedViews.stories.mocks';
|
||||
|
||||
@@ -78,3 +85,48 @@ export const SaveChangesMenu: Story = {
|
||||
await openSaveChangesMenu(canvasElement);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Views whose names run past the sidebar, with one of them open: each row and
|
||||
* the open view name truncate.
|
||||
*/
|
||||
export const LongNames: Story = {
|
||||
args: { longNames: true, openView: 'created-by-me' },
|
||||
};
|
||||
|
||||
/** The hover card of the first row: its query, who updated it and when. */
|
||||
export const HoverCard: Story = {
|
||||
play: hoverFirstRow,
|
||||
};
|
||||
|
||||
/** The hover card while its view is still loading: a skeleton in place of the query. */
|
||||
export const HoverCardLoading: Story = {
|
||||
args: { viewPending: true },
|
||||
play: hoverFirstRowLoading,
|
||||
};
|
||||
|
||||
/** A search that matches no view: the list says so in place of the rows. */
|
||||
export const SearchNoResults: Story = {
|
||||
play: searchWithNoResults,
|
||||
};
|
||||
|
||||
/** The + in the header opens Save view for the current query. */
|
||||
export const CreateModal: Story = {
|
||||
play: openCreateModal,
|
||||
};
|
||||
|
||||
/** Edit details from a row's menu: the modal starts on the view's name. */
|
||||
export const EditDetailsModal: Story = {
|
||||
play: openEditDetailsModal,
|
||||
};
|
||||
|
||||
/** Save as new view from the ✓ menu on unsaved changes. */
|
||||
export const SaveAsNewModal: Story = {
|
||||
args: { openView: 'unsaved-changes' },
|
||||
play: openSaveAsNewModal,
|
||||
};
|
||||
|
||||
/** Delete from a row's menu asks to confirm before the view goes. */
|
||||
export const DeleteDialog: Story = {
|
||||
play: openDeleteDialog,
|
||||
};
|
||||
|
||||
@@ -209,6 +209,18 @@ export const createdSavedView = (
|
||||
spec,
|
||||
});
|
||||
|
||||
const LONG_NAME_SUFFIX = ', every environment and region, last 30 days';
|
||||
|
||||
export const withLongName = (
|
||||
view: SavedviewtypesSavedViewDTO,
|
||||
): SavedviewtypesSavedViewDTO => ({
|
||||
...view,
|
||||
spec: {
|
||||
...view.spec,
|
||||
displayName: `${view.spec.displayName}${LONG_NAME_SUFFIX}`,
|
||||
},
|
||||
});
|
||||
|
||||
export const savedViewResponse = (
|
||||
view: SavedviewtypesSavedViewDTO,
|
||||
): GetSavedView200 => ({ status: 'success', data: view });
|
||||
|
||||
@@ -4,14 +4,15 @@ import { expect, waitFor, within } from 'storybook/test';
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { unreadableViewId } from '../__story_mockdata__/savedViews';
|
||||
import {
|
||||
byTestId,
|
||||
expectDisabled,
|
||||
expectPending,
|
||||
findRows,
|
||||
hoverUnreadableRow,
|
||||
openRowMenu,
|
||||
openSaveChangesMenu,
|
||||
openUnreadableView,
|
||||
rowMenuItem,
|
||||
rowName,
|
||||
saveChangesItem,
|
||||
@@ -99,21 +100,18 @@ export const NoCreateSaveMenu: Story = {
|
||||
};
|
||||
|
||||
/**
|
||||
* `read` denied on one view made by someone else: its name is disabled and its
|
||||
* hover card never opens, while every other view still opens.
|
||||
* `read` denied on one view made by someone else: the list checks no `read`, so
|
||||
* its row opens it like any other, and the header then says No view access.
|
||||
*/
|
||||
export const NoRead: Story = {
|
||||
args: { unreadableView: true },
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const rows = await findRows(canvasElement);
|
||||
const deniedId = unreadableViewId(args.source);
|
||||
play: openUnreadableView,
|
||||
};
|
||||
|
||||
await Promise.all(
|
||||
rows.map((row) =>
|
||||
expectDisabled(rowName(row), row.getAttribute('data-view-id') === deniedId),
|
||||
),
|
||||
);
|
||||
},
|
||||
/** The hover card of the unreadable view: the permission callout in place of its query. */
|
||||
export const NoReadHoverCard: Story = {
|
||||
args: { unreadableView: true },
|
||||
play: hoverUnreadableRow,
|
||||
};
|
||||
|
||||
/** The unreadable view open from its link: the header names it No view access. */
|
||||
|
||||
@@ -128,6 +128,11 @@ export interface SavedViewsListRowProps {
|
||||
hover: SavedViewsListRowHoverProps;
|
||||
}
|
||||
|
||||
export type SavedViewsRowLinkProps = Pick<
|
||||
SavedViewsListRowProps,
|
||||
'view' | 'isActive' | 'onSelect'
|
||||
>;
|
||||
|
||||
export type SavedViewRowActionKey =
|
||||
| 'copyLink'
|
||||
| 'copyId'
|
||||
@@ -181,7 +186,7 @@ export interface UseSavedViewRowActionsResult {
|
||||
}
|
||||
|
||||
export interface SavedViewHoverCardTarget {
|
||||
view: SavedviewtypesSavedViewDTO;
|
||||
viewId: string;
|
||||
anchorTop: number;
|
||||
anchorHeight: number;
|
||||
}
|
||||
@@ -202,8 +207,14 @@ export interface SavedViewHoverCardProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export interface SavedViewHoverCardDetailsProps {
|
||||
viewId: string;
|
||||
/** `read` is granted on every view, so the view needs no check of its own. */
|
||||
canReadAll: boolean;
|
||||
}
|
||||
|
||||
export interface SavedViewsListRowHoverProps {
|
||||
onRowEnter: (view: SavedviewtypesSavedViewDTO, row: HTMLElement) => void;
|
||||
onRowEnter: (viewId: string, row: HTMLElement) => void;
|
||||
onRowLeave: () => void;
|
||||
onMenuOpen: () => void;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
|
||||
import { makeView } from '../../__tests__/savedViewsTestUtils';
|
||||
import { getSavedViewPath } from '../getSavedViewPath';
|
||||
|
||||
const view = makeView({ id: 'view-1', displayName: 'Errors' });
|
||||
|
||||
function paramsOf(path: string): URLSearchParams {
|
||||
return new URL(path, window.location.origin).searchParams;
|
||||
}
|
||||
|
||||
describe('getSavedViewPath', () => {
|
||||
it('opens the view on the given explorer in the given time range', () => {
|
||||
const path = getSavedViewPath(
|
||||
ROUTES.TRACES_EXPLORER,
|
||||
view,
|
||||
'startTime=1700000000000&endTime=1700003600000',
|
||||
);
|
||||
const params = paramsOf(path);
|
||||
|
||||
expect(path.startsWith(`${ROUTES.TRACES_EXPLORER}?`)).toBe(true);
|
||||
expect(params.get(QueryParams.viewKey)).toBe(JSON.stringify(view.id));
|
||||
expect(params.get(QueryParams.startTime)).toBe('1700000000000');
|
||||
expect(params.get(QueryParams.endTime)).toBe('1700003600000');
|
||||
});
|
||||
|
||||
it('adds no time range when there is none', () => {
|
||||
const path = getSavedViewPath(ROUTES.TRACES_EXPLORER, view, '');
|
||||
|
||||
expect(path.endsWith('&')).toBe(false);
|
||||
expect(paramsOf(path).has(QueryParams.relativeTime)).toBe(false);
|
||||
});
|
||||
});
|
||||
17
frontend/src/container/SavedViews/utils/getSavedViewPath.ts
Normal file
17
frontend/src/container/SavedViews/utils/getSavedViewPath.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { SavedviewtypesSavedViewDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { buildExplorerNavigationUrl } from 'utils/buildExplorerNavigationUrl';
|
||||
|
||||
import { getSavedViewQuery } from './getSavedViewQuery';
|
||||
|
||||
export function getSavedViewPath(
|
||||
pathname: string,
|
||||
view: SavedviewtypesSavedViewDTO,
|
||||
timeSearch: string,
|
||||
): string {
|
||||
const path = buildExplorerNavigationUrl(pathname, getSavedViewQuery(view), {
|
||||
[QueryParams.panelTypes]: view.spec.panelType,
|
||||
[QueryParams.viewKey]: view.id,
|
||||
});
|
||||
return timeSearch ? `${path}&${timeSearch}` : path;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime';
|
||||
|
||||
import { useTimeSearchParams } from '../useTimeSearchParams';
|
||||
import { useTimeSearchParams } from 'hooks/useTimeSearchParams';
|
||||
|
||||
let mockGlobalTime = {
|
||||
selectedTime: '30m',
|
||||
@@ -12,12 +12,13 @@ import {
|
||||
IOption,
|
||||
IResourceAttribute,
|
||||
IResourceAttributeProps,
|
||||
OperatorValues,
|
||||
Tags,
|
||||
} from 'hooks/useResourceAttribute/types';
|
||||
import { decode } from 'js-base64';
|
||||
import { getCurrentLocation } from 'lib/router/navigation';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { OperatorValues, Tags } from 'hooks/useResourceAttribute/types';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { whilelistedKeys } from './config';
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useSelector } from 'react-redux';
|
||||
import { AppState } from 'store/reducers';
|
||||
import type { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import { timeParamsFromGlobalTime } from '../utils/timeUrlParams';
|
||||
import { timeParamsFromGlobalTime } from 'utils/timeUrlParams';
|
||||
|
||||
/** Active time window as a query string (no leading `?`), or `''` when unset. */
|
||||
export function useTimeSearchParams(): string {
|
||||
@@ -6,6 +6,7 @@ export const SavedViewCreatePermission = buildPermission(
|
||||
'create',
|
||||
'saved-view:*',
|
||||
);
|
||||
export const SavedViewReadPermission = buildPermission('read', 'saved-view:*');
|
||||
|
||||
export const buildSavedViewReadPermission = (id: string): BrandedPermission =>
|
||||
buildPermission('read', `saved-view:${id}`);
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import type { PanelEditorHandoffState } from '../PanelEditor/panelEditorHandoff';
|
||||
import { getPanelBuilderQuery } from '../Panels/utils/getPanelBuilderQuery';
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
import { useTimeSearchParams } from './useTimeSearchParams';
|
||||
import { useTimeSearchParams } from 'hooks/useTimeSearchParams';
|
||||
import logEvent from '@/api/common/logEvent';
|
||||
import { DashboardDetailEvents } from '../../constants/events';
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
parseNewPanelTarget,
|
||||
} from '../DashboardContainer/PanelEditor/newPanelRoute';
|
||||
import { useSyncVariablesForSuggestions } from '../DashboardContainer/hooks/useSyncVariablesForSuggestions';
|
||||
import { useTimeSearchParams } from '../DashboardContainer/hooks/useTimeSearchParams';
|
||||
import { useTimeSearchParams } from 'hooks/useTimeSearchParams';
|
||||
import { createDefaultPanel } from '../DashboardContainer/patchOps';
|
||||
import { useDashboardStore } from '../DashboardContainer/store/useDashboardStore';
|
||||
import { useSeedVariableSelection } from '../DashboardContainer/VariablesBar/hooks/useSeedVariableSelection';
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import ROUTES from 'constants/routes';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
|
||||
import { buildExplorerNavigationUrl } from '../buildExplorerNavigationUrl';
|
||||
|
||||
describe('buildExplorerNavigationUrl', () => {
|
||||
it('encodes composite query and view selectors', () => {
|
||||
const url = buildExplorerNavigationUrl(
|
||||
ROUTES.LOGS_EXPLORER,
|
||||
{ queryType: 'builder' } as never,
|
||||
{
|
||||
[QueryParams.panelTypes]: PANEL_TYPES.LIST,
|
||||
[QueryParams.viewKey]: 'view-1',
|
||||
},
|
||||
);
|
||||
|
||||
expect(url).toContain(ROUTES.LOGS_EXPLORER);
|
||||
expect(url).toContain(`${QueryParams.compositeQuery}=`);
|
||||
expect(url).toContain(`${QueryParams.viewKey}=`);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime';
|
||||
|
||||
import { timeParamsFromGlobalTime } from '../timeUrlParams';
|
||||
import { timeParamsFromGlobalTime } from 'utils/timeUrlParams';
|
||||
|
||||
describe('timeParamsFromGlobalTime', () => {
|
||||
it('emits relativeTime for a relative selection', () => {
|
||||
22
frontend/src/utils/buildExplorerNavigationUrl.ts
Normal file
22
frontend/src/utils/buildExplorerNavigationUrl.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
/**
|
||||
* Builds an explorer URL the same way `redirectWithQueryBuilderData` does —
|
||||
* without inheriting stale query params from the current page's `urlQuery`.
|
||||
*/
|
||||
export function buildExplorerNavigationUrl(
|
||||
route: string,
|
||||
query: Query,
|
||||
searchParams: Record<string, unknown>,
|
||||
): string {
|
||||
const params = new URLSearchParams();
|
||||
params.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(query)),
|
||||
);
|
||||
Object.entries(searchParams).forEach(([key, value]) => {
|
||||
params.set(key, JSON.stringify(value));
|
||||
});
|
||||
return `${route}?${params.toString()}`;
|
||||
}
|
||||
51
pkg/apiserver/signozapiserver/annotations.go
Normal file
51
pkg/apiserver/signozapiserver/annotations.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package signozapiserver
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/types/annotationtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func (provider *provider) addAnnotationsRoutes(router *mux.Router) error {
|
||||
if err := router.Handle("/api/v1/annotations", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.annotationsHandler.ListAnnotations, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "ListAnnotations",
|
||||
Tags: []string{"annotations"},
|
||||
Summary: "List annotations",
|
||||
Description: "Returns annotation markers of the requested kind inside the time range. For kind 'deployment', a marker is the first time a (service, environment, version) tuple was seen in the requested signals (traces, logs, or both when omitted). Each record carries the signal that produced it, the first seen time in epoch milliseconds, the trace and span ids or the log id of the first row, and the resource attributes of that row minus the identity keys. Supports an optional resource-attribute filter expression. historyAvailableFrom is the earliest time the result can be trusted, bounded by the signal retention.",
|
||||
Request: new(annotationtypes.PostableAnnotations),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(annotationtypes.Annotations),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{
|
||||
coretypes.ResourceTelemetryResourceTraces.Scope(coretypes.VerbRead),
|
||||
coretypes.ResourceTelemetryResourceLogs.Scope(coretypes.VerbRead),
|
||||
}),
|
||||
}, handler.WithResourceDefs(
|
||||
handler.TelemetryResourceDef{
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: querybuilder.TelemetrySelector,
|
||||
Resources: querybuilder.BuilderQueryWithFilterExpressionResources(coretypes.ResourceTelemetryResourceTraces, "filter.expression"),
|
||||
},
|
||||
handler.TelemetryResourceDef{
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: querybuilder.TelemetrySelector,
|
||||
Resources: querybuilder.BuilderQueryWithFilterExpressionResources(coretypes.ResourceTelemetryResourceLogs, "filter.expression"),
|
||||
},
|
||||
))).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/identn"
|
||||
"github.com/SigNoz/signoz/pkg/licensing"
|
||||
"github.com/SigNoz/signoz/pkg/modules/aiobservability"
|
||||
"github.com/SigNoz/signoz/pkg/modules/annotations"
|
||||
"github.com/SigNoz/signoz/pkg/modules/authdomain"
|
||||
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
@@ -73,6 +74,7 @@ type provider struct {
|
||||
metricsExplorerHandler metricsexplorer.Handler
|
||||
metricReductionRuleHandler metricreductionrule.Handler
|
||||
infraMonitoringHandler inframonitoring.Handler
|
||||
annotationsHandler annotations.Handler
|
||||
gatewayHandler gateway.Handler
|
||||
gatewayService gateway.Gateway
|
||||
fieldsHandler fields.Handler
|
||||
@@ -119,6 +121,7 @@ func NewFactory(
|
||||
metricsExplorerHandler metricsexplorer.Handler,
|
||||
metricReductionRuleHandler metricreductionrule.Handler,
|
||||
infraMonitoringHandler inframonitoring.Handler,
|
||||
annotationsHandler annotations.Handler,
|
||||
gatewayHandler gateway.Handler,
|
||||
gatewayService gateway.Gateway,
|
||||
fieldsHandler fields.Handler,
|
||||
@@ -173,6 +176,7 @@ func NewFactory(
|
||||
metricsExplorerHandler,
|
||||
metricReductionRuleHandler,
|
||||
infraMonitoringHandler,
|
||||
annotationsHandler,
|
||||
gatewayHandler,
|
||||
gatewayService,
|
||||
fieldsHandler,
|
||||
@@ -229,6 +233,7 @@ func newProvider(
|
||||
metricsExplorerHandler metricsexplorer.Handler,
|
||||
metricReductionRuleHandler metricreductionrule.Handler,
|
||||
infraMonitoringHandler inframonitoring.Handler,
|
||||
annotationsHandler annotations.Handler,
|
||||
gatewayHandler gateway.Handler,
|
||||
gatewayService gateway.Gateway,
|
||||
fieldsHandler fields.Handler,
|
||||
@@ -285,6 +290,7 @@ func newProvider(
|
||||
metricsExplorerHandler: metricsExplorerHandler,
|
||||
metricReductionRuleHandler: metricReductionRuleHandler,
|
||||
infraMonitoringHandler: infraMonitoringHandler,
|
||||
annotationsHandler: annotationsHandler,
|
||||
gatewayHandler: gatewayHandler,
|
||||
gatewayService: gatewayService,
|
||||
fieldsHandler: fieldsHandler,
|
||||
@@ -431,6 +437,10 @@ func (provider *provider) AddToRouter(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := provider.addAnnotationsRoutes(router); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := provider.addGatewayRoutes(router); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
19
pkg/modules/annotations/annotations.go
Normal file
19
pkg/modules/annotations/annotations.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package annotations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/statsreporter"
|
||||
"github.com/SigNoz/signoz/pkg/types/annotationtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type Handler interface {
|
||||
ListAnnotations(http.ResponseWriter, *http.Request)
|
||||
}
|
||||
|
||||
type Module interface {
|
||||
statsreporter.StatsCollector
|
||||
ListAnnotations(ctx context.Context, orgID valuer.UUID, req *annotationtypes.PostableAnnotations) (*annotationtypes.Annotations, error)
|
||||
}
|
||||
27
pkg/modules/annotations/implannotations/collect.go
Normal file
27
pkg/modules/annotations/implannotations/collect.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package implannotations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
func (m *module) Collect(ctx context.Context, _ valuer.UUID) (map[string]any, error) {
|
||||
stats := make(map[string]any)
|
||||
|
||||
query := fmt.Sprintf(
|
||||
"SELECT (SELECT count() FROM (SELECT 1 FROM %s.%s WHERE labels LIKE '%%\"service.version\"%%' LIMIT 1)), (SELECT count() FROM (SELECT 1 FROM %s.%s WHERE labels LIKE '%%\"service.version\"%%' LIMIT 1))",
|
||||
tracesTables.dbName, tracesTables.resourceTable, logsTables.dbName, logsTables.resourceTable,
|
||||
)
|
||||
var tracesCount, logsCount uint64
|
||||
if err := m.telemetryStore.ClickhouseDB().QueryRow(ctx, query).Scan(&tracesCount, &logsCount); err == nil {
|
||||
stats["telemetry.traces.service_version.exists"] = tracesCount > 0
|
||||
stats["telemetry.logs.service_version.exists"] = logsCount > 0
|
||||
} else {
|
||||
m.logger.DebugContext(ctx, "failed to collect service.version existence stats", errors.Attr(err))
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
263
pkg/modules/annotations/implannotations/deployments.go
Normal file
263
pkg/modules/annotations/implannotations/deployments.go
Normal file
@@ -0,0 +1,263 @@
|
||||
package implannotations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/logstelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/tracestelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/types/annotationtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
)
|
||||
|
||||
const resourceBucketSeconds = 1800
|
||||
|
||||
type signalTables struct {
|
||||
signal telemetrytypes.Signal
|
||||
dbName string
|
||||
resourceTable string
|
||||
dataTable string
|
||||
rawAlias string
|
||||
}
|
||||
|
||||
var (
|
||||
tracesTables = signalTables{
|
||||
signal: telemetrytypes.SignalTraces,
|
||||
dbName: tracestelemetryschema.DBName,
|
||||
resourceTable: tracestelemetryschema.TracesResourceV3TableName,
|
||||
dataTable: tracestelemetryschema.SpanIndexV3TableName,
|
||||
rawAlias: "raw_traces",
|
||||
}
|
||||
logsTables = signalTables{
|
||||
signal: telemetrytypes.SignalLogs,
|
||||
dbName: logstelemetryschema.DBName,
|
||||
resourceTable: logstelemetryschema.LogsResourceV2TableName,
|
||||
dataTable: logstelemetryschema.LogsV2TableName,
|
||||
rawAlias: "raw_logs",
|
||||
}
|
||||
)
|
||||
|
||||
// queryTracesDeployments returns the marker rows and historyAvailableFrom in ms, 0 when there is no trace history.
|
||||
func (m *module) queryTracesDeployments(ctx context.Context, orgID valuer.UUID, req *annotationtypes.PostableAnnotations) ([]annotationtypes.DeploymentRow, int64, error) {
|
||||
ctx = m.withAnnotationsContext(ctx, telemetrytypes.SignalTraces, "ListAnnotations")
|
||||
|
||||
filterClause, err := m.buildFilterClause(ctx, orgID, telemetrytypes.SignalTraces, req.Filter, req.Start, req.End)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
rBucket, err := m.readHistoryEdge(ctx, tracesTables)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if rBucket == 0 {
|
||||
return []annotationtypes.DeploymentRow{}, 0, nil
|
||||
}
|
||||
|
||||
query, args := buildTracesDeploymentsQuery(filterClause, req.Start, req.End, rBucket)
|
||||
rows, err := m.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, 0, errors.WrapInternalf(err, errors.CodeInternal, "failed to query traces deployments")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := []annotationtypes.DeploymentRow{}
|
||||
for rows.Next() {
|
||||
var (
|
||||
row = annotationtypes.DeploymentRow{Signal: telemetrytypes.SignalTraces}
|
||||
firstSeen time.Time
|
||||
firstIDs []string
|
||||
)
|
||||
if err := rows.Scan(&row.Service, &row.Environment, &row.Version, &firstSeen, &firstIDs, &row.Labels); err != nil {
|
||||
return nil, 0, errors.WrapInternalf(err, errors.CodeInternal, "failed to scan traces deployment row")
|
||||
}
|
||||
row.FirstSeen = firstSeen.UnixMilli()
|
||||
if len(firstIDs) == 2 {
|
||||
row.TraceID = firstIDs[0]
|
||||
row.SpanID = firstIDs[1]
|
||||
}
|
||||
result = append(result, row)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, 0, errors.WrapInternalf(err, errors.CodeInternal, "failed to iterate traces deployment rows")
|
||||
}
|
||||
|
||||
return result, historyAvailableFromMs(rBucket), nil
|
||||
}
|
||||
|
||||
// queryLogsDeployments returns the marker rows and historyAvailableFrom in ms, 0 when there is no log history.
|
||||
func (m *module) queryLogsDeployments(ctx context.Context, orgID valuer.UUID, req *annotationtypes.PostableAnnotations) ([]annotationtypes.DeploymentRow, int64, error) {
|
||||
ctx = m.withAnnotationsContext(ctx, telemetrytypes.SignalLogs, "ListAnnotations")
|
||||
|
||||
filterClause, err := m.buildFilterClause(ctx, orgID, telemetrytypes.SignalLogs, req.Filter, req.Start, req.End)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
rBucket, err := m.readHistoryEdge(ctx, logsTables)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if rBucket == 0 {
|
||||
return []annotationtypes.DeploymentRow{}, 0, nil
|
||||
}
|
||||
|
||||
query, args := buildLogsDeploymentsQuery(filterClause, req.Start, req.End, rBucket)
|
||||
rows, err := m.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, 0, errors.WrapInternalf(err, errors.CodeInternal, "failed to query logs deployments")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := []annotationtypes.DeploymentRow{}
|
||||
for rows.Next() {
|
||||
var (
|
||||
row = annotationtypes.DeploymentRow{Signal: telemetrytypes.SignalLogs}
|
||||
firstSeenNs uint64
|
||||
)
|
||||
if err := rows.Scan(&row.Service, &row.Environment, &row.Version, &firstSeenNs, &row.LogID, &row.Labels); err != nil {
|
||||
return nil, 0, errors.WrapInternalf(err, errors.CodeInternal, "failed to scan logs deployment row")
|
||||
}
|
||||
row.FirstSeen = int64(firstSeenNs / 1000000)
|
||||
result = append(result, row)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, 0, errors.WrapInternalf(err, errors.CodeInternal, "failed to iterate logs deployment rows")
|
||||
}
|
||||
|
||||
return result, historyAvailableFromMs(rBucket), nil
|
||||
}
|
||||
|
||||
// readHistoryEdge returns the oldest bucket the resource table holds, 0 when it is empty.
|
||||
func (m *module) readHistoryEdge(ctx context.Context, tables signalTables) (int64, error) {
|
||||
var rBucket int64
|
||||
if err := m.telemetryStore.ClickhouseDB().QueryRow(ctx, buildHistoryEdgeQuery(tables)).Scan(&rBucket); err != nil {
|
||||
return 0, errors.WrapInternalf(err, errors.CodeInternal, "failed to read %s retention edge", tables.signal.StringValue())
|
||||
}
|
||||
return rBucket, nil
|
||||
}
|
||||
|
||||
func buildTracesDeploymentsQuery(filterClause *sqlbuilder.WhereClause, startMs, endMs, rBucket int64) (string, []any) {
|
||||
bounds := []string{
|
||||
"fromUnixTimestamp64Milli(toInt64(?)) AS t1_dt",
|
||||
"fromUnixTimestamp64Milli(toInt64(?)) AS t2_dt",
|
||||
}
|
||||
ctes, cteArgs := buildResourcePasses(tracesTables, filterClause, startMs, endMs, rBucket, bounds, []any{startMs, endMs})
|
||||
|
||||
final := buildFinalSelect(
|
||||
tracesTables,
|
||||
"argMin((raw_traces.trace_id, raw_traces.span_id), raw_traces.timestamp) AS first_ids",
|
||||
"first_seen >= t1_dt AND first_seen <= t2_dt",
|
||||
)
|
||||
|
||||
return querybuilder.CombineCTEs(ctes) + final, querybuilder.PrependArgs(cteArgs, nil)
|
||||
}
|
||||
|
||||
func buildLogsDeploymentsQuery(filterClause *sqlbuilder.WhereClause, startMs, endMs, rBucket int64) (string, []any) {
|
||||
bounds := []string{
|
||||
"toUInt64(?) * 1000000 AS t1_ns",
|
||||
"toUInt64(?) * 1000000 AS t2_ns",
|
||||
}
|
||||
ctes, cteArgs := buildResourcePasses(logsTables, filterClause, startMs, endMs, rBucket, bounds, []any{startMs, endMs})
|
||||
|
||||
final := buildFinalSelect(
|
||||
logsTables,
|
||||
"argMin(raw_logs.id, raw_logs.timestamp) AS first_id",
|
||||
"first_seen >= t1_ns AND first_seen <= t2_ns",
|
||||
)
|
||||
|
||||
return querybuilder.CombineCTEs(ctes) + final, querybuilder.PrependArgs(cteArgs, nil)
|
||||
}
|
||||
|
||||
// buildResourcePasses returns the constants, the signal's time bounds and the two resource-table CTEs shared by both signals, with their args in order.
|
||||
func buildResourcePasses(tables signalTables, filterClause *sqlbuilder.WhereClause, startMs, endMs, rBucket int64, bounds []string, boundArgs []any) ([]string, [][]any) {
|
||||
constants := []string{
|
||||
"toInt64(intDiv(?, 1000)) AS t1",
|
||||
"toInt64(intDiv(?, 1000)) AS t2",
|
||||
fmt.Sprintf("toInt64(intDiv(t1, %d) * %d) AS bt1", resourceBucketSeconds, resourceBucketSeconds),
|
||||
"toInt64(?) AS r_bucket",
|
||||
}
|
||||
constants = append(constants, bounds...)
|
||||
constantArgs := append([]any{startMs, endMs, rBucket}, boundArgs...)
|
||||
|
||||
resourceTable := fmt.Sprintf("%s.%s", tables.dbName, tables.resourceTable)
|
||||
|
||||
firstBucketInner := sqlbuilder.NewSelectBuilder()
|
||||
firstBucketInner.Select(
|
||||
"simpleJSONExtractString(labels, 'service.name') AS service",
|
||||
"simpleJSONExtractString(labels, 'deployment.environment') AS env",
|
||||
"simpleJSONExtractString(labels, 'service.version') AS version",
|
||||
"seen_at_ts_bucket_start AS bucket",
|
||||
)
|
||||
firstBucketInner.From(resourceTable)
|
||||
firstBucketInner.Where("version != ''", "seen_at_ts_bucket_start <= t2")
|
||||
firstBucketInner.AddWhereClause(sqlbuilder.CopyWhereClause(filterClause))
|
||||
firstBucketInnerSQL, firstBucketArgs := firstBucketInner.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
firstBucketSQL := fmt.Sprintf(
|
||||
"SELECT service, env, version, min(bucket) AS min_bucket FROM (%s) GROUP BY service, env, version HAVING min_bucket >= bt1 AND min_bucket > r_bucket",
|
||||
firstBucketInnerSQL,
|
||||
)
|
||||
|
||||
cand := sqlbuilder.NewSelectBuilder()
|
||||
cand.Select(
|
||||
"simpleJSONExtractString(labels, 'service.name') AS service",
|
||||
"simpleJSONExtractString(labels, 'deployment.environment') AS env",
|
||||
"simpleJSONExtractString(labels, 'service.version') AS version",
|
||||
"seen_at_ts_bucket_start AS min_bucket",
|
||||
"fingerprint AS fp",
|
||||
"labels",
|
||||
)
|
||||
cand.From(resourceTable)
|
||||
cand.Where(
|
||||
"version != ''",
|
||||
"(service, env, version, seen_at_ts_bucket_start) GLOBAL IN (SELECT service, env, version, min_bucket FROM first_bucket)",
|
||||
)
|
||||
cand.AddWhereClause(sqlbuilder.CopyWhereClause(filterClause))
|
||||
candSQL, candArgs := cand.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
ctes := make([]string, 0, len(constants)+2)
|
||||
ctes = append(ctes, constants...)
|
||||
ctes = append(ctes, fmt.Sprintf("first_bucket AS (%s)", firstBucketSQL), fmt.Sprintf("cand AS (%s)", candSQL))
|
||||
|
||||
return ctes, [][]any{constantArgs, firstBucketArgs, candArgs}
|
||||
}
|
||||
|
||||
// buildFinalSelect joins the raw table back to cand; idSelect and having carry the signal-specific columns and bounds.
|
||||
func buildFinalSelect(tables signalTables, idSelect, having string) string {
|
||||
raw := tables.rawAlias
|
||||
return fmt.Sprintf(
|
||||
"SELECT cand.service, cand.env, cand.version, min(%s.timestamp) AS first_seen, %s, argMin(cand.labels, %s.timestamp) AS labels "+
|
||||
"FROM %s.%s AS %s "+
|
||||
"GLOBAL INNER JOIN cand ON %s.resource_fingerprint = cand.fp AND toInt64(%s.ts_bucket_start) = cand.min_bucket "+
|
||||
"WHERE (%s.ts_bucket_start, %s.resource_fingerprint) GLOBAL IN (SELECT toUInt64(min_bucket), fp FROM cand) "+
|
||||
"GROUP BY cand.service, cand.env, cand.version "+
|
||||
"HAVING %s "+
|
||||
"ORDER BY first_seen",
|
||||
raw, idSelect, raw,
|
||||
tables.dbName, tables.dataTable, raw,
|
||||
raw, raw,
|
||||
raw, raw,
|
||||
having,
|
||||
)
|
||||
}
|
||||
|
||||
func buildHistoryEdgeQuery(tables signalTables) string {
|
||||
return fmt.Sprintf("SELECT min(seen_at_ts_bucket_start) FROM %s.%s", tables.dbName, tables.resourceTable)
|
||||
}
|
||||
|
||||
func historyAvailableFromMs(rBucket int64) int64 {
|
||||
return (rBucket + resourceBucketSeconds) * 1000
|
||||
}
|
||||
|
||||
// resolveSignals returns the requested signals, both when none were given.
|
||||
func resolveSignals(req *annotationtypes.PostableAnnotations) []telemetrytypes.Signal {
|
||||
if len(req.Signals) == 0 {
|
||||
return []telemetrytypes.Signal{telemetrytypes.SignalTraces, telemetrytypes.SignalLogs}
|
||||
}
|
||||
return req.Signals
|
||||
}
|
||||
125
pkg/modules/annotations/implannotations/deployments_test.go
Normal file
125
pkg/modules/annotations/implannotations/deployments_test.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package implannotations
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/annotationtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func serviceFilterClause(value string) *sqlbuilder.WhereClause {
|
||||
clause := sqlbuilder.NewWhereClause()
|
||||
cond := sqlbuilder.NewCond()
|
||||
clause.AddWhereExpr(cond.Args, cond.Equal("simpleJSONExtractString(labels, 'service.name')", value))
|
||||
return clause
|
||||
}
|
||||
|
||||
// withServiceFilter inserts the service predicate into both resource passes of an unfiltered golden.
|
||||
func withServiceFilter(golden string) string {
|
||||
golden = strings.Replace(golden, "seen_at_ts_bucket_start <= t2)", "seen_at_ts_bucket_start <= t2 AND simpleJSONExtractString(labels, 'service.name') = ?)", 1)
|
||||
return strings.Replace(golden, "min_bucket FROM first_bucket))", "min_bucket FROM first_bucket) AND simpleJSONExtractString(labels, 'service.name') = ?)", 1)
|
||||
}
|
||||
|
||||
func TestBuildTracesDeploymentsQuery(t *testing.T) {
|
||||
noFilter := "WITH toInt64(intDiv(?, 1000)) AS t1, toInt64(intDiv(?, 1000)) AS t2, toInt64(intDiv(t1, 1800) * 1800) AS bt1, toInt64(?) AS r_bucket, fromUnixTimestamp64Milli(toInt64(?)) AS t1_dt, fromUnixTimestamp64Milli(toInt64(?)) AS t2_dt, " +
|
||||
"first_bucket AS (SELECT service, env, version, min(bucket) AS min_bucket FROM (SELECT simpleJSONExtractString(labels, 'service.name') AS service, simpleJSONExtractString(labels, 'deployment.environment') AS env, simpleJSONExtractString(labels, 'service.version') AS version, seen_at_ts_bucket_start AS bucket FROM signoz_traces.distributed_traces_v3_resource WHERE version != '' AND seen_at_ts_bucket_start <= t2) GROUP BY service, env, version HAVING min_bucket >= bt1 AND min_bucket > r_bucket), " +
|
||||
"cand AS (SELECT simpleJSONExtractString(labels, 'service.name') AS service, simpleJSONExtractString(labels, 'deployment.environment') AS env, simpleJSONExtractString(labels, 'service.version') AS version, seen_at_ts_bucket_start AS min_bucket, fingerprint AS fp, labels FROM signoz_traces.distributed_traces_v3_resource WHERE version != '' AND (service, env, version, seen_at_ts_bucket_start) GLOBAL IN (SELECT service, env, version, min_bucket FROM first_bucket)) " +
|
||||
"SELECT cand.service, cand.env, cand.version, min(raw_traces.timestamp) AS first_seen, argMin((raw_traces.trace_id, raw_traces.span_id), raw_traces.timestamp) AS first_ids, argMin(cand.labels, raw_traces.timestamp) AS labels FROM signoz_traces.distributed_signoz_index_v3 AS raw_traces GLOBAL INNER JOIN cand ON raw_traces.resource_fingerprint = cand.fp AND toInt64(raw_traces.ts_bucket_start) = cand.min_bucket WHERE (raw_traces.ts_bucket_start, raw_traces.resource_fingerprint) GLOBAL IN (SELECT toUInt64(min_bucket), fp FROM cand) GROUP BY cand.service, cand.env, cand.version HAVING first_seen >= t1_dt AND first_seen <= t2_dt ORDER BY first_seen"
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
filter *sqlbuilder.WhereClause
|
||||
wantSQL string
|
||||
wantArgs []any
|
||||
}{
|
||||
{
|
||||
name: "NoFilter_Golden",
|
||||
filter: sqlbuilder.NewWhereClause(),
|
||||
wantSQL: noFilter,
|
||||
wantArgs: []any{int64(1700000000000), int64(1700003600000), int64(1699990000), int64(1700000000000), int64(1700003600000)},
|
||||
},
|
||||
{
|
||||
name: "Filtered_ArgsInBothPasses",
|
||||
filter: serviceFilterClause("cart"),
|
||||
wantSQL: withServiceFilter(noFilter),
|
||||
wantArgs: []any{int64(1700000000000), int64(1700003600000), int64(1699990000), int64(1700000000000), int64(1700003600000), "cart", "cart"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
sql, args := buildTracesDeploymentsQuery(testCase.filter, 1700000000000, 1700003600000, 1699990000)
|
||||
assert.Equal(t, testCase.wantSQL, sql)
|
||||
assert.Equal(t, testCase.wantArgs, args)
|
||||
assert.Equal(t, strings.Count(sql, "?"), len(args))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildLogsDeploymentsQuery(t *testing.T) {
|
||||
noFilter := "WITH toInt64(intDiv(?, 1000)) AS t1, toInt64(intDiv(?, 1000)) AS t2, toInt64(intDiv(t1, 1800) * 1800) AS bt1, toInt64(?) AS r_bucket, toUInt64(?) * 1000000 AS t1_ns, toUInt64(?) * 1000000 AS t2_ns, " +
|
||||
"first_bucket AS (SELECT service, env, version, min(bucket) AS min_bucket FROM (SELECT simpleJSONExtractString(labels, 'service.name') AS service, simpleJSONExtractString(labels, 'deployment.environment') AS env, simpleJSONExtractString(labels, 'service.version') AS version, seen_at_ts_bucket_start AS bucket FROM signoz_logs.distributed_logs_v2_resource WHERE version != '' AND seen_at_ts_bucket_start <= t2) GROUP BY service, env, version HAVING min_bucket >= bt1 AND min_bucket > r_bucket), " +
|
||||
"cand AS (SELECT simpleJSONExtractString(labels, 'service.name') AS service, simpleJSONExtractString(labels, 'deployment.environment') AS env, simpleJSONExtractString(labels, 'service.version') AS version, seen_at_ts_bucket_start AS min_bucket, fingerprint AS fp, labels FROM signoz_logs.distributed_logs_v2_resource WHERE version != '' AND (service, env, version, seen_at_ts_bucket_start) GLOBAL IN (SELECT service, env, version, min_bucket FROM first_bucket)) " +
|
||||
"SELECT cand.service, cand.env, cand.version, min(raw_logs.timestamp) AS first_seen, argMin(raw_logs.id, raw_logs.timestamp) AS first_id, argMin(cand.labels, raw_logs.timestamp) AS labels FROM signoz_logs.distributed_logs_v2 AS raw_logs GLOBAL INNER JOIN cand ON raw_logs.resource_fingerprint = cand.fp AND toInt64(raw_logs.ts_bucket_start) = cand.min_bucket WHERE (raw_logs.ts_bucket_start, raw_logs.resource_fingerprint) GLOBAL IN (SELECT toUInt64(min_bucket), fp FROM cand) GROUP BY cand.service, cand.env, cand.version HAVING first_seen >= t1_ns AND first_seen <= t2_ns ORDER BY first_seen"
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
filter *sqlbuilder.WhereClause
|
||||
wantSQL string
|
||||
wantArgs []any
|
||||
}{
|
||||
{
|
||||
name: "NoFilter_Golden",
|
||||
filter: sqlbuilder.NewWhereClause(),
|
||||
wantSQL: noFilter,
|
||||
wantArgs: []any{int64(1700000000000), int64(1700003600000), int64(1699990000), int64(1700000000000), int64(1700003600000)},
|
||||
},
|
||||
{
|
||||
name: "Filtered_ArgsInBothPasses",
|
||||
filter: serviceFilterClause("cart"),
|
||||
wantSQL: withServiceFilter(noFilter),
|
||||
wantArgs: []any{int64(1700000000000), int64(1700003600000), int64(1699990000), int64(1700000000000), int64(1700003600000), "cart", "cart"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
sql, args := buildLogsDeploymentsQuery(testCase.filter, 1700000000000, 1700003600000, 1699990000)
|
||||
assert.Equal(t, testCase.wantSQL, sql)
|
||||
assert.Equal(t, testCase.wantArgs, args)
|
||||
assert.Equal(t, strings.Count(sql, "?"), len(args))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHistoryEdgeQuery(t *testing.T) {
|
||||
assert.Equal(t, "SELECT min(seen_at_ts_bucket_start) FROM signoz_traces.distributed_traces_v3_resource", buildHistoryEdgeQuery(tracesTables))
|
||||
assert.Equal(t, "SELECT min(seen_at_ts_bucket_start) FROM signoz_logs.distributed_logs_v2_resource", buildHistoryEdgeQuery(logsTables))
|
||||
}
|
||||
|
||||
func TestHistoryAvailableFromMs(t *testing.T) {
|
||||
assert.Equal(t, int64(1700001800000), historyAvailableFromMs(1700000000))
|
||||
}
|
||||
|
||||
func TestResolveSignals(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
in []telemetrytypes.Signal
|
||||
want []telemetrytypes.Signal
|
||||
}{
|
||||
{name: "Empty_Both", in: nil, want: []telemetrytypes.Signal{telemetrytypes.SignalTraces, telemetrytypes.SignalLogs}},
|
||||
{name: "LogsOnly_Logs", in: []telemetrytypes.Signal{telemetrytypes.SignalLogs}, want: []telemetrytypes.Signal{telemetrytypes.SignalLogs}},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
got := resolveSignals(&annotationtypes.PostableAnnotations{Signals: testCase.in})
|
||||
require.Len(t, got, len(testCase.want))
|
||||
assert.Equal(t, testCase.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
46
pkg/modules/annotations/implannotations/handler.go
Normal file
46
pkg/modules/annotations/implannotations/handler.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package implannotations
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/binding"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/modules/annotations"
|
||||
"github.com/SigNoz/signoz/pkg/types/annotationtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type handler struct {
|
||||
module annotations.Module
|
||||
}
|
||||
|
||||
func NewHandler(m annotations.Module) annotations.Handler {
|
||||
return &handler{
|
||||
module: m,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *handler) ListAnnotations(rw http.ResponseWriter, req *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(req.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
|
||||
var parsedReq annotationtypes.PostableAnnotations
|
||||
if err := binding.JSON.BindBody(req.Body, &parsedReq); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.module.ListAnnotations(req.Context(), orgID, &parsedReq)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, result)
|
||||
}
|
||||
55
pkg/modules/annotations/implannotations/helpers.go
Normal file
55
pkg/modules/annotations/implannotations/helpers.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package implannotations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
)
|
||||
|
||||
// Returns an empty clause when the expression is empty or holds no resource condition.
|
||||
func (m *module) buildFilterClause(ctx context.Context, orgID valuer.UUID, signal telemetrytypes.Signal, filter *qbtypes.Filter, startMillis, endMillis int64) (*sqlbuilder.WhereClause, error) {
|
||||
expression := ""
|
||||
if filter != nil {
|
||||
expression = strings.TrimSpace(filter.Expression)
|
||||
}
|
||||
if expression == "" {
|
||||
return sqlbuilder.NewWhereClause(), nil
|
||||
}
|
||||
|
||||
whereClauseSelectors := querybuilder.QueryStringToKeysSelectors(expression)
|
||||
for idx := range whereClauseSelectors {
|
||||
whereClauseSelectors[idx].Signal = signal
|
||||
whereClauseSelectors[idx].SelectorMatchType = telemetrytypes.FieldSelectorMatchTypeExact
|
||||
}
|
||||
|
||||
whereClauseSelectors = querybuilder.ExpandKeySelectorsForFamilies(ctx, orgID, m.fl, whereClauseSelectors)
|
||||
keys, _, err := m.telemetryMetadataStore.GetKeysMulti(ctx, orgID, whereClauseSelectors)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
opts := querybuilder.FilterExprVisitorOpts{
|
||||
Context: ctx,
|
||||
Query: querybuilder.NewQueryInfo(ctx, orgID, m.fl, signal, nil, querybuilder.ToNanoSecs(uint64(startMillis)), querybuilder.ToNanoSecs(uint64(endMillis))),
|
||||
Storage: m.storage,
|
||||
Logger: m.logger,
|
||||
FieldKeys: keys,
|
||||
SkipFullTextFilter: true,
|
||||
}
|
||||
|
||||
whereClause, err := querybuilder.PrepareWhereClause(expression, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if whereClause.IsEmpty() {
|
||||
return sqlbuilder.NewWhereClause(), nil
|
||||
}
|
||||
|
||||
return whereClause.WhereClause, nil
|
||||
}
|
||||
165
pkg/modules/annotations/implannotations/helpers_test.go
Normal file
165
pkg/modules/annotations/implannotations/helpers_test.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package implannotations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
|
||||
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
|
||||
"github.com/SigNoz/signoz/pkg/statementbuilder/resourcefilter"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes/telemetrytypestest"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newTestModule(t *testing.T, fl flagger.Flagger, signal telemetrytypes.Signal) *module {
|
||||
t.Helper()
|
||||
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
|
||||
mockMetadataStore.KeysMap = map[string][]*telemetrytypes.TelemetryFieldKey{
|
||||
"service.name": {{
|
||||
Name: "service.name",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
}},
|
||||
"k8s.namespace.name": {{
|
||||
Name: "k8s.namespace.name",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
}},
|
||||
"deployment.environment": {{
|
||||
Name: "deployment.environment",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
}},
|
||||
"deployment.environment.name": {{
|
||||
Name: "deployment.environment.name",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
}},
|
||||
"http.request.method": {{
|
||||
Name: "http.request.method",
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
}},
|
||||
}
|
||||
for _, keys := range mockMetadataStore.KeysMap {
|
||||
for _, key := range keys {
|
||||
key.Signal = signal
|
||||
}
|
||||
}
|
||||
return &module{
|
||||
telemetryMetadataStore: mockMetadataStore,
|
||||
storage: resourcefilter.NewStorage(),
|
||||
fl: fl,
|
||||
logger: instrumentationtest.New().ToProviderSettings().Logger,
|
||||
}
|
||||
}
|
||||
|
||||
func buildSQL(clause *sqlbuilder.WhereClause) (string, []any) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("fingerprint")
|
||||
sb.From("t")
|
||||
sb.AddWhereClause(clause)
|
||||
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
return strings.TrimSpace(sql), args
|
||||
}
|
||||
|
||||
func TestBuildFilterClause(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
signal telemetrytypes.Signal
|
||||
filter *qbtypes.Filter
|
||||
familyOn bool
|
||||
wantSQL string
|
||||
wantArgs []any
|
||||
}{
|
||||
{
|
||||
name: "NilFilter_EmptyClause",
|
||||
signal: telemetrytypes.SignalTraces,
|
||||
filter: nil,
|
||||
wantSQL: "SELECT fingerprint FROM t",
|
||||
wantArgs: nil,
|
||||
},
|
||||
{
|
||||
name: "WhitespaceExpression_EmptyClause",
|
||||
signal: telemetrytypes.SignalTraces,
|
||||
filter: &qbtypes.Filter{Expression: " "},
|
||||
wantSQL: "SELECT fingerprint FROM t",
|
||||
wantArgs: nil,
|
||||
},
|
||||
{
|
||||
name: "ResourceKey_Traces_Compiled",
|
||||
signal: telemetrytypes.SignalTraces,
|
||||
filter: &qbtypes.Filter{Expression: "service.name = 'cartservice'"},
|
||||
wantSQL: "SELECT fingerprint FROM t WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?)",
|
||||
wantArgs: []any{"cartservice", "%service.name%", "%service.name\":\"cartservice%"},
|
||||
},
|
||||
{
|
||||
name: "ResourceKey_Logs_Compiled",
|
||||
signal: telemetrytypes.SignalLogs,
|
||||
filter: &qbtypes.Filter{Expression: "k8s.namespace.name = 'shop'"},
|
||||
wantSQL: "SELECT fingerprint FROM t WHERE (simpleJSONExtractString(labels, 'k8s.namespace.name') = ? AND labels LIKE ? AND labels LIKE ?)",
|
||||
wantArgs: []any{"shop", "%k8s.namespace.name%", "%k8s.namespace.name\":\"shop%"},
|
||||
},
|
||||
{
|
||||
name: "InList_Compiled",
|
||||
signal: telemetrytypes.SignalTraces,
|
||||
filter: &qbtypes.Filter{Expression: "service.name IN ('frontend', 'checkout')"},
|
||||
wantSQL: "SELECT fingerprint FROM t WHERE ((simpleJSONExtractString(labels, 'service.name') = ? OR simpleJSONExtractString(labels, 'service.name') = ?) AND labels LIKE ? AND (labels LIKE ? OR labels LIKE ?))",
|
||||
wantArgs: []any{"frontend", "checkout", "%service.name%", "%service.name\":\"frontend%", "%service.name\":\"checkout%"},
|
||||
},
|
||||
{
|
||||
name: "SpanAttributeOnly_EmptyClause",
|
||||
signal: telemetrytypes.SignalTraces,
|
||||
filter: &qbtypes.Filter{Expression: "http.request.method = 'GET'"},
|
||||
wantSQL: "SELECT fingerprint FROM t",
|
||||
wantArgs: nil,
|
||||
},
|
||||
{
|
||||
name: "SpecialCharacters_BoundAsArgs",
|
||||
signal: telemetrytypes.SignalTraces,
|
||||
filter: &qbtypes.Filter{Expression: "service.name = 'ca`rt$0'"},
|
||||
wantSQL: "SELECT fingerprint FROM t WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?)",
|
||||
wantArgs: []any{"ca`rt$0", "%service.name%", "%service.name\":\"ca`rt$0%"},
|
||||
},
|
||||
{
|
||||
name: "EnvironmentFamily_FlagOn_Widened",
|
||||
signal: telemetrytypes.SignalTraces,
|
||||
filter: &qbtypes.Filter{Expression: "deployment.environment = 'production'"},
|
||||
familyOn: true,
|
||||
wantSQL: "SELECT fingerprint FROM t WHERE (COALESCE(NULLIF(simpleJSONExtractString(labels, 'deployment.environment.name'), ''), NULLIF(simpleJSONExtractString(labels, 'deployment.environment'), ''), '') = ? AND (labels LIKE ? OR labels LIKE ?) AND (labels LIKE ? OR labels LIKE ?))",
|
||||
wantArgs: []any{"production", "%deployment.environment.name%", "%deployment.environment%", "%deployment.environment.name\":\"production%", "%deployment.environment\":\"production%"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
fl := flaggertest.New(t)
|
||||
if testCase.familyOn {
|
||||
fl = flaggertest.WithBooleanFlags(t, map[string]bool{flagger.FeatureResolveSemconvFamilies.String(): true})
|
||||
}
|
||||
m := newTestModule(t, fl, testCase.signal)
|
||||
|
||||
clause, err := m.buildFilterClause(context.Background(), valuer.UUID{}, testCase.signal, testCase.filter, 1000, 2000)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, clause)
|
||||
|
||||
sql, args := buildSQL(clause)
|
||||
assert.Equal(t, testCase.wantSQL, sql)
|
||||
assert.Equal(t, testCase.wantArgs, args)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFilterClause_ParseError(t *testing.T) {
|
||||
m := newTestModule(t, flaggertest.New(t), telemetrytypes.SignalTraces)
|
||||
|
||||
_, err := m.buildFilterClause(context.Background(), valuer.UUID{}, telemetrytypes.SignalTraces, &qbtypes.Filter{Expression: "service.name = "}, 1000, 2000)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
102
pkg/modules/annotations/implannotations/module.go
Normal file
102
pkg/modules/annotations/implannotations/module.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package implannotations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"slices"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
"github.com/SigNoz/signoz/pkg/modules/annotations"
|
||||
"github.com/SigNoz/signoz/pkg/statementbuilder/resourcefilter"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/annotationtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
type module struct {
|
||||
telemetryStore telemetrystore.TelemetryStore
|
||||
telemetryMetadataStore telemetrytypes.MetadataStore
|
||||
storage qbtypes.Storage
|
||||
fl flagger.Flagger
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewModule(
|
||||
telemetryStore telemetrystore.TelemetryStore,
|
||||
telemetryMetadataStore telemetrytypes.MetadataStore,
|
||||
fl flagger.Flagger,
|
||||
providerSettings factory.ProviderSettings,
|
||||
) annotations.Module {
|
||||
return &module{
|
||||
telemetryStore: telemetryStore,
|
||||
telemetryMetadataStore: telemetryMetadataStore,
|
||||
storage: resourcefilter.NewStorage(),
|
||||
fl: fl,
|
||||
logger: providerSettings.Logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *module) ListAnnotations(ctx context.Context, orgID valuer.UUID, req *annotationtypes.PostableAnnotations) (*annotationtypes.Annotations, error) {
|
||||
if err := req.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var (
|
||||
tracesSpecs, logsSpecs []annotationtypes.DeploymentSpec
|
||||
tracesHistoryEdge, logsHistoryEdge int64
|
||||
)
|
||||
signals := resolveSignals(req)
|
||||
errGroup, gCtx := errgroup.WithContext(ctx)
|
||||
|
||||
if slices.Contains(signals, telemetrytypes.SignalTraces) {
|
||||
errGroup.Go(func() error {
|
||||
rows, edge, err := m.queryTracesDeployments(gCtx, orgID, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tracesSpecs, err = annotationtypes.NewDeploymentSpecsFromRows(rows)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tracesHistoryEdge = edge
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if slices.Contains(signals, telemetrytypes.SignalLogs) {
|
||||
errGroup.Go(func() error {
|
||||
rows, edge, err := m.queryLogsDeployments(gCtx, orgID, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logsSpecs, err = annotationtypes.NewDeploymentSpecsFromRows(rows)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logsHistoryEdge = edge
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := errGroup.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
page, total := annotationtypes.ApplyLimit(annotationtypes.MergeDeployments(tracesSpecs, logsSpecs), req.Limit)
|
||||
return annotationtypes.NewAnnotationsFromDeployments(page, total, annotationtypes.EarliestHistoryEdge(tracesHistoryEdge, logsHistoryEdge)), nil
|
||||
}
|
||||
|
||||
func (m *module) withAnnotationsContext(ctx context.Context, signal telemetrytypes.Signal, functionName string) context.Context {
|
||||
comments := map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: signal.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "annotations",
|
||||
instrumentationtypes.CodeFunctionName: functionName,
|
||||
}
|
||||
return ctxtypes.NewContextWithCommentVals(ctx, comments)
|
||||
}
|
||||
@@ -14,6 +14,8 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/licensing"
|
||||
"github.com/SigNoz/signoz/pkg/modules/aiobservability"
|
||||
"github.com/SigNoz/signoz/pkg/modules/aiobservability/implaiobservability"
|
||||
"github.com/SigNoz/signoz/pkg/modules/annotations"
|
||||
"github.com/SigNoz/signoz/pkg/modules/annotations/implannotations"
|
||||
"github.com/SigNoz/signoz/pkg/modules/apdex"
|
||||
"github.com/SigNoz/signoz/pkg/modules/apdex/implapdex"
|
||||
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
|
||||
@@ -72,6 +74,7 @@ type Handlers struct {
|
||||
MetricsExplorer metricsexplorer.Handler
|
||||
MetricReductionRule metricreductionrule.Handler
|
||||
InfraMonitoring inframonitoring.Handler
|
||||
Annotations annotations.Handler
|
||||
Global global.Handler
|
||||
FlaggerHandler flagger.Handler
|
||||
GatewayHandler gateway.Handler
|
||||
@@ -125,6 +128,7 @@ func NewHandlers(
|
||||
MetricsExplorer: implmetricsexplorer.NewHandler(modules.MetricsExplorer),
|
||||
MetricReductionRule: implmetricreductionrule.NewHandler(modules.MetricReductionRule),
|
||||
InfraMonitoring: implinframonitoring.NewHandler(modules.InfraMonitoring),
|
||||
Annotations: implannotations.NewHandler(modules.Annotations),
|
||||
SpanPercentile: implspanpercentile.NewHandler(modules.SpanPercentile),
|
||||
Global: signozglobal.NewHandler(global),
|
||||
FlaggerHandler: flagger.NewHandler(flaggerService),
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/emailing"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
"github.com/SigNoz/signoz/pkg/modules/annotations"
|
||||
"github.com/SigNoz/signoz/pkg/modules/annotations/implannotations"
|
||||
"github.com/SigNoz/signoz/pkg/modules/apdex"
|
||||
"github.com/SigNoz/signoz/pkg/modules/apdex/implapdex"
|
||||
"github.com/SigNoz/signoz/pkg/modules/authdomain"
|
||||
@@ -85,6 +87,7 @@ type Modules struct {
|
||||
MetricsExplorer metricsexplorer.Module
|
||||
MetricReductionRule metricreductionrule.Module
|
||||
InfraMonitoring inframonitoring.Module
|
||||
Annotations annotations.Module
|
||||
Promote promote.Module
|
||||
ServiceAccount serviceaccount.Module
|
||||
ServiceAccountGetter serviceaccount.Getter
|
||||
@@ -155,6 +158,7 @@ func NewModules(
|
||||
MetricsExplorer: implmetricsexplorer.NewModule(telemetryStore, telemetryMetadataStore, cache, ruleStore, dashboard, fl, providerSettings, config.MetricsExplorer),
|
||||
MetricReductionRule: metricReductionRule,
|
||||
InfraMonitoring: implinframonitoring.NewModule(telemetryStore, telemetryMetadataStore, querier, fl, providerSettings, config.InfraMonitoring),
|
||||
Annotations: implannotations.NewModule(telemetryStore, telemetryMetadataStore, fl, providerSettings),
|
||||
Promote: implpromote.NewModule(telemetryMetadataStore, telemetryStore, providerSettings),
|
||||
ServiceAccount: serviceAccount,
|
||||
ServiceAccountGetter: serviceAccountGetter,
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/instrumentation"
|
||||
"github.com/SigNoz/signoz/pkg/licensing"
|
||||
"github.com/SigNoz/signoz/pkg/modules/aiobservability"
|
||||
"github.com/SigNoz/signoz/pkg/modules/annotations"
|
||||
"github.com/SigNoz/signoz/pkg/modules/authdomain"
|
||||
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
@@ -82,6 +83,7 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta
|
||||
struct{ metricsexplorer.Handler }{},
|
||||
struct{ metricreductionrule.Handler }{},
|
||||
struct{ inframonitoring.Handler }{},
|
||||
struct{ annotations.Handler }{},
|
||||
struct{ gateway.Handler }{},
|
||||
struct{ gateway.Gateway }{},
|
||||
struct{ fields.Handler }{},
|
||||
|
||||
@@ -350,6 +350,7 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
|
||||
handlers.MetricsExplorer,
|
||||
handlers.MetricReductionRule,
|
||||
handlers.InfraMonitoring,
|
||||
handlers.Annotations,
|
||||
handlers.GatewayHandler,
|
||||
gatewayService,
|
||||
handlers.Fields,
|
||||
|
||||
@@ -570,6 +570,7 @@ func New(
|
||||
cloudIntegrationModule,
|
||||
modules.LogsPipeline,
|
||||
modules.InfraMonitoring,
|
||||
modules.Annotations,
|
||||
querier,
|
||||
authz,
|
||||
}
|
||||
|
||||
@@ -200,7 +200,7 @@ func TestConditionBuilder(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
storage := newStorage()
|
||||
storage := NewStorage()
|
||||
|
||||
for _, tc := range testCases {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
|
||||
func TestFamilyEqualWidensIndexHintsToAnyMember(t *testing.T) {
|
||||
fl := flaggertest.WithBooleanFlags(t, map[string]bool{flagger.FeatureResolveSemconvFamilies.String(): true})
|
||||
storage := newStorage()
|
||||
storage := NewStorage()
|
||||
fieldKeys := map[string][]*telemetrytypes.TelemetryFieldKey{
|
||||
"deployment.environment.name": {{
|
||||
Name: "deployment.environment.name",
|
||||
@@ -48,7 +48,7 @@ func TestFamilyEqualWidensIndexHintsToAnyMember(t *testing.T) {
|
||||
|
||||
func TestFamilyNotEqualDropsNegatedValueHint(t *testing.T) {
|
||||
fl := flaggertest.WithBooleanFlags(t, map[string]bool{flagger.FeatureResolveSemconvFamilies.String(): true})
|
||||
storage := newStorage()
|
||||
storage := NewStorage()
|
||||
fieldKeys := map[string][]*telemetrytypes.TelemetryFieldKey{
|
||||
"deployment.environment.name": {{
|
||||
Name: "deployment.environment.name",
|
||||
@@ -76,7 +76,7 @@ func TestFamilyNotEqualDropsNegatedValueHint(t *testing.T) {
|
||||
|
||||
func TestFamilyExistsIsAnyMemberPresence(t *testing.T) {
|
||||
fl := flaggertest.WithBooleanFlags(t, map[string]bool{flagger.FeatureResolveSemconvFamilies.String(): true})
|
||||
storage := newStorage()
|
||||
storage := NewStorage()
|
||||
fieldKeys := map[string][]*telemetrytypes.TelemetryFieldKey{
|
||||
"deployment.environment.name": {{
|
||||
Name: "deployment.environment.name",
|
||||
@@ -115,7 +115,7 @@ func TestFamilyExistsIsAnyMemberPresence(t *testing.T) {
|
||||
// including the negated value hint on !=.
|
||||
func TestSingleMemberShapesUnchanged(t *testing.T) {
|
||||
fl := flaggertest.WithBooleanFlags(t, map[string]bool{flagger.FeatureResolveSemconvFamilies.String(): true})
|
||||
storage := newStorage()
|
||||
storage := NewStorage()
|
||||
fieldKeys := map[string][]*telemetrytypes.TelemetryFieldKey{
|
||||
"deployment.environment.name": {{
|
||||
Name: "deployment.environment.name",
|
||||
|
||||
@@ -49,7 +49,7 @@ func New[T any](
|
||||
logger: set.Logger(),
|
||||
dbName: dbName,
|
||||
tableName: tableName,
|
||||
storage: newStorage(),
|
||||
storage: NewStorage(),
|
||||
metadataStore: metadataStore,
|
||||
signal: signal,
|
||||
source: source,
|
||||
|
||||
@@ -22,7 +22,7 @@ type storage struct{}
|
||||
|
||||
var _ qbtypes.Storage = (*storage)(nil)
|
||||
|
||||
func newStorage() *storage {
|
||||
func NewStorage() qbtypes.Storage {
|
||||
return &storage{}
|
||||
}
|
||||
|
||||
|
||||
125
pkg/types/annotationtypes/annotations.go
Normal file
125
pkg/types/annotationtypes/annotations.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package annotationtypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/swaggest/jsonschema-go"
|
||||
)
|
||||
|
||||
var ValidSignals = []telemetrytypes.Signal{telemetrytypes.SignalTraces, telemetrytypes.SignalLogs}
|
||||
|
||||
type AnnotationKind struct {
|
||||
valuer.String
|
||||
}
|
||||
|
||||
var (
|
||||
AnnotationKindDeployment = AnnotationKind{valuer.NewString("deployment")}
|
||||
)
|
||||
|
||||
func (AnnotationKind) Enum() []any {
|
||||
return []any{
|
||||
AnnotationKindDeployment,
|
||||
}
|
||||
}
|
||||
|
||||
type Annotations struct {
|
||||
Records []Annotation `json:"records" required:"true"`
|
||||
Total int `json:"total" required:"true"`
|
||||
HistoryAvailableFrom int64 `json:"historyAvailableFrom" required:"true"`
|
||||
}
|
||||
|
||||
type Annotation struct {
|
||||
Kind AnnotationKind `json:"kind" required:"true"`
|
||||
Spec any `json:"spec" required:"true"`
|
||||
}
|
||||
|
||||
type PostableAnnotations struct {
|
||||
Kind AnnotationKind `json:"kind" required:"true"`
|
||||
Start int64 `json:"start" required:"true"`
|
||||
End int64 `json:"end" required:"true"`
|
||||
Signals []telemetrytypes.Signal `json:"signals"`
|
||||
Filter *qbtypes.Filter `json:"filter"`
|
||||
Limit int `json:"limit" required:"true"`
|
||||
}
|
||||
|
||||
var (
|
||||
_ jsonschema.OneOfExposer = Annotation{}
|
||||
_ jsonschema.Preparer = Annotation{}
|
||||
)
|
||||
|
||||
// annotationDeployment is the OpenAPI schema for an Annotation with kind=deployment.
|
||||
type annotationDeployment struct {
|
||||
Kind AnnotationKind `json:"kind" required:"true"`
|
||||
Spec DeploymentSpec `json:"spec" required:"true"`
|
||||
}
|
||||
|
||||
func (Annotation) JSONSchemaOneOf() []any {
|
||||
return []any{
|
||||
annotationDeployment{},
|
||||
}
|
||||
}
|
||||
|
||||
func (Annotation) PrepareJSONSchema(schema *jsonschema.Schema) error {
|
||||
if schema.ExtraProperties == nil {
|
||||
schema.ExtraProperties = map[string]any{}
|
||||
}
|
||||
|
||||
schema.ExtraProperties["x-signoz-discriminator"] = map[string]any{
|
||||
"propertyName": "kind",
|
||||
"mapping": map[string]string{
|
||||
AnnotationKindDeployment.StringValue(): "#/components/schemas/AnnotationtypesAnnotationDeployment",
|
||||
},
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (req *PostableAnnotations) Validate() error {
|
||||
if req == nil {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "request is nil")
|
||||
}
|
||||
|
||||
if req.Kind != AnnotationKindDeployment {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid kind %q: only %q is supported", req.Kind.StringValue(), AnnotationKindDeployment.StringValue())
|
||||
}
|
||||
|
||||
if req.Start <= 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid start time %d: start must be greater than 0", req.Start)
|
||||
}
|
||||
|
||||
if req.End <= 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid end time %d: end must be greater than 0", req.End)
|
||||
}
|
||||
|
||||
if req.Start >= req.End {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid time range: start (%d) must be less than end (%d)", req.Start, req.End)
|
||||
}
|
||||
|
||||
for _, signal := range req.Signals {
|
||||
if !slices.Contains(ValidSignals, signal) {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid signal %q: must be one of %q, %q", signal.StringValue(), telemetrytypes.SignalTraces.StringValue(), telemetrytypes.SignalLogs.StringValue())
|
||||
}
|
||||
}
|
||||
|
||||
if req.Limit < 1 || req.Limit > 5000 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "limit must be between 1 and 5000")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnmarshalJSON validates input immediately after decoding.
|
||||
func (req *PostableAnnotations) UnmarshalJSON(data []byte) error {
|
||||
type raw PostableAnnotations
|
||||
var decoded raw
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
return err
|
||||
}
|
||||
*req = PostableAnnotations(decoded)
|
||||
return req.Validate()
|
||||
}
|
||||
159
pkg/types/annotationtypes/annotations_test.go
Normal file
159
pkg/types/annotationtypes/annotations_test.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package annotationtypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPostableAnnotations_Validate(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
req *PostableAnnotations
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "DeploymentKind_BothSignals_Valid",
|
||||
req: &PostableAnnotations{Kind: AnnotationKindDeployment, Start: 1000, End: 2000, Signals: []telemetrytypes.Signal{telemetrytypes.SignalTraces, telemetrytypes.SignalLogs}, Limit: 100},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "NoSignals_Valid",
|
||||
req: &PostableAnnotations{Kind: AnnotationKindDeployment, Start: 1000, End: 2000, Limit: 1},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "NilRequest_Rejected",
|
||||
req: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "UnknownKind_Rejected",
|
||||
req: &PostableAnnotations{Kind: AnnotationKind{valuer.NewString("incident")}, Start: 1000, End: 2000, Limit: 100},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "EmptyKind_Rejected",
|
||||
req: &PostableAnnotations{Start: 1000, End: 2000, Limit: 100},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "StartZero_Rejected",
|
||||
req: &PostableAnnotations{Kind: AnnotationKindDeployment, Start: 0, End: 2000, Limit: 100},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "EndZero_Rejected",
|
||||
req: &PostableAnnotations{Kind: AnnotationKindDeployment, Start: 1000, End: 0, Limit: 100},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "StartAfterEnd_Rejected",
|
||||
req: &PostableAnnotations{Kind: AnnotationKindDeployment, Start: 3000, End: 2000, Limit: 100},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "StartEqualsEnd_Rejected",
|
||||
req: &PostableAnnotations{Kind: AnnotationKindDeployment, Start: 2000, End: 2000, Limit: 100},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "MetricsSignal_Rejected",
|
||||
req: &PostableAnnotations{Kind: AnnotationKindDeployment, Start: 1000, End: 2000, Signals: []telemetrytypes.Signal{telemetrytypes.SignalMetrics}, Limit: 100},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "LimitZero_Rejected",
|
||||
req: &PostableAnnotations{Kind: AnnotationKindDeployment, Start: 1000, End: 2000, Limit: 0},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "LimitNegative_Rejected",
|
||||
req: &PostableAnnotations{Kind: AnnotationKindDeployment, Start: 1000, End: 2000, Limit: -5},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "LimitAboveMax_Rejected",
|
||||
req: &PostableAnnotations{Kind: AnnotationKindDeployment, Start: 1000, End: 2000, Limit: 5001},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "LimitAtMax_Valid",
|
||||
req: &PostableAnnotations{Kind: AnnotationKindDeployment, Start: 1000, End: 2000, Limit: 5000},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
err := testCase.req.Validate()
|
||||
if !testCase.wantErr {
|
||||
assert.NoError(t, err)
|
||||
return
|
||||
}
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Ast(err, errors.TypeInvalidInput))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostableAnnotations_UnmarshalJSON(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
body string
|
||||
want PostableAnnotations
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "FullBody_Decoded",
|
||||
body: `{"kind":"deployment","start":1000,"end":2000,"signals":["traces"],"filter":{"expression":"service.name = 'cart'"},"limit":50}`,
|
||||
want: PostableAnnotations{
|
||||
Kind: AnnotationKindDeployment,
|
||||
Start: 1000,
|
||||
End: 2000,
|
||||
Signals: []telemetrytypes.Signal{telemetrytypes.SignalTraces},
|
||||
Limit: 50,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "InvalidKind_ValidatedOnDecode",
|
||||
body: `{"kind":"release","start":1000,"end":2000,"limit":50}`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "MissingLimit_ValidatedOnDecode",
|
||||
body: `{"kind":"deployment","start":1000,"end":2000}`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "MalformedJSON_Rejected",
|
||||
body: `{"kind":"deployment",`,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
var got PostableAnnotations
|
||||
err := json.Unmarshal([]byte(testCase.body), &got)
|
||||
if testCase.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testCase.want.Kind, got.Kind)
|
||||
assert.Equal(t, testCase.want.Start, got.Start)
|
||||
assert.Equal(t, testCase.want.End, got.End)
|
||||
assert.Equal(t, testCase.want.Signals, got.Signals)
|
||||
assert.Equal(t, testCase.want.Limit, got.Limit)
|
||||
require.NotNil(t, got.Filter)
|
||||
assert.Equal(t, "service.name = 'cart'", got.Filter.Expression)
|
||||
})
|
||||
}
|
||||
}
|
||||
140
pkg/types/annotationtypes/deployments.go
Normal file
140
pkg/types/annotationtypes/deployments.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package annotationtypes
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
var deploymentIdentityKeys = []string{"service.name", "deployment.environment", "deployment.environment.name", "service.version"}
|
||||
|
||||
type DeploymentSpec struct {
|
||||
ID string `json:"id" required:"true"`
|
||||
Signal telemetrytypes.Signal `json:"signal" required:"true"`
|
||||
Service string `json:"service" required:"true"`
|
||||
Environment string `json:"environment" required:"true"`
|
||||
Version string `json:"version" required:"true"`
|
||||
FirstSeen int64 `json:"firstSeen" required:"true"`
|
||||
TraceID string `json:"traceId" required:"true"`
|
||||
SpanID string `json:"spanId" required:"true"`
|
||||
LogID string `json:"logId" required:"true"`
|
||||
ResourceAttributes map[string]string `json:"resourceAttributes" required:"true"`
|
||||
}
|
||||
|
||||
// DeploymentRow is the storage shape one marker row is scanned into; FirstSeen is epoch ms.
|
||||
type DeploymentRow struct {
|
||||
Signal telemetrytypes.Signal
|
||||
Service string
|
||||
Environment string
|
||||
Version string
|
||||
FirstSeen int64
|
||||
TraceID string
|
||||
SpanID string
|
||||
LogID string
|
||||
Labels string
|
||||
}
|
||||
|
||||
func NewDeploymentSpecFromRow(row DeploymentRow) (DeploymentSpec, error) {
|
||||
resourceAttributes := map[string]string{}
|
||||
if row.Labels != "" {
|
||||
if err := json.Unmarshal([]byte(row.Labels), &resourceAttributes); err != nil {
|
||||
return DeploymentSpec{}, errors.WrapInternalf(err, errors.CodeInternal, "failed to parse resource labels for %s/%s/%s", row.Service, row.Environment, row.Version)
|
||||
}
|
||||
}
|
||||
for _, key := range deploymentIdentityKeys {
|
||||
delete(resourceAttributes, key)
|
||||
}
|
||||
|
||||
return DeploymentSpec{
|
||||
ID: NewDeploymentID(row.Service, row.Environment, row.Version),
|
||||
Signal: row.Signal,
|
||||
Service: row.Service,
|
||||
Environment: row.Environment,
|
||||
Version: row.Version,
|
||||
FirstSeen: row.FirstSeen,
|
||||
TraceID: row.TraceID,
|
||||
SpanID: row.SpanID,
|
||||
LogID: row.LogID,
|
||||
ResourceAttributes: resourceAttributes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func NewDeploymentSpecsFromRows(rows []DeploymentRow) ([]DeploymentSpec, error) {
|
||||
specs := make([]DeploymentSpec, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
spec, err := NewDeploymentSpecFromRow(row)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
specs = append(specs, spec)
|
||||
}
|
||||
return specs, nil
|
||||
}
|
||||
|
||||
func NewDeploymentID(service, environment, version string) string {
|
||||
sum := sha256.Sum256([]byte(strings.Join([]string{service, environment, version}, "\x00")))
|
||||
return "dep_" + hex.EncodeToString(sum[:4])
|
||||
}
|
||||
|
||||
// MergeDeployments keeps the earliest FirstSeen per (service, environment, version); on a tie the first input wins.
|
||||
func MergeDeployments(specs ...[]DeploymentSpec) []DeploymentSpec {
|
||||
byTuple := map[string]DeploymentSpec{}
|
||||
for _, list := range specs {
|
||||
for _, spec := range list {
|
||||
key := strings.Join([]string{spec.Service, spec.Environment, spec.Version}, "\x00")
|
||||
existing, ok := byTuple[key]
|
||||
if !ok || spec.FirstSeen < existing.FirstSeen {
|
||||
byTuple[key] = spec
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
merged := make([]DeploymentSpec, 0, len(byTuple))
|
||||
for _, spec := range byTuple {
|
||||
merged = append(merged, spec)
|
||||
}
|
||||
sort.Slice(merged, func(i, j int) bool {
|
||||
if merged[i].FirstSeen != merged[j].FirstSeen {
|
||||
return merged[i].FirstSeen < merged[j].FirstSeen
|
||||
}
|
||||
if merged[i].Service != merged[j].Service {
|
||||
return merged[i].Service < merged[j].Service
|
||||
}
|
||||
if merged[i].Environment != merged[j].Environment {
|
||||
return merged[i].Environment < merged[j].Environment
|
||||
}
|
||||
return merged[i].Version < merged[j].Version
|
||||
})
|
||||
return merged
|
||||
}
|
||||
|
||||
// EarliestHistoryEdge returns the smallest non-zero edge, 0 when no signal has history.
|
||||
func EarliestHistoryEdge(edges ...int64) int64 {
|
||||
var earliest int64
|
||||
for _, edge := range edges {
|
||||
if edge != 0 && (earliest == 0 || edge < earliest) {
|
||||
earliest = edge
|
||||
}
|
||||
}
|
||||
return earliest
|
||||
}
|
||||
|
||||
func NewAnnotationsFromDeployments(specs []DeploymentSpec, total int, historyAvailableFrom int64) *Annotations {
|
||||
records := make([]Annotation, 0, len(specs))
|
||||
for _, spec := range specs {
|
||||
records = append(records, Annotation{Kind: AnnotationKindDeployment, Spec: spec})
|
||||
}
|
||||
return &Annotations{Records: records, Total: total, HistoryAvailableFrom: historyAvailableFrom}
|
||||
}
|
||||
|
||||
func ApplyLimit(specs []DeploymentSpec, limit int) ([]DeploymentSpec, int) {
|
||||
if limit >= len(specs) {
|
||||
return specs, len(specs)
|
||||
}
|
||||
return specs[:limit], len(specs)
|
||||
}
|
||||
204
pkg/types/annotationtypes/deployments_test.go
Normal file
204
pkg/types/annotationtypes/deployments_test.go
Normal file
@@ -0,0 +1,204 @@
|
||||
package annotationtypes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewDeploymentID(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
service string
|
||||
env string
|
||||
version string
|
||||
wantEq bool
|
||||
}{
|
||||
{name: "SameTuple_SameID", service: "checkout", env: "prod", version: "1.2.0", wantEq: true},
|
||||
{name: "DifferentVersion_DifferentID", service: "checkout", env: "prod", version: "1.2.1", wantEq: false},
|
||||
{name: "DifferentEnv_DifferentID", service: "checkout", env: "staging", version: "1.2.0", wantEq: false},
|
||||
{name: "DifferentService_DifferentID", service: "cart", env: "prod", version: "1.2.0", wantEq: false},
|
||||
}
|
||||
|
||||
base := NewDeploymentID("checkout", "prod", "1.2.0")
|
||||
require.Len(t, base, len("dep_")+8)
|
||||
assert.True(t, len(base) > 4 && base[:4] == "dep_")
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
got := NewDeploymentID(testCase.service, testCase.env, testCase.version)
|
||||
assert.Equal(t, testCase.wantEq, got == base)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDeploymentSpecFromRow(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
row DeploymentRow
|
||||
wantAttrs map[string]string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "IdentityKeysStripped_OthersKept",
|
||||
row: DeploymentRow{
|
||||
Signal: telemetrytypes.SignalTraces, Service: "checkout", Environment: "prod", Version: "1.2.0", FirstSeen: 1700000000123,
|
||||
TraceID: "abc", SpanID: "def",
|
||||
Labels: `{"service.name":"checkout","deployment.environment":"prod","deployment.environment.name":"prod","service.version":"1.2.0","k8s.namespace.name":"shop","host.name":"node-1"}`,
|
||||
},
|
||||
wantAttrs: map[string]string{"k8s.namespace.name": "shop", "host.name": "node-1"},
|
||||
},
|
||||
{
|
||||
name: "EmptyLabels_EmptyMap",
|
||||
row: DeploymentRow{Signal: telemetrytypes.SignalLogs, Service: "cart", Environment: "prod", Version: "2.0.0", LogID: "log-1"},
|
||||
wantAttrs: map[string]string{},
|
||||
},
|
||||
{
|
||||
name: "MalformedLabels_Error",
|
||||
row: DeploymentRow{Service: "cart", Environment: "prod", Version: "2.0.0", Labels: `{"service.name":`},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
spec, err := NewDeploymentSpecFromRow(testCase.row)
|
||||
if testCase.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, NewDeploymentID(testCase.row.Service, testCase.row.Environment, testCase.row.Version), spec.ID)
|
||||
assert.Equal(t, testCase.row.Signal, spec.Signal)
|
||||
assert.Equal(t, testCase.row.FirstSeen, spec.FirstSeen)
|
||||
assert.Equal(t, testCase.row.TraceID, spec.TraceID)
|
||||
assert.Equal(t, testCase.row.SpanID, spec.SpanID)
|
||||
assert.Equal(t, testCase.row.LogID, spec.LogID)
|
||||
assert.Equal(t, testCase.wantAttrs, spec.ResourceAttributes)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeDeployments(t *testing.T) {
|
||||
traces := []DeploymentSpec{
|
||||
{Service: "checkout", Environment: "prod", Version: "1.2.0", FirstSeen: 200, Signal: telemetrytypes.SignalTraces, TraceID: "t1"},
|
||||
{Service: "cart", Environment: "prod", Version: "3.0.0", FirstSeen: 100, Signal: telemetrytypes.SignalTraces, TraceID: "t2"},
|
||||
{Service: "api", Environment: "prod", Version: "9.0.0", FirstSeen: 300, Signal: telemetrytypes.SignalTraces, TraceID: "t3"},
|
||||
}
|
||||
logs := []DeploymentSpec{
|
||||
{Service: "checkout", Environment: "prod", Version: "1.2.0", FirstSeen: 150, Signal: telemetrytypes.SignalLogs, LogID: "l1"},
|
||||
{Service: "cart", Environment: "prod", Version: "3.0.0", FirstSeen: 100, Signal: telemetrytypes.SignalLogs, LogID: "l2"},
|
||||
{Service: "worker", Environment: "prod", Version: "0.1.0", FirstSeen: 300, Signal: telemetrytypes.SignalLogs, LogID: "l3"},
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
inputs [][]DeploymentSpec
|
||||
wantOrder []string
|
||||
wantSignalOf map[string]telemetrytypes.Signal
|
||||
}{
|
||||
{
|
||||
name: "EarliestWins_TieKeepsFirstInput_SortedByFirstSeenThenService",
|
||||
inputs: [][]DeploymentSpec{traces, logs},
|
||||
wantOrder: []string{"cart", "checkout", "api", "worker"},
|
||||
wantSignalOf: map[string]telemetrytypes.Signal{
|
||||
"cart": telemetrytypes.SignalTraces,
|
||||
"checkout": telemetrytypes.SignalLogs,
|
||||
"api": telemetrytypes.SignalTraces,
|
||||
"worker": telemetrytypes.SignalLogs,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "SingleInput_Sorted",
|
||||
inputs: [][]DeploymentSpec{traces},
|
||||
wantOrder: []string{"cart", "checkout", "api"},
|
||||
wantSignalOf: map[string]telemetrytypes.Signal{"cart": telemetrytypes.SignalTraces, "checkout": telemetrytypes.SignalTraces, "api": telemetrytypes.SignalTraces},
|
||||
},
|
||||
{
|
||||
name: "NoInput_Empty",
|
||||
inputs: nil,
|
||||
wantOrder: []string{},
|
||||
wantSignalOf: map[string]telemetrytypes.Signal{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
merged := MergeDeployments(testCase.inputs...)
|
||||
require.Len(t, merged, len(testCase.wantOrder))
|
||||
for i, spec := range merged {
|
||||
assert.Equal(t, testCase.wantOrder[i], spec.Service)
|
||||
assert.Equal(t, testCase.wantSignalOf[spec.Service], spec.Signal)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyLimit(t *testing.T) {
|
||||
specs := []DeploymentSpec{{Service: "a"}, {Service: "b"}, {Service: "c"}}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
limit int
|
||||
wantLen int
|
||||
wantTotal int
|
||||
}{
|
||||
{name: "LimitBelowCount_Truncated", limit: 2, wantLen: 2, wantTotal: 3},
|
||||
{name: "LimitEqualsCount_All", limit: 3, wantLen: 3, wantTotal: 3},
|
||||
{name: "LimitAboveCount_All", limit: 100, wantLen: 3, wantTotal: 3},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
page, total := ApplyLimit(specs, testCase.limit)
|
||||
assert.Len(t, page, testCase.wantLen)
|
||||
assert.Equal(t, testCase.wantTotal, total)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEarliestHistoryEdge(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
edges []int64
|
||||
want int64
|
||||
}{
|
||||
{name: "BothZero_Zero", edges: []int64{0, 0}, want: 0},
|
||||
{name: "OneZero_OtherWins", edges: []int64{0, 500}, want: 500},
|
||||
{name: "BothSet_Smallest", edges: []int64{900, 500}, want: 500},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
assert.Equal(t, testCase.want, EarliestHistoryEdge(testCase.edges...))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDeploymentSpecsFromRows(t *testing.T) {
|
||||
specs, err := NewDeploymentSpecsFromRows([]DeploymentRow{
|
||||
{Service: "a", Environment: "prod", Version: "1", Labels: `{"service.name":"a","host.name":"h1"}`},
|
||||
{Service: "b", Environment: "prod", Version: "2"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, specs, 2)
|
||||
assert.Equal(t, map[string]string{"host.name": "h1"}, specs[0].ResourceAttributes)
|
||||
assert.Equal(t, map[string]string{}, specs[1].ResourceAttributes)
|
||||
|
||||
_, err = NewDeploymentSpecsFromRows([]DeploymentRow{{Labels: `{`}})
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestNewAnnotationsFromDeployments(t *testing.T) {
|
||||
got := NewAnnotationsFromDeployments([]DeploymentSpec{{Service: "a"}}, 7, 1234)
|
||||
require.Len(t, got.Records, 1)
|
||||
assert.Equal(t, AnnotationKindDeployment, got.Records[0].Kind)
|
||||
assert.Equal(t, 7, got.Total)
|
||||
assert.Equal(t, int64(1234), got.HistoryAvailableFrom)
|
||||
|
||||
empty := NewAnnotationsFromDeployments(nil, 0, 0)
|
||||
assert.NotNil(t, empty.Records)
|
||||
assert.Len(t, empty.Records, 0)
|
||||
}
|
||||
2
tests/integration/testdata/annotations/edge_logs.jsonl
vendored
Normal file
2
tests/integration/testdata/annotations/edge_logs.jsonl
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
{ "timestamp": "2026-01-01T01:00:00.000000Z", "resources": { "service.name": "checkout", "deployment.environment": "prod", "service.version": "1.0.0" }, "attributes": {}, "body": "checkout 1.0.0 started", "severity_text": "INFO" }
|
||||
{ "timestamp": "2026-01-01T02:39:30.000000Z", "resources": { "service.name": "checkout", "deployment.environment": "prod", "service.version": "1.1.0" }, "attributes": {}, "body": "checkout 1.1.0 started", "severity_text": "INFO" }
|
||||
2
tests/integration/testdata/annotations/edge_traces.jsonl
vendored
Normal file
2
tests/integration/testdata/annotations/edge_traces.jsonl
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
{"timestamp": "2026-01-01T00:00:00.000000Z", "duration": "PT1.0S", "trace_id": "b0000000000000000000000000000001", "span_id": "b000000000000001", "parent_span_id": "", "name": "GET /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": {"service.name": "checkout", "deployment.environment": "prod", "service.version": "1.0.0", "host.name": "ip-10-0-1-23", "os.type": "linux"}, "attributes": {"net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "GET", "http.response.status_code": "200", "http.request.path": "/checkout"}}
|
||||
{"timestamp": "2026-01-01T02:40:00.000000Z", "duration": "PT1.2S", "trace_id": "b0000000000000000000000000000002", "span_id": "b000000000000002", "parent_span_id": "", "name": "GET /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": {"service.name": "checkout", "deployment.environment": "prod", "service.version": "1.1.0", "host.name": "ip-10-0-1-23", "os.type": "linux"}, "attributes": {"net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "GET", "http.response.status_code": "200", "http.request.path": "/checkout"}}
|
||||
4
tests/integration/testdata/annotations/logs.jsonl
vendored
Normal file
4
tests/integration/testdata/annotations/logs.jsonl
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{ "timestamp": "2026-01-01T00:00:00.000000Z", "resources": { "service.name": "checkout", "deployment.environment": "prod", "service.version": "1.0.0", "k8s.namespace.name": "shop", "custom.team": "payments", "signoz.workspace.key.id": "key-a" }, "attributes": { "code.file": "checkout.py" }, "body": "checkout 1.0.0 started", "severity_text": "INFO" }
|
||||
{ "timestamp": "2026-01-01T02:39:30.000000Z", "resources": { "service.name": "checkout", "deployment.environment": "prod", "service.version": "1.1.0", "k8s.namespace.name": "shop", "custom.team": "payments", "signoz.workspace.key.id": "key-a" }, "attributes": { "code.file": "checkout.py" }, "body": "checkout 1.1.0 started", "severity_text": "INFO" }
|
||||
{ "timestamp": "2026-01-01T02:44:00.000000Z", "resources": { "service.name": "worker", "deployment.environment": "prod", "service.version": "3.0.0", "k8s.namespace.name": "jobs", "custom.team": "payments", "signoz.workspace.key.id": "key-b" }, "attributes": { "code.file": "worker.py" }, "body": "worker 3.0.0 started", "severity_text": "INFO" }
|
||||
{ "timestamp": "2026-01-01T02:47:00.000000Z", "resources": { "service.name": "api", "deployment.environment": "prod", "service.version": "6.0.0", "k8s.namespace.name": "shop", "custom.team": "payments", "signoz.workspace.key.id": "key-b" }, "attributes": { "code.file": "api.py" }, "body": "api 6.0.0 started", "severity_text": "INFO" }
|
||||
10
tests/integration/testdata/annotations/traces.jsonl
vendored
Normal file
10
tests/integration/testdata/annotations/traces.jsonl
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
{"timestamp": "2026-01-01T00:00:00.000000Z", "duration": "PT1.0S", "trace_id": "a0000000000000000000000000000001", "span_id": "a000000000000001", "parent_span_id": "", "name": "GET /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": {"service.name": "checkout", "deployment.environment": "prod", "service.version": "1.0.0", "k8s.namespace.name": "shop", "custom.team": "payments", "signoz.workspace.key.id": "key-a", "host.name": "ip-10-0-1-23", "os.type": "linux"}, "attributes": {"net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "GET", "http.response.status_code": "200", "http.request.path": "/checkout"}}
|
||||
{"timestamp": "2026-01-01T01:00:00.000000Z", "duration": "PT1.2S", "trace_id": "a0000000000000000000000000000002", "span_id": "a000000000000002", "parent_span_id": "", "name": "GET /legacy", "kind": 2, "status_code": 1, "status_message": "", "resources": {"service.name": "legacy", "deployment.environment": "prod", "service.version": "0.5.0", "k8s.namespace.name": "shop", "custom.team": "payments", "signoz.workspace.key.id": "key-a", "host.name": "ip-10-0-1-24", "os.type": "linux"}, "attributes": {"net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "GET", "http.response.status_code": "200", "http.request.path": "/legacy"}}
|
||||
{"timestamp": "2026-01-01T01:40:00.000000Z", "duration": "PT1.4S", "trace_id": "a0000000000000000000000000000003", "span_id": "a000000000000003", "parent_span_id": "", "name": "GET /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": {"service.name": "checkout", "deployment.environment": "prod", "service.version": "1.0.0", "k8s.namespace.name": "shop", "custom.team": "payments", "signoz.workspace.key.id": "key-a", "host.name": "ip-10-0-1-23", "os.type": "linux"}, "attributes": {"net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "GET", "http.response.status_code": "200", "http.request.path": "/checkout"}}
|
||||
{"timestamp": "2026-01-01T02:40:00.000000Z", "duration": "PT1.0S", "trace_id": "a0000000000000000000000000000004", "span_id": "a000000000000004", "parent_span_id": "", "name": "GET /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": {"service.name": "checkout", "deployment.environment": "prod", "service.version": "1.1.0", "k8s.namespace.name": "shop", "custom.team": "payments", "signoz.workspace.key.id": "key-a", "host.name": "ip-10-0-1-23", "os.type": "linux"}, "attributes": {"net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "GET", "http.response.status_code": "200", "http.request.path": "/checkout"}}
|
||||
{"timestamp": "2026-01-01T02:42:00.000000Z", "duration": "PT1.2S", "trace_id": "a0000000000000000000000000000005", "span_id": "a000000000000005", "parent_span_id": "", "name": "GET /cart", "kind": 2, "status_code": 1, "status_message": "", "resources": {"service.name": "cart", "deployment.environment": "prod", "service.version": "2.0.0", "k8s.namespace.name": "shop", "custom.team": "payments", "signoz.workspace.key.id": "key-b", "host.name": "ip-10-0-1-25", "os.type": "linux"}, "attributes": {"net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "GET", "http.response.status_code": "200", "http.request.path": "/cart"}}
|
||||
{"timestamp": "2026-01-01T02:44:00.000000Z", "duration": "PT1.4S", "trace_id": "a0000000000000000000000000000006", "span_id": "a000000000000006", "parent_span_id": "", "name": "run job", "kind": 1, "status_code": 1, "status_message": "", "resources": {"service.name": "worker", "deployment.environment": "prod", "service.version": "3.0.0", "k8s.namespace.name": "jobs", "custom.team": "payments", "signoz.workspace.key.id": "key-b", "host.name": "ip-10-0-2-10", "os.type": "linux"}, "attributes": {"net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "job"}}
|
||||
{"timestamp": "2026-01-01T02:46:00.000000Z", "duration": "PT1.0S", "trace_id": "a0000000000000000000000000000007", "span_id": "a000000000000007", "parent_span_id": "", "name": "GET /tie", "kind": 2, "status_code": 1, "status_message": "", "resources": {"service.name": "tieA", "deployment.environment": "prod", "service.version": "4.0.0", "k8s.namespace.name": "shop", "custom.team": "payments", "signoz.workspace.key.id": "key-a", "host.name": "ip-10-0-1-26", "os.type": "linux"}, "attributes": {"net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "GET", "http.response.status_code": "200", "http.request.path": "/tie"}}
|
||||
{"timestamp": "2026-01-01T02:46:00.000000Z", "duration": "PT1.2S", "trace_id": "a0000000000000000000000000000008", "span_id": "a000000000000008", "parent_span_id": "", "name": "GET /tie", "kind": 2, "status_code": 1, "status_message": "", "resources": {"service.name": "tieB", "deployment.environment": "prod", "service.version": "4.0.0", "k8s.namespace.name": "shop", "custom.team": "payments", "signoz.workspace.key.id": "key-a", "host.name": "ip-10-0-1-27", "os.type": "linux"}, "attributes": {"net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "GET", "http.response.status_code": "200", "http.request.path": "/tie"}}
|
||||
{"timestamp": "2026-01-01T02:48:00.000000Z", "duration": "PT1.4S", "trace_id": "a0000000000000000000000000000009", "span_id": "a000000000000009", "parent_span_id": "", "name": "GET /special", "kind": 2, "status_code": 1, "status_message": "", "resources": {"service.name": "sp`ec'ial", "deployment.environment": "pr$0d", "service.version": "5.0.0$0", "k8s.namespace.name": "shop", "custom.team": "payments", "signoz.workspace.key.id": "key-a", "host.name": "ip-10-0-1-28", "os.type": "linux"}, "attributes": {"net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "GET", "http.response.status_code": "200", "http.request.path": "/special"}}
|
||||
{"timestamp": "2026-01-01T02:50:00.000000Z", "duration": "PT1.0S", "trace_id": "a000000000000000000000000000000a", "span_id": "a00000000000000a", "parent_span_id": "", "name": "GET /noversion", "kind": 2, "status_code": 1, "status_message": "", "resources": {"service.name": "noversion", "deployment.environment": "prod", "service.version": "", "k8s.namespace.name": "shop", "custom.team": "payments", "signoz.workspace.key.id": "key-a", "host.name": "ip-10-0-1-29", "os.type": "linux"}, "attributes": {"net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "GET", "http.response.status_code": "200", "http.request.path": "/noversion"}}
|
||||
418
tests/integration/tests/annotations/01_deployments.py
Normal file
418
tests/integration/tests/annotations/01_deployments.py
Normal file
@@ -0,0 +1,418 @@
|
||||
import re
|
||||
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.fs import get_testdata_file_path
|
||||
from fixtures.logs import Logs
|
||||
from fixtures.traces import Traces
|
||||
|
||||
ENDPOINT = "/api/v1/annotations"
|
||||
IDENTITY_KEYS = ("service.name", "deployment.environment", "deployment.environment.name", "service.version")
|
||||
# load_from_file shifts the earliest dataset row (the baseline tuple) to now - BASELINE_OFFSET.
|
||||
BASELINE_OFFSET = timedelta(hours=3)
|
||||
ALL_MARKERS = {("checkout", "1.1.0"), ("cart", "2.0.0"), ("worker", "3.0.0"), ("tieA", "4.0.0"), ("tieB", "4.0.0"), ("api", "6.0.0"), ("sp`ec'ial", "5.0.0$0")}
|
||||
|
||||
|
||||
def test_deployments_empty_tables(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token,
|
||||
insert_traces, # pylint: disable=unused-argument
|
||||
insert_logs, # pylint: disable=unused-argument
|
||||
) -> None:
|
||||
"""No telemetry at all: 200 with no records and no history edge."""
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(ENDPOINT),
|
||||
headers={"authorization": f"Bearer {token}"},
|
||||
json={"kind": "deployment", "start": int((now - timedelta(hours=1)).timestamp() * 1000), "end": int(now.timestamp() * 1000), "limit": 50},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["data"] == {"records": [], "total": 0, "historyAvailableFrom": 0}
|
||||
|
||||
|
||||
def test_deployments_accuracy(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token,
|
||||
insert_traces,
|
||||
insert_logs,
|
||||
) -> None:
|
||||
"""Seed both signals; assert the full record contract, exact first-seen times,
|
||||
deep-link ids, stripped identity keys, ordering, merge winners and the history edge."""
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
traces = Traces.load_from_file(get_testdata_file_path("annotations/traces.jsonl"), base_time=now - BASELINE_OFFSET)
|
||||
logs = Logs.load_from_file(get_testdata_file_path("annotations/logs.jsonl"), base_time=now - BASELINE_OFFSET)
|
||||
insert_traces(traces)
|
||||
insert_logs(logs)
|
||||
span_of: dict[tuple[str, str], Traces] = {}
|
||||
for span in sorted(traces, key=lambda span: span.timestamp):
|
||||
span_of.setdefault((span.resources_string["service.name"], span.resources_string["service.version"]), span)
|
||||
log_of: dict[tuple[str, str], Logs] = {}
|
||||
for log in sorted(logs, key=lambda log: log.timestamp):
|
||||
log_of.setdefault((log.resources_string["service.name"], log.resources_string["service.version"]), log)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(ENDPOINT),
|
||||
headers={"authorization": f"Bearer {token}"},
|
||||
json={"kind": "deployment", "start": int((now - timedelta(hours=1)).timestamp() * 1000), "end": int(now.timestamp() * 1000), "limit": 50},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
data = response.json()["data"]
|
||||
|
||||
# Ordered by firstSeen then service. checkout 1.1.0: logs 30s before traces, logs wins.
|
||||
# worker 3.0.0: same timestamp on both signals, traces wins. tieA/tieB: same timestamp.
|
||||
# checkout 1.0.0 (oldest bucket), legacy 0.5.0 (before start), noversion (empty version) are not markers.
|
||||
expected = [
|
||||
("checkout", "1.1.0", "logs"),
|
||||
("cart", "2.0.0", "traces"),
|
||||
("worker", "3.0.0", "traces"),
|
||||
("tieA", "4.0.0", "traces"),
|
||||
("tieB", "4.0.0", "traces"),
|
||||
("api", "6.0.0", "logs"),
|
||||
("sp`ec'ial", "5.0.0$0", "traces"),
|
||||
]
|
||||
assert [(r["spec"]["service"], r["spec"]["version"], r["spec"]["signal"]) for r in data["records"]] == expected
|
||||
assert data["total"] == len(expected)
|
||||
assert data["historyAvailableFrom"] == (int((now - BASELINE_OFFSET).timestamp()) // 1800 * 1800 + 1800) * 1000
|
||||
|
||||
for record in data["records"]:
|
||||
assert record["kind"] == "deployment"
|
||||
spec = record["spec"]
|
||||
for field in ("id", "signal", "service", "environment", "version", "firstSeen", "traceId", "spanId", "logId", "resourceAttributes"):
|
||||
assert field in spec, f"missing {field} in {spec!r}"
|
||||
assert re.fullmatch(r"dep_[0-9a-f]{8}", spec["id"])
|
||||
key = (spec["service"], spec["version"])
|
||||
if spec["signal"] == "traces":
|
||||
span = span_of[key]
|
||||
assert spec["firstSeen"] == int(span.timestamp.timestamp() * 1000)
|
||||
assert (spec["traceId"], spec["spanId"], spec["logId"]) == (span.trace_id, span.span_id, "")
|
||||
resources = span.resources_string
|
||||
else:
|
||||
log = log_of[key]
|
||||
assert spec["firstSeen"] == int(log.timestamp) // 1_000_000
|
||||
assert (spec["traceId"], spec["spanId"], spec["logId"]) == ("", "", log.id)
|
||||
resources = log.resources_string
|
||||
assert spec["environment"] == resources["deployment.environment"]
|
||||
assert spec["resourceAttributes"] == {k: v for k, v in resources.items() if k not in IDENTITY_KEYS}
|
||||
|
||||
assert len({r["spec"]["id"] for r in data["records"]}) == len(expected)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("start_anchor", "start_offset", "end_anchor", "end_offset", "expected"),
|
||||
[
|
||||
pytest.param("cart", 0, "now", 0, ALL_MARKERS - {("checkout", "1.1.0")}, id="start_at_first_row_inclusive"),
|
||||
pytest.param("cart", 1, "now", 0, ALL_MARKERS - {("checkout", "1.1.0"), ("cart", "2.0.0")}, id="start_after_first_row"),
|
||||
pytest.param("baseline", 7200, "cart", 0, {("checkout", "1.1.0"), ("cart", "2.0.0")}, id="end_at_first_row_inclusive"),
|
||||
pytest.param("baseline", 7200, "cart", -1, {("checkout", "1.1.0")}, id="end_before_first_row"),
|
||||
pytest.param("baseline", -60, "baseline", 60, set(), id="oldest_bucket_only"),
|
||||
pytest.param("baseline", -7200, "baseline", -3600, set(), id="window_before_data"),
|
||||
pytest.param("now", -300, "now", 0, set(), id="window_after_data"),
|
||||
],
|
||||
)
|
||||
def test_deployments_exclusions( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token,
|
||||
insert_traces,
|
||||
insert_logs,
|
||||
start_anchor: str,
|
||||
start_offset: int,
|
||||
end_anchor: str,
|
||||
end_offset: int,
|
||||
expected: set[tuple[str, str]],
|
||||
) -> None:
|
||||
"""Window bounds bind exactly on first_seen (inclusive both ends). Rows in the oldest
|
||||
bucket, before start, or outside the window never produce markers, and the history
|
||||
edge is reported even when nothing matches."""
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
traces = Traces.load_from_file(get_testdata_file_path("annotations/traces.jsonl"), base_time=now - BASELINE_OFFSET)
|
||||
logs = Logs.load_from_file(get_testdata_file_path("annotations/logs.jsonl"), base_time=now - BASELINE_OFFSET)
|
||||
insert_traces(traces)
|
||||
insert_logs(logs)
|
||||
|
||||
anchors = {"cart": next(span.timestamp for span in traces if span.resources_string["service.name"] == "cart"), "baseline": now - BASELINE_OFFSET, "now": now}
|
||||
start = anchors[start_anchor] + timedelta(seconds=start_offset)
|
||||
end = anchors[end_anchor] + timedelta(seconds=end_offset)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(ENDPOINT),
|
||||
headers={"authorization": f"Bearer {token}"},
|
||||
json={"kind": "deployment", "start": int(start.timestamp() * 1000), "end": int(end.timestamp() * 1000), "limit": 50},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
data = response.json()["data"]
|
||||
assert {(r["spec"]["service"], r["spec"]["version"]) for r in data["records"]} == expected
|
||||
assert data["total"] == len(expected)
|
||||
assert data["historyAvailableFrom"] == (int((now - BASELINE_OFFSET).timestamp()) // 1800 * 1800 + 1800) * 1000
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("signals", "expected"),
|
||||
[
|
||||
pytest.param(None, {("checkout", "1.1.0", "logs"), ("cart", "2.0.0", "traces"), ("worker", "3.0.0", "traces"), ("tieA", "4.0.0", "traces"), ("tieB", "4.0.0", "traces"), ("api", "6.0.0", "logs"), ("sp`ec'ial", "5.0.0$0", "traces")}, id="unset_both"),
|
||||
pytest.param([], {("checkout", "1.1.0", "logs"), ("cart", "2.0.0", "traces"), ("worker", "3.0.0", "traces"), ("tieA", "4.0.0", "traces"), ("tieB", "4.0.0", "traces"), ("api", "6.0.0", "logs"), ("sp`ec'ial", "5.0.0$0", "traces")}, id="empty_list_both"),
|
||||
pytest.param(["traces"], {("checkout", "1.1.0", "traces"), ("cart", "2.0.0", "traces"), ("worker", "3.0.0", "traces"), ("tieA", "4.0.0", "traces"), ("tieB", "4.0.0", "traces"), ("sp`ec'ial", "5.0.0$0", "traces")}, id="traces_only"),
|
||||
pytest.param(["logs"], {("checkout", "1.1.0", "logs"), ("worker", "3.0.0", "logs"), ("api", "6.0.0", "logs")}, id="logs_only"),
|
||||
pytest.param(["logs", "traces"], {("checkout", "1.1.0", "logs"), ("cart", "2.0.0", "traces"), ("worker", "3.0.0", "traces"), ("tieA", "4.0.0", "traces"), ("tieB", "4.0.0", "traces"), ("api", "6.0.0", "logs"), ("sp`ec'ial", "5.0.0$0", "traces")}, id="explicit_both"),
|
||||
],
|
||||
)
|
||||
def test_deployments_signals( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token,
|
||||
insert_traces,
|
||||
insert_logs,
|
||||
signals: list[str] | None,
|
||||
expected: set[tuple[str, str, str]],
|
||||
) -> None:
|
||||
"""Signal selection: a single signal only ever reports that signal and its ids; the
|
||||
deployment id of a tuple is the same whichever signal reports it."""
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
traces = Traces.load_from_file(get_testdata_file_path("annotations/traces.jsonl"), base_time=now - BASELINE_OFFSET)
|
||||
logs = Logs.load_from_file(get_testdata_file_path("annotations/logs.jsonl"), base_time=now - BASELINE_OFFSET)
|
||||
insert_traces(traces)
|
||||
insert_logs(logs)
|
||||
|
||||
body: dict = {"kind": "deployment", "start": int((now - timedelta(hours=1)).timestamp() * 1000), "end": int(now.timestamp() * 1000), "limit": 50}
|
||||
if signals is not None:
|
||||
body["signals"] = signals
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = requests.post(signoz.self.host_configs["8080"].get(ENDPOINT), headers={"authorization": f"Bearer {token}"}, json=body, timeout=5)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
data = response.json()["data"]
|
||||
assert {(r["spec"]["service"], r["spec"]["version"], r["spec"]["signal"]) for r in data["records"]} == expected
|
||||
assert data["total"] == len(expected)
|
||||
for record in data["records"]:
|
||||
spec = record["spec"]
|
||||
if spec["signal"] == "traces":
|
||||
assert spec["traceId"] and spec["spanId"] and spec["logId"] == ""
|
||||
else:
|
||||
assert spec["logId"] and spec["traceId"] == "" and spec["spanId"] == ""
|
||||
|
||||
# Same tuple, other signal: same deployment id.
|
||||
other = requests.post(
|
||||
signoz.self.host_configs["8080"].get(ENDPOINT),
|
||||
headers={"authorization": f"Bearer {token}"},
|
||||
json={**body, "signals": ["traces"] if signals == ["logs"] else ["logs"]},
|
||||
timeout=5,
|
||||
)
|
||||
assert other.status_code == HTTPStatus.OK, other.text
|
||||
ids = {(r["spec"]["service"], r["spec"]["version"]): r["spec"]["id"] for r in data["records"]}
|
||||
for record in other.json()["data"]["records"]:
|
||||
key = (record["spec"]["service"], record["spec"]["version"])
|
||||
if key in ids:
|
||||
assert record["spec"]["id"] == ids[key]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("signals", "edge_offset"),
|
||||
[
|
||||
pytest.param(None, timedelta(hours=3), id="both_earliest_edge"),
|
||||
pytest.param(["traces"], timedelta(hours=3), id="traces_edge"),
|
||||
pytest.param(["logs"], timedelta(hours=2), id="logs_edge"),
|
||||
],
|
||||
)
|
||||
def test_deployments_history_edge( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token,
|
||||
insert_traces,
|
||||
insert_logs,
|
||||
signals: list[str] | None,
|
||||
edge_offset: timedelta,
|
||||
) -> None:
|
||||
"""historyAvailableFrom is the bucket after the oldest resource bucket of the queried
|
||||
signals; with both signals it is the earlier of the two edges."""
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
insert_traces(Traces.load_from_file(get_testdata_file_path("annotations/edge_traces.jsonl"), base_time=now - timedelta(hours=3)))
|
||||
insert_logs(Logs.load_from_file(get_testdata_file_path("annotations/edge_logs.jsonl"), base_time=now - timedelta(hours=2)))
|
||||
|
||||
body: dict = {"kind": "deployment", "start": int((now - timedelta(hours=1)).timestamp() * 1000), "end": int(now.timestamp() * 1000), "limit": 50}
|
||||
if signals is not None:
|
||||
body["signals"] = signals
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = requests.post(signoz.self.host_configs["8080"].get(ENDPOINT), headers={"authorization": f"Bearer {token}"}, json=body, timeout=5)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
data = response.json()["data"]
|
||||
assert data["historyAvailableFrom"] == (int((now - edge_offset).timestamp()) // 1800 * 1800 + 1800) * 1000
|
||||
assert {(r["spec"]["service"], r["spec"]["version"]) for r in data["records"]} == {("checkout", "1.1.0")}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("expression", "expected"),
|
||||
[
|
||||
pytest.param("k8s.namespace.name = 'jobs'", {("worker", "3.0.0")}, id="eq"),
|
||||
pytest.param("k8s.namespace.name IN ('jobs', 'shop')", ALL_MARKERS, id="in"),
|
||||
pytest.param("k8s.namespace.name NOT IN ('shop')", {("worker", "3.0.0")}, id="not_in"),
|
||||
pytest.param("service.name CONTAINS 'tie'", {("tieA", "4.0.0"), ("tieB", "4.0.0")}, id="contains"),
|
||||
pytest.param("service.name LIKE 'tie%'", {("tieA", "4.0.0"), ("tieB", "4.0.0")}, id="like"),
|
||||
pytest.param("k8s.namespace.name = 'shop' AND service.name = 'cart'", {("cart", "2.0.0")}, id="and"),
|
||||
pytest.param("service.name = 'cart' OR service.name = 'api'", {("cart", "2.0.0"), ("api", "6.0.0")}, id="or_across_signals"),
|
||||
pytest.param("deployment.environment = 'pr$0d'", {("sp`ec'ial", "5.0.0$0")}, id="dollar_in_value"),
|
||||
pytest.param('service.name = "sp`ec\'ial"', {("sp`ec'ial", "5.0.0$0")}, id="backtick_quote_in_value"),
|
||||
pytest.param("custom.team = 'payments'", ALL_MARKERS, id="custom_resource_attribute"),
|
||||
pytest.param("signoz.workspace.key.id = 'key-b'", {("cart", "2.0.0"), ("worker", "3.0.0"), ("api", "6.0.0")}, id="workspace_key"),
|
||||
pytest.param("service.name = 'nope'", set(), id="no_match"),
|
||||
pytest.param("k8s.namespace.namee = 'jobs'", ALL_MARKERS, id="unresolved_key_noop"),
|
||||
pytest.param("http.request.method = 'GET'", ALL_MARKERS, id="span_attribute_noop"),
|
||||
pytest.param("", ALL_MARKERS, id="empty_expression"),
|
||||
],
|
||||
)
|
||||
def test_deployments_filter( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token,
|
||||
insert_traces,
|
||||
insert_logs,
|
||||
expression: str,
|
||||
expected: set[tuple[str, str]],
|
||||
) -> None:
|
||||
"""Filters apply to resource attributes on both signals; keys that are not resource
|
||||
attributes compile to nothing and leave the result unfiltered."""
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
insert_traces(Traces.load_from_file(get_testdata_file_path("annotations/traces.jsonl"), base_time=now - BASELINE_OFFSET))
|
||||
insert_logs(Logs.load_from_file(get_testdata_file_path("annotations/logs.jsonl"), base_time=now - BASELINE_OFFSET))
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(ENDPOINT),
|
||||
headers={"authorization": f"Bearer {token}"},
|
||||
json={"kind": "deployment", "start": int((now - timedelta(hours=1)).timestamp() * 1000), "end": int(now.timestamp() * 1000), "limit": 50, "filter": {"expression": expression}},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
data = response.json()["data"]
|
||||
assert {(r["spec"]["service"], r["spec"]["version"]) for r in data["records"]} == expected
|
||||
assert data["total"] == len(expected)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"expression",
|
||||
[
|
||||
pytest.param("service.name =", id="trailing_op"),
|
||||
pytest.param("(service.name = 'cart'", id="unclosed_paren"),
|
||||
],
|
||||
)
|
||||
def test_deployments_filter_invalid(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token,
|
||||
expression: str,
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(ENDPOINT),
|
||||
headers={"authorization": f"Bearer {token}"},
|
||||
json={"kind": "deployment", "start": int((now - timedelta(hours=1)).timestamp() * 1000), "end": int(now.timestamp() * 1000), "limit": 50, "filter": {"expression": expression}},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
assert "syntax error" in response.json()["error"]["message"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("limit", "expected_len"),
|
||||
[
|
||||
pytest.param(1, 1, id="below_count"),
|
||||
pytest.param(7, 7, id="equals_count"),
|
||||
pytest.param(5000, 7, id="above_count"),
|
||||
],
|
||||
)
|
||||
def test_deployments_limit( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token,
|
||||
insert_traces,
|
||||
insert_logs,
|
||||
limit: int,
|
||||
expected_len: int,
|
||||
) -> None:
|
||||
"""limit truncates the merged, ordered list; total always counts every marker."""
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
insert_traces(Traces.load_from_file(get_testdata_file_path("annotations/traces.jsonl"), base_time=now - BASELINE_OFFSET))
|
||||
insert_logs(Logs.load_from_file(get_testdata_file_path("annotations/logs.jsonl"), base_time=now - BASELINE_OFFSET))
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(ENDPOINT),
|
||||
headers={"authorization": f"Bearer {token}"},
|
||||
json={"kind": "deployment", "start": int((now - timedelta(hours=1)).timestamp() * 1000), "end": int(now.timestamp() * 1000), "limit": limit},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
data = response.json()["data"]
|
||||
assert len(data["records"]) == expected_len
|
||||
assert data["total"] == len(ALL_MARKERS)
|
||||
assert (data["records"][0]["spec"]["service"], data["records"][0]["spec"]["version"]) == ("checkout", "1.1.0")
|
||||
assert [r["spec"]["firstSeen"] for r in data["records"]] == sorted(r["spec"]["firstSeen"] for r in data["records"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("overrides", "message"),
|
||||
[
|
||||
pytest.param({"kind": None}, "invalid kind", id="kind_missing"),
|
||||
pytest.param({"kind": "release"}, "invalid kind", id="kind_unknown"),
|
||||
pytest.param({"start": 0}, "start must be greater than 0", id="start_zero"),
|
||||
pytest.param({"start": -1}, "start must be greater than 0", id="start_negative"),
|
||||
pytest.param({"end": 0}, "end must be greater than 0", id="end_zero"),
|
||||
pytest.param({"end": "same"}, "must be less than end", id="start_equals_end"),
|
||||
pytest.param({"end": "before"}, "must be less than end", id="start_greater_than_end"),
|
||||
pytest.param({"signals": ["metrics"]}, "invalid signal", id="signal_unknown"),
|
||||
pytest.param({"limit": None}, "limit must be between", id="limit_missing"),
|
||||
pytest.param({"limit": 0}, "limit must be between", id="limit_zero"),
|
||||
pytest.param({"limit": -1}, "limit must be between", id="limit_negative"),
|
||||
pytest.param({"limit": 5001}, "limit must be between", id="limit_too_large"),
|
||||
],
|
||||
)
|
||||
def test_deployments_validation_errors(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token,
|
||||
overrides: dict,
|
||||
message: str,
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
body: dict = {"kind": "deployment", "start": int((now - timedelta(hours=1)).timestamp() * 1000), "end": int(now.timestamp() * 1000), "limit": 50}
|
||||
for key, value in overrides.items():
|
||||
if value is None:
|
||||
body.pop(key)
|
||||
elif value == "same":
|
||||
body[key] = body["start"]
|
||||
elif value == "before":
|
||||
body[key] = body["start"] - 1
|
||||
else:
|
||||
body[key] = value
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = requests.post(signoz.self.host_configs["8080"].get(ENDPOINT), headers={"authorization": f"Bearer {token}"}, json=body, timeout=5)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
assert message in response.json()["error"]["message"]
|
||||
|
||||
|
||||
def test_deployments_malformed_body(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token,
|
||||
) -> None:
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(ENDPOINT),
|
||||
headers={"authorization": f"Bearer {token}", "content-type": "application/json"},
|
||||
data='{"kind": "deployment", "start": ',
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
150
tests/integration/tests/annotations/02_authz.py
Normal file
150
tests/integration/tests/annotations/02_authz.py
Normal file
@@ -0,0 +1,150 @@
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from wiremock.resources.mappings import Mapping
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import (
|
||||
USER_ADMIN_EMAIL,
|
||||
USER_ADMIN_PASSWORD,
|
||||
add_license,
|
||||
change_user_role,
|
||||
create_active_user,
|
||||
)
|
||||
from fixtures.fs import get_testdata_file_path
|
||||
from fixtures.logs import Logs
|
||||
from fixtures.role import transaction_group
|
||||
from fixtures.traces import Traces
|
||||
|
||||
ENDPOINT = "/api/v1/annotations"
|
||||
user_password = "password123Z$"
|
||||
users = {
|
||||
"metrics_only": ("annotations-metrics-only", "annotations-metrics-only@telemetry.test"),
|
||||
"traces_only": ("annotations-traces-only", "annotations-traces-only@telemetry.test"),
|
||||
"logs_only": ("annotations-logs-only", "annotations-logs-only@telemetry.test"),
|
||||
"both_wildcard": ("annotations-both-wildcard", "annotations-both-wildcard@telemetry.test"),
|
||||
"both_scoped": ("annotations-both-scoped", "annotations-both-scoped@telemetry.test"),
|
||||
}
|
||||
|
||||
|
||||
def test_setup(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
|
||||
get_token: Callable[[str, str], str],
|
||||
create_role: Callable[..., str],
|
||||
) -> None:
|
||||
add_license(signoz, make_http_mocks, get_token)
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
grants = {
|
||||
"metrics_only": [transaction_group("read", "telemetryresource", "metrics", ["builder_query/*"])],
|
||||
"traces_only": [transaction_group("read", "telemetryresource", "traces", ["builder_query/*"])],
|
||||
"logs_only": [transaction_group("read", "telemetryresource", "logs", ["builder_query/*"])],
|
||||
"both_wildcard": [
|
||||
transaction_group("read", "telemetryresource", "traces", ["builder_query/*"]),
|
||||
transaction_group("read", "telemetryresource", "logs", ["builder_query/*"]),
|
||||
],
|
||||
"both_scoped": [
|
||||
transaction_group("read", "telemetryresource", "traces", ["builder_query/signoz.workspace.key.id/key-a"]),
|
||||
transaction_group("read", "telemetryresource", "logs", ["builder_query/signoz.workspace.key.id/key-a"]),
|
||||
],
|
||||
}
|
||||
for name, (role, email) in users.items():
|
||||
create_role(admin_token, role, grants[name])
|
||||
user = create_active_user(signoz, admin_token, email=email, role="signoz-viewer", password=user_password)
|
||||
change_user_role(signoz, admin_token, user, "signoz-viewer", role)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("user", "expected"),
|
||||
[
|
||||
pytest.param("metrics_only", HTTPStatus.FORBIDDEN, id="metrics_only_denied"),
|
||||
pytest.param("traces_only", HTTPStatus.FORBIDDEN, id="traces_only_denied"),
|
||||
pytest.param("logs_only", HTTPStatus.FORBIDDEN, id="logs_only_denied"),
|
||||
pytest.param("both_wildcard", HTTPStatus.OK, id="both_wildcard_allowed"),
|
||||
pytest.param("both_scoped", HTTPStatus.FORBIDDEN, id="both_scoped_unfiltered_denied"),
|
||||
pytest.param("admin", HTTPStatus.OK, id="admin_allowed"),
|
||||
pytest.param(None, HTTPStatus.UNAUTHORIZED, id="anonymous"),
|
||||
],
|
||||
)
|
||||
def test_list_route(
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
user: str | None,
|
||||
expected: HTTPStatus,
|
||||
) -> None:
|
||||
"""The route declares traces and logs read; a grant on only one signal is denied."""
|
||||
now = datetime.now(tz=UTC)
|
||||
headers = {}
|
||||
if user == "admin":
|
||||
headers["Authorization"] = f"Bearer {get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)}"
|
||||
elif user is not None:
|
||||
headers["Authorization"] = f"Bearer {get_token(users[user][1], user_password)}"
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(ENDPOINT),
|
||||
json={"kind": "deployment", "start": int((now - timedelta(minutes=10)).timestamp() * 1000), "end": int(now.timestamp() * 1000), "limit": 10},
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
)
|
||||
assert response.status_code == expected, response.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("user", ["metrics_only", "traces_only", "logs_only"])
|
||||
def test_denied_message_names_wildcard(
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
user: str,
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC)
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(ENDPOINT),
|
||||
json={"kind": "deployment", "start": int((now - timedelta(minutes=10)).timestamp() * 1000), "end": int(now.timestamp() * 1000), "limit": 10},
|
||||
headers={"Authorization": f"Bearer {get_token(users[user][1], user_password)}"},
|
||||
timeout=10,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN, response.text
|
||||
assert "builder_query/*" in response.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("user", "expression", "expected", "expected_markers"),
|
||||
[
|
||||
pytest.param("both_scoped", "signoz.workspace.key.id = 'key-a'", HTTPStatus.OK, {("checkout", "1.1.0"), ("tieA", "4.0.0"), ("tieB", "4.0.0"), ("sp`ec'ial", "5.0.0$0")}, id="scoped_granted_key"),
|
||||
pytest.param("both_scoped", "signoz.workspace.key.id = 'key-b'", HTTPStatus.FORBIDDEN, set(), id="scoped_other_key"),
|
||||
pytest.param("both_scoped", "signoz.workspace.key.id IN ('key-a', 'key-b')", HTTPStatus.FORBIDDEN, set(), id="scoped_both_keys"),
|
||||
pytest.param("both_scoped", "signoz.workspace.key.id = 'key-a' AND k8s.namespace.name = 'shop'", HTTPStatus.OK, {("checkout", "1.1.0"), ("tieA", "4.0.0"), ("tieB", "4.0.0"), ("sp`ec'ial", "5.0.0$0")}, id="scoped_granted_key_with_extra_filter"),
|
||||
pytest.param("both_wildcard", None, HTTPStatus.OK, {("checkout", "1.1.0"), ("cart", "2.0.0"), ("worker", "3.0.0"), ("tieA", "4.0.0"), ("tieB", "4.0.0"), ("api", "6.0.0"), ("sp`ec'ial", "5.0.0$0")}, id="wildcard_unfiltered"),
|
||||
],
|
||||
)
|
||||
def test_scoped_read( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
insert_logs: Callable[[list[Logs]], None],
|
||||
user: str,
|
||||
expression: str | None,
|
||||
expected: HTTPStatus,
|
||||
expected_markers: set[tuple[str, str]],
|
||||
) -> None:
|
||||
"""A key-scoped grant on both signals passes only when the filter pins that key, and
|
||||
the response then carries only that key's markers from either signal."""
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
insert_traces(Traces.load_from_file(get_testdata_file_path("annotations/traces.jsonl"), base_time=now - timedelta(hours=3)))
|
||||
insert_logs(Logs.load_from_file(get_testdata_file_path("annotations/logs.jsonl"), base_time=now - timedelta(hours=3)))
|
||||
|
||||
body: dict = {"kind": "deployment", "start": int((now - timedelta(hours=1)).timestamp() * 1000), "end": int(now.timestamp() * 1000), "limit": 50}
|
||||
if expression:
|
||||
body["filter"] = {"expression": expression}
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(ENDPOINT),
|
||||
json=body,
|
||||
headers={"Authorization": f"Bearer {get_token(users[user][1], user_password)}"},
|
||||
timeout=10,
|
||||
)
|
||||
assert response.status_code == expected, response.text
|
||||
if expected == HTTPStatus.OK:
|
||||
assert {(r["spec"]["service"], r["spec"]["version"]) for r in response.json()["data"]["records"]} == expected_markers
|
||||
Reference in New Issue
Block a user