Compare commits

..

1 Commits

Author SHA1 Message Date
nityanandagohain
dcbd3e0747 chore: improvements for skip fingerprint 2026-06-03 15:55:11 +05:30
46 changed files with 337 additions and 3486 deletions

View File

@@ -432,7 +432,7 @@ cloudintegration:
version: v0.0.8
##################### Trace Detail #####################
traces:
tracedetail:
waterfall:
# Number of spans returned per request when the trace is too large to show all at once.
span_page_size: 500

View File

@@ -1261,7 +1261,6 @@ components:
- sqs
- storageaccountsblob
- cdnprofile
- appservice
type: string
CloudintegrationtypesServiceMetadata:
properties:

View File

@@ -2574,7 +2574,6 @@ export enum CloudintegrationtypesServiceIDDTO {
sqs = 'sqs',
storageaccountsblob = 'storageaccountsblob',
cdnprofile = 'cdnprofile',
appservice = 'appservice',
}
export type CloudintegrationtypesCloudIntegrationServiceDTOAnyOf = {
/**

View File

@@ -1,5 +0,0 @@
.container {
display: flex;
flex-direction: column;
height: 100%;
}

View File

@@ -1,198 +0,0 @@
import { useEffect, useState } from 'react';
import { FullScreenHandle } from 'react-full-screen';
import { useTranslation } from 'react-i18next';
import { useCopyToClipboard } from 'react-use';
import {
ClipboardCopy,
Ellipsis,
FileJson,
Fullscreen,
LockKeyhole,
PenLine,
Plus,
} from '@signozhq/icons';
import { Popover } from 'antd';
import { Button } from '@signozhq/ui/button';
import { toast } from '@signozhq/ui/sonner';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { DeleteButton } from 'container/ListOfDashboard/TableComponents/DeleteButton';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import { useAppContext } from 'providers/App/App';
import { USER_ROLES } from 'types/roles';
import styles from '../DashboardDescription.module.scss';
interface Props {
dashboard: DashboardtypesGettableDashboardV2DTO;
handle: FullScreenHandle;
isDashboardLocked: boolean;
editDashboard: boolean;
isAuthor: boolean;
addPanelPermission: boolean;
onAddPanel: () => void;
onLockToggle: () => void;
onOpenRename: () => void;
}
function DashboardActions({
dashboard,
handle,
isDashboardLocked,
editDashboard,
isAuthor,
addPanelPermission,
onAddPanel,
onLockToggle,
onOpenRename,
}: Props): JSX.Element {
const { user } = useAppContext();
const { t } = useTranslation(['dashboard', 'common']);
const id = dashboard.id;
const title = dashboard.spec?.display?.name ?? '';
const [isDashboardSettingsOpen, setIsDashboardSettingsOpen] =
useState<boolean>(false);
const [state, setCopy] = useCopyToClipboard();
useEffect(() => {
if (state.error) {
toast.error(t('something_went_wrong', { ns: 'common' }));
}
if (state.value) {
toast.success(t('success', { ns: 'common' }));
}
}, [state.error, state.value, t]);
const dashboardDataJSON = (): string => JSON.stringify(dashboard, null, 2);
const exportJSON = (): void => {
const blob = new Blob([dashboardDataJSON()], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${title || 'dashboard'}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
return (
<div className={styles.rightSection}>
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
<Popover
open={isDashboardSettingsOpen}
arrow={false}
onOpenChange={(visible): void => setIsDashboardSettingsOpen(visible)}
rootClassName={styles.dashboardSettings}
content={
<div className={styles.menuContent}>
<section className={styles.section1}>
{(isAuthor || user.role === USER_ROLES.ADMIN) && (
<TooltipSimple
title={
dashboard.createdBy === 'integration'
? 'Dashboards created by integrations cannot be unlocked'
: ''
}
>
<Button
variant="ghost"
prefix={<LockKeyhole size={14} />}
disabled={dashboard.createdBy === 'integration'}
onClick={(): void => {
setIsDashboardSettingsOpen(false);
onLockToggle();
}}
testId="lock-unlock-dashboard"
>
{isDashboardLocked ? 'Unlock Dashboard' : 'Lock Dashboard'}
</Button>
</TooltipSimple>
)}
{!isDashboardLocked && editDashboard && (
<Button
variant="ghost"
prefix={<PenLine size={14} />}
onClick={(): void => {
onOpenRename();
setIsDashboardSettingsOpen(false);
}}
>
Rename
</Button>
)}
<Button
variant="ghost"
prefix={<Fullscreen size={14} />}
onClick={handle.enter}
>
Full screen
</Button>
</section>
<section className={styles.section2}>
<Button
variant="ghost"
prefix={<FileJson size={14} />}
onClick={(): void => {
exportJSON();
setIsDashboardSettingsOpen(false);
}}
>
Export JSON
</Button>
<Button
variant="ghost"
prefix={<ClipboardCopy size={14} />}
onClick={(): void => {
setCopy(dashboardDataJSON());
setIsDashboardSettingsOpen(false);
}}
>
Copy as JSON
</Button>
</section>
<section className={styles.deleteDashboard}>
<DeleteButton
createdBy={dashboard.createdBy || ''}
name={title}
id={id}
isLocked={isDashboardLocked}
routeToListPage
/>
</section>
</div>
}
trigger="click"
placement="bottomRight"
>
<Button
variant="ghost"
size="icon"
prefix={<Ellipsis size={14} />}
className={styles.icons}
testId="options"
/>
</Popover>
{!isDashboardLocked && addPanelPermission && (
<Button
variant="solid"
color="primary"
className={styles.addPanelBtn}
onClick={onAddPanel}
prefix={<Plus size="md" />}
testId="add-panel-header"
>
New Panel
</Button>
)}
</div>
);
}
export default DashboardActions;

View File

@@ -1,303 +0,0 @@
.dashboardDescriptionContainer {
box-shadow: none;
border: none;
background: unset;
color: var(--l2-foreground);
:global(.ant-card-body) {
padding: 0px;
}
.dashboardDetails {
display: flex;
justify-content: space-between;
gap: 8px;
padding: 16px 16px 0px 16px;
align-items: flex-start;
.leftSection {
display: flex;
align-items: center;
gap: 8px;
width: 45%;
.dashboardImg {
height: 16px;
width: 16px;
}
.dashboardTitle {
color: var(--l1-foreground);
font-family: Inter;
font-size: 16px;
font-style: normal;
font-weight: 500;
line-height: 24px; /* 150% */
letter-spacing: -0.08px;
max-width: 80%;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.publicDashboardIcon {
margin-right: 4px;
}
}
.rightSection {
display: flex;
width: 55%;
justify-content: flex-end;
flex-wrap: wrap;
align-items: center;
gap: 14px;
.icons {
display: flex;
align-items: center;
width: 32px;
height: 34px;
padding: 6px;
justify-content: center;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
color: var(--l2-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 10px; /* 83.333% */
letter-spacing: 0.12px;
}
.icons:hover {
background-color: unset;
}
.configureButton {
display: flex;
align-items: center;
width: 93px;
height: 34px;
padding: 6px;
justify-content: center;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
color: var(--l2-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 10px; /* 83.333% */
letter-spacing: 0.12px;
}
.addPanelBtn {
display: flex;
width: 119px;
height: 34px;
padding: 5.937px 11.875px;
justify-content: center;
align-items: center;
color: var(--primary-foreground);
background: var(--primary-background);
font-family: Inter;
font-size: 11.875px;
font-style: normal;
font-weight: 500;
line-height: 17.812px; /* 150% */
}
}
}
.dashboardTags {
display: flex;
gap: 6px;
padding: 16px 16px 0px 16px;
flex-wrap: wrap;
.tag {
display: flex;
padding: 4px 8px;
justify-content: center;
align-items: center;
border-radius: 20px;
border: 1px solid color-mix(in srgb, var(--bg-sienna-500) 20%, transparent);
background: color-mix(in srgb, var(--bg-sienna-500) 10%, transparent);
color: var(--bg-sienna-400);
text-align: center;
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
margin-inline-end: 0px;
}
}
.dashboardDescriptionSection {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 22px; /* 157.143% */
letter-spacing: -0.07px;
padding: 20px 16px 0px 16px;
}
}
.dashboardSettings {
width: 191px;
height: 302px;
flex-shrink: 0;
:global(.ant-popover-inner) {
padding: 0px;
border-radius: 4px;
border: 1px solid var(--l1-border);
background: linear-gradient(
139deg,
color-mix(in srgb, var(--card) 80%, transparent) 0%,
color-mix(in srgb, var(--card) 90%, transparent) 98.68%
) !important;
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
}
.menuContent {
display: flex;
flex-direction: column;
section {
display: flex;
flex-direction: column;
align-items: start;
button {
display: flex;
width: 100%;
height: unset;
padding: 8px;
align-items: center;
gap: 12px;
color: var(--l2-foreground);
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: normal;
letter-spacing: 0.14px;
border-top: none;
}
}
.section1,
.section2 {
border-bottom: 1px solid var(--l1-border);
}
.deleteDashboard button {
color: var(--bg-cherry-400) !important;
}
}
}
.renameDashboard {
:global(.ant-modal-content) {
width: 384px;
flex-shrink: 0;
border-radius: 4px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
box-shadow: 0px -4px 16px 2px rgba(0, 0, 0, 0.2);
padding: 0px;
:global(.ant-modal-header) {
height: 52px;
padding: 16px;
background: var(--l2-background);
border-bottom: 1px solid var(--l1-border);
margin-bottom: 0px;
:global(.ant-modal-title) {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
width: 349px;
height: 20px;
}
}
:global(.ant-modal-body) {
padding: 16px;
.dashboardContent {
display: flex;
flex-direction: column;
gap: 8px;
.nameText {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 20px; /* 142.857% */
}
.dashboardNameInput {
display: flex;
padding: 6px 6px 6px 8px;
align-items: center;
gap: 4px;
align-self: stretch;
border-radius: 0px 2px 2px 0px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
}
}
}
:global(.ant-modal-footer) {
padding: 16px;
margin-top: 0px;
.dashboardRename {
display: flex;
flex-direction: row-reverse;
gap: 12px;
.cancelBtn {
display: flex;
padding: 4px 8px;
justify-content: center;
align-items: center;
gap: 4px;
border-radius: 2px;
background: var(--l1-border);
}
.renameBtn {
display: flex;
align-items: center;
width: 169px;
padding: 4px 8px;
justify-content: center;
gap: 4px;
border-radius: 2px;
background: var(--primary-background);
}
}
}
}
}

View File

@@ -1,32 +0,0 @@
import { Badge } from '@signozhq/ui/badge';
import { isEmpty } from 'lodash-es';
import styles from '../DashboardDescription.module.scss';
interface Props {
tags: string[];
description: string;
}
function DashboardMeta({ tags, description }: Props): JSX.Element {
return (
<>
{tags.length > 0 && (
<div className={styles.dashboardTags}>
{tags.map((tag) => (
<Badge key={tag} className={styles.tag}>
{tag}
</Badge>
))}
</div>
)}
{!isEmpty(description) && (
<section className={styles.dashboardDescriptionSection}>
{description}
</section>
)}
</>
);
}
export default DashboardMeta;

View File

@@ -1,47 +0,0 @@
import { Globe, LockKeyhole } from '@signozhq/icons';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import styles from '../DashboardDescription.module.scss';
interface Props {
title: string;
image: string;
isPublicDashboard: boolean;
isDashboardLocked: boolean;
}
function DashboardTitle({
title,
image,
isPublicDashboard,
isDashboardLocked,
}: Props): JSX.Element {
return (
<div className={styles.leftSection}>
<img src={image} alt="dashboard-img" className={styles.dashboardImg} />
<TooltipSimple title={title.length > 30 ? title : ''}>
<Typography.Text
className={styles.dashboardTitle}
data-testid="dashboard-title"
>
{title}
</Typography.Text>
</TooltipSimple>
{isPublicDashboard && (
<TooltipSimple title="This dashboard is publicly accessible">
<Globe size={14} className={styles.publicDashboardIcon} />
</TooltipSimple>
)}
{isDashboardLocked && (
<TooltipSimple title="This dashboard is locked">
<LockKeyhole size={14} />
</TooltipSimple>
)}
</div>
);
}
export default DashboardTitle;

View File

@@ -1,70 +0,0 @@
import { Input, Modal } from 'antd';
import { Button } from '@signozhq/ui/button';
import { Check, X } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import styles from '../DashboardDescription.module.scss';
interface Props {
open: boolean;
value: string;
isLoading: boolean;
onChange: (value: string) => void;
onRename: () => void;
onClose: () => void;
}
function RenameDashboardModal({
open,
value,
isLoading,
onChange,
onRename,
onClose,
}: Props): JSX.Element {
return (
<Modal
open={open}
title="Rename Dashboard"
onOk={onRename}
onCancel={onClose}
rootClassName={styles.renameDashboard}
footer={
<div className={styles.dashboardRename}>
<Button
variant="solid"
color="primary"
prefix={<Check size={14} />}
className={styles.renameBtn}
onClick={onRename}
disabled={isLoading}
>
Rename Dashboard
</Button>
<Button
variant="ghost"
prefix={<X size={14} />}
className={styles.cancelBtn}
onClick={onClose}
>
Cancel
</Button>
</div>
}
>
<div className={styles.dashboardContent}>
<Typography.Text className={styles.nameText}>
Enter a new name
</Typography.Text>
<Input
data-testid="dashboard-name"
className={styles.dashboardNameInput}
value={value}
onChange={(e): void => onChange(e.target.value)}
/>
</div>
</Modal>
);
}
export default RenameDashboardModal;

View File

@@ -1,159 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { FullScreenHandle } from 'react-full-screen';
import { Card } from 'antd';
import { toast } from '@signozhq/ui/sonner';
import logEvent from 'api/common/logEvent';
import {
lockDashboardV2,
patchDashboardV2,
unlockDashboardV2,
} from 'api/generated/services/dashboard';
import type {
DashboardtypesGettableDashboardV2DTO,
DashboardtypesJSONPatchOperationDTO,
} from 'api/generated/services/sigNoz.schemas';
import { Base64Icons } from 'container/DashboardContainer/DashboardSettings/General/utils';
import useComponentPermission from 'hooks/useComponentPermission';
import { useAppContext } from 'providers/App/App';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import DashboardHeader from '../components/DashboardHeader/DashboardHeader';
import DashboardActions from './DashboardActions/DashboardActions';
import DashboardMeta from './DashboardMeta/DashboardMeta';
import DashboardTitle from './DashboardTitle/DashboardTitle';
import RenameDashboardModal from './RenameDashboardModal/RenameDashboardModal';
import styles from './DashboardDescription.module.scss';
interface DashboardDescriptionProps {
dashboard: DashboardtypesGettableDashboardV2DTO;
handle: FullScreenHandle;
refetch: () => void;
}
function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
const { dashboard, handle, refetch } = props;
const id = dashboard.id;
const isDashboardLocked = !!dashboard.locked;
const title = dashboard.spec?.display?.name ?? '';
const description = dashboard.spec?.display?.description ?? '';
const image = dashboard.image || Base64Icons[0];
const tags = useMemo(
() =>
(dashboard.tags ?? []).map((t) =>
t.key === t.value ? t.key : `${t.key}:${t.value}`,
),
[dashboard.tags],
);
const { user } = useAppContext();
const [editDashboard] = useComponentPermission(['edit_dashboard'], user.role);
const { showErrorModal } = useErrorModal();
const isAuthor =
!!user?.email && !!dashboard.createdBy && dashboard.createdBy === user.email;
const addPanelPermission = !isDashboardLocked;
// V2 public dashboard wiring lives separately; treat as not-public for chrome.
const isPublicDashboard = false;
const [isRenameDashboardOpen, setIsRenameDashboardOpen] =
useState<boolean>(false);
const [updatedTitle, setUpdatedTitle] = useState<string>(title);
const [isRenameLoading, setIsRenameLoading] = useState<boolean>(false);
useEffect(() => {
setUpdatedTitle(title);
}, [title]);
const handleLockDashboardToggle = async (): Promise<void> => {
if (!id) {
return;
}
try {
if (isDashboardLocked) {
await unlockDashboardV2({ id });
toast.success('Dashboard unlocked');
} else {
await lockDashboardV2({ id });
toast.success('Dashboard locked');
}
refetch();
} catch (error) {
showErrorModal(error as APIError);
}
};
const onNameChangeHandler = async (): Promise<void> => {
const trimmed = updatedTitle.trim();
if (!id || !trimmed || trimmed === title) {
setIsRenameDashboardOpen(false);
return;
}
try {
setIsRenameLoading(true);
const patch: DashboardtypesJSONPatchOperationDTO[] = [
{
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
path: '/spec/display/name',
value: trimmed,
},
];
await patchDashboardV2({ id }, patch);
toast.success('Dashboard renamed successfully');
setIsRenameDashboardOpen(false);
refetch();
} catch (error) {
showErrorModal(error as APIError);
setIsRenameDashboardOpen(true);
} finally {
setIsRenameLoading(false);
}
};
const onEmptyWidgetHandler = (): void => {
void logEvent('Dashboard Detail V2: Add new panel clicked', {
dashboardId: id,
});
toast.info('V2 panel editor coming next');
};
return (
<Card className={styles.dashboardDescriptionContainer}>
<DashboardHeader title={title} image={image} />
<section className={styles.dashboardDetails}>
<DashboardTitle
title={title}
image={image}
isPublicDashboard={isPublicDashboard}
isDashboardLocked={isDashboardLocked}
/>
<DashboardActions
dashboard={dashboard}
handle={handle}
isDashboardLocked={isDashboardLocked}
editDashboard={editDashboard}
isAuthor={isAuthor}
addPanelPermission={addPanelPermission}
onAddPanel={onEmptyWidgetHandler}
onLockToggle={handleLockDashboardToggle}
onOpenRename={(): void => setIsRenameDashboardOpen(true)}
/>
</section>
<DashboardMeta tags={tags} description={description} />
<RenameDashboardModal
open={isRenameDashboardOpen}
value={updatedTitle}
isLoading={isRenameLoading}
onChange={setUpdatedTitle}
onRename={onNameChangeHandler}
onClose={(): void => setIsRenameDashboardOpen(false)}
/>
</Card>
);
}
export default DashboardDescription;

View File

@@ -1,52 +0,0 @@
.panel {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
background: var(--bg-ink-400, #0b0c0e);
border: 1px solid var(--bg-slate-400, #1d212d);
border-radius: 4px;
overflow: hidden;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid var(--bg-slate-400, #1d212d);
cursor: grab;
}
.headerLeft {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.headerTitle {
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.badge {
margin-inline-end: 0;
}
.body {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 12px;
color: var(--bg-vanilla-400, #8993ae);
font-size: 12px;
text-align: center;
}
.bodyKind {
margin-bottom: 6px;
}

View File

@@ -1,67 +0,0 @@
import { useMemo } from 'react';
import { Badge } from '@signozhq/ui/badge';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import { EllipsisVertical } from '@signozhq/icons';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import styles from './Panel.module.scss';
interface Props {
panel: DashboardtypesPanelDTO | undefined;
panelId: string;
/**
* Placeholder: true once this panel's section enters the viewport. The panel
* query-loading implementation (later PR) will consume this to lazily fetch
* data. Currently unused on purpose.
*/
isVisible?: boolean;
}
function Panel({ panel, panelId, isVisible }: Props): JSX.Element {
const name = panel?.spec?.display?.name || `Panel ${panelId.slice(0, 6)}`;
const description = panel?.spec?.display?.description;
const kind = panel?.spec?.plugin?.kind?.replace(/^signoz\//, '') ?? 'unknown';
const queryCount = panel?.spec?.queries?.length ?? 0;
const headerTitle = useMemo(() => {
if (!description) {
return name;
}
return (
<TooltipSimple title={description}>
<span>{name}</span>
</TooltipSimple>
);
}, [name, description]);
return (
<div
className={styles.panel}
data-panel-visible={isVisible ? 'true' : 'false'}
>
<div className={cx(styles.header, 'panel-drag-handle')}>
<div className={styles.headerLeft}>
<Typography.Text className={styles.headerTitle}>
{headerTitle}
</Typography.Text>
<Badge className={styles.badge}>{kind}</Badge>
</div>
<EllipsisVertical size={14} />
</div>
<div className={styles.body}>
<div>
<div className={styles.bodyKind}>{kind} panel</div>
<div>
{queryCount} {queryCount === 1 ? 'query' : 'queries'} · chart rendering
coming next
</div>
</div>
</div>
</div>
);
}
export default Panel;

View File

@@ -1,10 +0,0 @@
.body {
flex: 1;
padding: 12px 24px;
overflow: auto;
}
.emptyState {
padding: 48px;
text-align: center;
}

View File

@@ -1,9 +0,0 @@
.section {
margin-bottom: 12px;
border: 1px solid var(--bg-slate-500);
border-radius: 4px;
}
.dragging {
opacity: 0.8;
}

View File

@@ -1,60 +0,0 @@
import { useRef, useState } from 'react';
import { useIntersectionObserver } from 'hooks/useIntersectionObserver';
import type { DashboardSection } from '../../../utils';
import SectionGrid from '../SectionGrid/SectionGrid';
import SectionHeader from '../SectionHeader/SectionHeader';
import styles from './Section.module.scss';
interface Props {
section: DashboardSection;
}
function Section({ section }: Props): JSX.Element {
const containerRef = useRef<HTMLDivElement>(null);
// Placeholder signal for lazy panel query-loading (consumed in a later PR):
// true once the section scrolls into (or near) the viewport.
const isVisible = useIntersectionObserver(containerRef, {
rootMargin: '200px',
});
const [open, setOpen] = useState<boolean>(section.open);
const toggle = (): void => setOpen((prev) => !prev);
const grid = <SectionGrid items={section.items} isVisible={isVisible} />;
if (!section.title) {
// Untitled section — just the grid (no header chrome), but still observed
// for the viewport signal.
return (
<div
ref={containerRef}
data-testid={`dashboard-section-${section.id}`}
data-section-layout-index={section.layoutIndex}
>
{grid}
</div>
);
}
return (
<div
ref={containerRef}
className={styles.section}
data-testid={`dashboard-section-${section.id}`}
data-section-layout-index={section.layoutIndex}
>
<SectionHeader
sectionId={section.id}
title={section.title}
open={open}
onToggle={toggle}
repeatVariable={section.repeatVariable}
/>
{open ? grid : null}
</div>
);
}
export default Section;

View File

@@ -1,12 +0,0 @@
.grid {
// Override react-grid-layout's default red drag/resize placeholder with the
// SigNoz brand blue.
:global(.react-grid-item.react-grid-placeholder) {
background: var(--bg-robin-500);
opacity: 0.2;
border-radius: 4px;
transition-duration: 100ms;
z-index: 2;
user-select: none;
}
}

View File

@@ -1,50 +0,0 @@
import { useMemo } from 'react';
import GridLayout, { WidthProvider, type Layout } from 'react-grid-layout';
import type { DashboardSection } from '../../../utils';
import Panel from '../../Panel/Panel';
import styles from './SectionGrid.module.scss';
const ResponsiveGridLayout = WidthProvider(GridLayout);
interface Props {
items: DashboardSection['items'];
/** Forwarded to panels — true when the parent section is in the viewport. */
isVisible?: boolean;
}
function SectionGrid({ items, isVisible }: Props): JSX.Element {
const rglLayout = useMemo<Layout[]>(
() =>
items.map((item) => ({
i: item.id,
x: item.x,
y: item.y,
w: item.width,
h: item.height,
})),
[items],
);
return (
<ResponsiveGridLayout
className={styles.grid}
cols={12}
rowHeight={45}
autoSize
useCSSTransforms
layout={rglLayout}
isDraggable={false}
isResizable={false}
margin={[8, 8]}
>
{items.map((item) => (
<div key={item.id}>
<Panel panel={item.panel} panelId={item.id} isVisible={isVisible} />
</div>
))}
</ResponsiveGridLayout>
);
}
export default SectionGrid;

View File

@@ -1,52 +0,0 @@
.header {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 12px;
&.headerOpen {
border-bottom: 1px solid var(--bg-slate-500);
}
}
.dragHandle {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
background: transparent;
border: none;
color: var(--bg-vanilla-400, #8993ae);
cursor: grab;
&:active {
cursor: grabbing;
}
}
.toggle {
flex: 1;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 4px;
padding: 0;
background: transparent;
border: none;
color: inherit;
text-align: left;
cursor: pointer;
min-width: 0;
}
.title {
margin-left: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.repeatBadge {
margin-left: 8px;
opacity: 0.6;
}

View File

@@ -1,42 +0,0 @@
import { ChevronDown, ChevronRight } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import styles from './SectionHeader.module.scss';
interface Props {
sectionId: string;
title: string;
open: boolean;
onToggle: () => void;
repeatVariable?: string;
}
function SectionHeader({
sectionId,
title,
open,
onToggle,
repeatVariable,
}: Props): JSX.Element {
return (
<div className={cx(styles.header, { [styles.headerOpen]: open })}>
<button
type="button"
className={styles.toggle}
onClick={onToggle}
data-testid={`dashboard-section-toggle-${sectionId}`}
>
{open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
<Typography.Text className={styles.title}>{title}</Typography.Text>
{repeatVariable ? (
<Typography.Text className={styles.repeatBadge}>
(repeats per ${repeatVariable})
</Typography.Text>
) : null}
</button>
</div>
);
}
export default SectionHeader;

View File

@@ -1,53 +0,0 @@
import { ReactNode, useMemo } from 'react';
import { Empty } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import type {
DashboardtypesLayoutDTO,
DashboardtypesPanelDTO,
} from 'api/generated/services/sigNoz.schemas';
import { layoutsToSections } from '../utils';
import Section from './Section/Section/Section';
import styles from './PanelsAndSectionsLayout.module.scss';
import 'react-grid-layout/css/styles.css';
import 'react-resizable/css/styles.css';
interface Props {
layouts: DashboardtypesLayoutDTO[];
panels: Record<string, DashboardtypesPanelDTO | undefined>;
}
function PanelsAndSectionsLayout({ layouts, panels }: Props): JSX.Element {
const sections = useMemo(
() => layoutsToSections(layouts, panels),
[layouts, panels],
);
const isEmpty =
sections.length === 0 || sections.every((s) => s.items.length === 0);
const renderContent = (): ReactNode => {
if (isEmpty) {
return (
<div className={styles.emptyState}>
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
<Typography.Text>No panels in this dashboard yet</Typography.Text>
}
/>
</div>
);
}
return sections.map((section) => (
<Section key={section.id} section={section} />
));
};
return <div className={styles.body}>{renderContent()}</div>;
}
export default PanelsAndSectionsLayout;

View File

@@ -1,63 +0,0 @@
.dashboardBreadcrumbs {
width: 100%;
height: 48px;
display: flex;
gap: 6px;
align-items: center;
max-width: 80%;
.dashboardBtn {
display: flex;
align-items: center;
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
padding: 0px;
height: 20px;
}
.dashboardBtn:hover {
background-color: unset;
}
.idBtn {
display: flex;
align-items: center;
gap: 4px;
padding: 0px 2px;
border-radius: 2px;
background: color-mix(in srgb, var(--bg-robin-400) 10%, transparent);
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
height: 20px;
max-width: calc(100% - 120px);
span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
:global(.ant-btn-icon) {
margin-inline-end: 4px;
}
}
.idBtn:hover {
background: color-mix(in srgb, var(--bg-robin-400) 10%, transparent);
color: var(--bg-robin-300);
}
.dashboardIconImage {
height: 14px;
width: 14px;
}
}

View File

@@ -1,56 +0,0 @@
import { useCallback } from 'react';
import { Button } from '@signozhq/ui/button';
import getSessionStorageApi from 'api/browser/sessionstorage/get';
import ROUTES from 'constants/routes';
import { DASHBOARDS_LIST_QUERY_PARAMS_STORAGE_KEY } from 'hooks/dashboard/useDashboardsListQueryParams';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { LayoutGrid } from '@signozhq/icons';
import styles from './DashboardBreadcrumbs.module.scss';
interface Props {
title: string;
image: string;
}
function DashboardBreadcrumbs({ title, image }: Props): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const goToListPage = useCallback(() => {
const dashboardsListQueryParamsString = getSessionStorageApi(
DASHBOARDS_LIST_QUERY_PARAMS_STORAGE_KEY,
);
if (dashboardsListQueryParamsString) {
safeNavigate({
pathname: ROUTES.ALL_DASHBOARD,
search: `?${dashboardsListQueryParamsString}`,
});
} else {
safeNavigate(ROUTES.ALL_DASHBOARD);
}
}, [safeNavigate]);
return (
<div className={styles.dashboardBreadcrumbs}>
<Button
variant="ghost"
prefix={<LayoutGrid size={14} />}
className={styles.dashboardBtn}
onClick={goToListPage}
>
Dashboard /
</Button>
<Button variant="ghost" className={styles.idBtn}>
<img
src={image}
alt="dashboard-icon"
className={styles.dashboardIconImage}
/>
{title}
</Button>
</div>
);
}
export default DashboardBreadcrumbs;

View File

@@ -1,9 +0,0 @@
.dashboardHeader {
border-bottom: 1px solid var(--l1-border);
display: flex;
justify-content: space-between;
gap: 16px;
align-items: center;
padding: 0 8px;
box-sizing: border-box;
}

View File

@@ -1,22 +0,0 @@
import { memo } from 'react';
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
import DashboardBreadcrumbs from './DashboardBreadcrumbs';
import styles from './DashboardHeader.module.scss';
interface Props {
title: string;
image: string;
}
function DashboardHeader({ title, image }: Props): JSX.Element {
return (
<div className={styles.dashboardHeader}>
<DashboardBreadcrumbs title={title} image={image} />
<HeaderRightSection enableAnnouncements={false} enableShare enableFeedback />
</div>
);
}
export default memo(DashboardHeader);

View File

@@ -1,36 +0,0 @@
import { useMemo } from 'react';
import { FullScreen, useFullScreenHandle } from 'react-full-screen';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import DashboardDescription from './DashboardDescription';
import PanelsAndSectionsLayout from './PanelsAndSectionsLayout';
import styles from './DashboardContainer.module.scss';
interface Props {
dashboard: DashboardtypesGettableDashboardV2DTO;
refetch: () => void;
}
function DashboardContainer({ dashboard, refetch }: Props): JSX.Element {
const fullScreenHandle = useFullScreenHandle();
const { spec } = dashboard;
const layouts = useMemo(() => spec?.layouts ?? [], [spec?.layouts]);
const panels = useMemo(() => spec?.panels ?? {}, [spec?.panels]);
return (
<FullScreen handle={fullScreenHandle}>
<div className={styles.container}>
<DashboardDescription
dashboard={dashboard}
handle={fullScreenHandle}
refetch={refetch}
/>
<PanelsAndSectionsLayout layouts={layouts} panels={panels} />
</div>
</FullScreen>
);
}
export default DashboardContainer;

View File

@@ -1,154 +0,0 @@
import type {
DashboardtypesLayoutDTO,
DashboardtypesPanelDTO,
} from 'api/generated/services/sigNoz.schemas';
export interface GridItem {
id: string;
x: number;
y: number;
width: number;
height: number;
panel: DashboardtypesPanelDTO | undefined;
}
const PANEL_REF_PREFIX = '#/spec/panels/';
export function extractPanelIdFromRef(ref: string | undefined): string | null {
if (!ref) {
return null;
}
if (!ref.startsWith(PANEL_REF_PREFIX)) {
return null;
}
return ref.slice(PANEL_REF_PREFIX.length);
}
export function flattenGridLayout(
layouts: DashboardtypesLayoutDTO[] | undefined | null,
panels: Record<string, DashboardtypesPanelDTO | undefined> | undefined,
): GridItem[] {
if (!layouts?.length) {
return [];
}
const items: GridItem[] = [];
layouts.forEach((layoutEnvelope) => {
if (layoutEnvelope?.kind !== 'Grid') {
return;
}
const gridItems = layoutEnvelope.spec?.items ?? [];
gridItems.forEach((item) => {
const id = extractPanelIdFromRef(item.content?.$ref);
if (!id) {
return;
}
items.push({
id,
x: item.x ?? 0,
y: item.y ?? 0,
width: item.width ?? 6,
height: item.height ?? 6,
panel: panels?.[id],
});
});
});
return items;
}
/**
* A section corresponds to one entry in `spec.layouts`. If the Grid has a
* `display.title`, it renders with a collapsible header; otherwise it is a
* "default" untitled section (visually just the grid).
*/
export interface DashboardSection {
/**
* Stable identity used for React keys and dnd-kit sortable item ids. Derived
* from the section's content (its first panel ref) so it survives reordering
* — unlike the positional `layoutIndex`. See `getSectionStableId`.
*/
id: string;
/** Position of this section's Grid in `spec.layouts`. All JSON-Patch ops target by this. */
layoutIndex: number;
title: string | undefined;
open: boolean;
items: GridItem[];
repeatVariable: string | undefined;
}
/**
* Derives a stable id for a section from its content. Reordering sections changes
* their `layoutIndex` but not their content, so keying off the first panel ref
* keeps React component instances (and any local state) bound to the right
* section across a reorder. Empty sections fall back to a positional id — they
* are rarely reordered, and a future backend `id` on the layout spec is the
* proper long-term fix.
*/
export function getSectionStableId(
items: GridItem[],
layoutIndex: number,
): string {
if (items.length > 0) {
return `sec-${items[0].id}`;
}
return `sec-empty-${layoutIndex}`;
}
export function layoutsToSections(
layouts: DashboardtypesLayoutDTO[] | undefined | null,
panels: Record<string, DashboardtypesPanelDTO | undefined> | undefined,
): DashboardSection[] {
if (!layouts?.length) {
return [];
}
return layouts
.map((layoutEnvelope, idx) => {
if (layoutEnvelope?.kind !== 'Grid') {
return null;
}
const spec = layoutEnvelope.spec;
const items: GridItem[] = (spec?.items ?? [])
.map((item) => {
const id = extractPanelIdFromRef(item.content?.$ref);
if (!id) {
return null;
}
return {
id,
x: item.x ?? 0,
y: item.y ?? 0,
width: item.width ?? 6,
height: item.height ?? 6,
panel: panels?.[id],
};
})
.filter((it): it is GridItem => it !== null);
const title = spec?.display?.title;
// `open` defaults to true when no collapse field is set (the section
// is expanded by default).
const open = spec?.display?.collapse?.open !== false;
return {
id: getSectionStableId(items, idx),
layoutIndex: idx,
title,
open,
items,
repeatVariable: spec?.repeatVariable,
};
})
.filter((s): s is DashboardSection => s !== null);
}
export function getPanelKindLabel(
panel: DashboardtypesPanelDTO | undefined,
): string {
const kind = panel?.spec?.plugin?.kind;
if (!kind) {
return 'unknown';
}
return kind.replace(/^signoz\//, '');
}

View File

@@ -1,3 +0,0 @@
.errorState {
padding: 24px;
}

View File

@@ -1,43 +1,5 @@
import { useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { Typography } from '@signozhq/ui/typography';
import { useGetDashboardV2 } from 'api/generated/services/dashboard';
import Spinner from 'components/Spinner';
import DashboardContainer from './DashboardContainer';
import styles from './DashboardPageV2.module.scss';
function DashboardPageV2(): JSX.Element {
const { dashboardId } = useParams<{ dashboardId: string }>();
const { data, isLoading, isError, error, refetch } = useGetDashboardV2({
id: dashboardId,
});
const dashboard = data?.data;
const name = dashboard?.spec?.display?.name;
useEffect(() => {
if (name) {
document.title = name;
}
}, [name]);
if (isLoading) {
return <Spinner tip="Loading dashboard..." />;
}
if (isError || !dashboard) {
return (
<div className={styles.errorState}>
<Typography.Title>Failed to load dashboard</Typography.Title>
<Typography.Text>{(error as Error)?.message}</Typography.Text>
</div>
);
}
return <DashboardContainer dashboard={dashboard} refetch={refetch} />;
return <>DashboardPageV2</>;
}
export default DashboardPageV2;

View File

@@ -1 +0,0 @@
<svg id="b70acf0a-34b4-4bdf-9024-7496043ff915" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18"><defs><radialGradient id="e2cf8746-c9a8-4eee-86c2-4951983c6032" cx="13428.81" cy="3518.86" r="56.67" gradientTransform="translate(-2005.33 -518.83) scale(0.15)" gradientUnits="userSpaceOnUse"><stop offset="0.18" stop-color="#5ea0ef"/><stop offset="1" stop-color="#0078d4"/></radialGradient><linearGradient id="bdd213dd-d313-473c-8ff4-0133fd3a9033" x1="4.4" y1="11.48" x2="4.37" y2="7.53" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ccc"/><stop offset="1" stop-color="#fcfcfc"/></linearGradient><linearGradient id="afcc63c5-3649-4476-a742-bcb53a569f3c" x1="10.13" y1="15.45" x2="10.13" y2="11.9" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ccc"/><stop offset="1" stop-color="#fcfcfc"/></linearGradient><linearGradient id="bd873f0b-9954-4aa5-a3df-9f4c64e8729d" x1="14.18" y1="11.15" x2="14.18" y2="7.38" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ccc"/><stop offset="1" stop-color="#fcfcfc"/></linearGradient></defs><title>Icon-web-41</title><path id="ee75dd06-1aca-4f76-9d11-d05a284020ad" d="M14.21,15.72A8.5,8.5,0,0,1,3.79,2.28l.09-.06a8.5,8.5,0,0,1,10.33,13.5" fill="url(#e2cf8746-c9a8-4eee-86c2-4951983c6032)"/><path d="M6.69,7.23A13,13,0,0,1,15.6,3.65a8.47,8.47,0,0,0-1.49-1.44,14.34,14.34,0,0,0-4.69,1.1A12.54,12.54,0,0,0,5.34,6.13,2.76,2.76,0,0,1,6.69,7.23Z" fill="#fff" opacity="0.6"/><path d="M2.48,10.65a17.86,17.86,0,0,0-.83,2.62,7.82,7.82,0,0,0,.62.92c.18.23.35.44.55.65A17.94,17.94,0,0,1,3.9,11.37,2.76,2.76,0,0,1,2.48,10.65Z" fill="#fff" opacity="0.6"/><path d="M3.46,6.11a12,12,0,0,1-.69-2.94,8.15,8.15,0,0,0-1.1,1.45A12.69,12.69,0,0,0,2.24,7,2.69,2.69,0,0,1,3.46,6.11Z" fill="#f2f2f2" opacity="0.55"/><circle cx="4.38" cy="8.68" r="2.73" fill="url(#bdd213dd-d313-473c-8ff4-0133fd3a9033)"/><path d="M8.36,13.67A1.77,1.77,0,0,1,8.9,12.4a11.88,11.88,0,0,1-2.53-1.86,2.74,2.74,0,0,1-1.49.83,13.1,13.1,0,0,0,1.45,1.28A12.12,12.12,0,0,0,8.38,13.9,1.79,1.79,0,0,1,8.36,13.67Z" fill="#f2f2f2" opacity="0.55"/><path d="M14.66,13.88a12,12,0,0,1-2.76-.32.41.41,0,0,1,0,.11,1.75,1.75,0,0,1-.51,1.24,13.69,13.69,0,0,0,3.42.24A8.21,8.21,0,0,0,16,13.81,11.5,11.5,0,0,1,14.66,13.88Z" fill="#f2f2f2" opacity="0.55"/><circle cx="10.13" cy="13.67" r="1.78" fill="url(#afcc63c5-3649-4476-a742-bcb53a569f3c)"/><path d="M12.32,8.93a1.83,1.83,0,0,1,.61-1A25.5,25.5,0,0,1,8.47,3.79a16.91,16.91,0,0,1-2-2.92,7.64,7.64,0,0,0-1.09.42A18.14,18.14,0,0,0,7.53,4.47,26.44,26.44,0,0,0,12.32,8.93Z" fill="#f2f2f2" opacity="0.7"/><circle cx="14.18" cy="9.27" r="1.89" fill="url(#bd873f0b-9954-4aa5-a3df-9f4c64e8729d)"/><path d="M17.35,10.54,17,10.37l0,0-.3-.16-.06,0L16.38,10l-.07,0L16,9.8a1.76,1.76,0,0,1-.64.92c.12.08.25.15.38.22l.08.05.35.19,0,0,.86.45h0a8.63,8.63,0,0,0,.29-1.11Z" fill="#f2f2f2" opacity="0.55"/><circle cx="4.38" cy="8.68" r="2.73" fill="url(#bdd213dd-d313-473c-8ff4-0133fd3a9033)"/><circle cx="10.13" cy="13.67" r="1.78" fill="url(#afcc63c5-3649-4476-a742-bcb53a569f3c)"/></svg>

Before

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -1,272 +0,0 @@
{
"id": "appservice",
"title": "App Services",
"icon": "file://icon.svg",
"overview": "file://overview.md",
"supportedSignals": {
"metrics": true,
"logs": true
},
"dataCollected": {
"metrics": [
{
"name": "azure_averagememoryworkingset_average",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_bytesreceived_total",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_bytessent_total",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_backendrequestcount_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_cputime_count",
"unit": "Milliseconds",
"type": "Gauge",
"description": ""
},
{
"name": "azure_cputime_total",
"unit": "Milliseconds",
"type": "Gauge",
"description": ""
},
{
"name": "azure_cputime_minimum",
"unit": "Milliseconds",
"type": "Gauge",
"description": ""
},
{
"name": "azure_cputime_maximum",
"unit": "Milliseconds",
"type": "Gauge",
"description": ""
},
{
"name": "azure_currentassemblies_average",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_filesystemusage_average",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_gen0collections_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_ge10collections_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_gen2collections_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_handles_average",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_healthcheckstatus_average",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_http101_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_http2xx_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_http3xx_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_http401_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_http403_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_http404_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_http406_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_http4xx_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_http5xx_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_httpresponsetime_average",
"unit": "Milliseconds",
"type": "Gauge",
"description": ""
},
{
"name": "azure_iootherbytespersecond_total",
"unit": "BytesPerSecond",
"type": "Gauge",
"description": ""
},
{
"name": "azure_iootheroperationspersecond_total",
"unit": "BytesPerSecond",
"type": "Gauge",
"description": ""
},
{
"name": "azure_ioreadbytespersecond_total",
"unit": "BytesPerSecond",
"type": "Gauge",
"description": ""
},
{
"name": "azure_ioreadoperationspersecond_total",
"unit": "BytesPerSecond",
"type": "Gauge",
"description": ""
},
{
"name": "azure_iowritebytespersecond_total",
"unit": "BytesPerSecond",
"type": "Gauge",
"description": ""
},
{
"name": "azure_iowriteoperationspersecond_total",
"unit": "BytesPerSecond",
"type": "Gauge",
"description": ""
},
{
"name": "azure_privatebytes_average",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_requests_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_requestsinapplicationqueue_average",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_thread_average",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_totalappdomains_average",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_totalappdomainsunloaded_average",
"unit": "Count",
"type": "Gauge",
"description": ""
}
],
"logs": [
{
"name": "Resource ID",
"path": "resources.azure.resource.id",
"type": "string"
}
]
},
"telemetryCollectionStrategy": {
"azure": {
"resourceProvider": "Microsoft.Web",
"resourceType": "sites",
"metrics": {},
"logs": {
"categoryGroups": ["allLogs"]
}
}
},
"assets": {
"dashboards": [
{
"id": "overview",
"title": "App Services Overview",
"description": "Overview of App Services metrics",
"definition": "file://assets/dashboards/overview.json"
}
]
}
}

View File

@@ -1,5 +0,0 @@
### Monitor Azure App Services with SigNoz
Collect key App Services metrics and view them with an out of the box dashboard.
Note: This integration DO NOT collect metrics for any database that was setup with your App Service (if any).

View File

@@ -19,7 +19,7 @@ type WaterfallConfig struct {
}
func NewConfigFactory() factory.ConfigFactory {
return factory.NewConfigFactory(factory.MustNewName("traces"), newConfig)
return factory.NewConfigFactory(factory.MustNewName("tracedetail"), newConfig)
}
func newConfig() factory.Config {
@@ -34,13 +34,16 @@ func newConfig() factory.Config {
func (c Config) Validate() error {
if c.Waterfall.SpanPageSize <= 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "traces.waterfall.span_limit_per_request must be positive, got %v", c.Waterfall.SpanPageSize)
return errors.NewInvalidInputf(errors.CodeInvalidInput,
"tracedetail.waterfall.span_limit_per_request must be positive, got %v", c.Waterfall.SpanPageSize)
}
if c.Waterfall.MaxDepthToAutoExpand < 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "traces.waterfall.max_depth_for_selected_children cannot be negative, got %d", c.Waterfall.MaxDepthToAutoExpand)
return errors.NewInvalidInputf(errors.CodeInvalidInput,
"tracedetail.waterfall.max_depth_for_selected_children cannot be negative, got %d", c.Waterfall.MaxDepthToAutoExpand)
}
if c.Waterfall.MaxLimitToSelectAllSpans == 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "traces.waterfall.max_limit_to_select_all_spans must be positive")
return errors.NewInvalidInputf(errors.CodeInvalidInput,
"tracedetail.waterfall.max_limit_to_select_all_spans must be positive")
}
return nil
}

View File

@@ -7,34 +7,21 @@ import (
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
"github.com/SigNoz/signoz/pkg/types/spantypes"
"go.opentelemetry.io/otel/metric"
)
type module struct {
store spantypes.TraceStore
settings factory.ScopedProviderSettings
config tracedetail.Config
metrics *moduleMetrics
}
func NewModule(traceStore spantypes.TraceStore, providerSettings factory.ProviderSettings, cfg tracedetail.Config) *module {
scopedProviderSettings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/tracedetail/impltracedetail")
metrics, err := newModuleMetrics(scopedProviderSettings.Meter())
if err != nil {
panic(err)
}
m := &module{
return &module{
config: cfg,
store: traceStore,
settings: scopedProviderSettings,
metrics: metrics,
}
m.metrics.waterfallSpanLimit.Record(context.Background(), int64(cfg.Waterfall.MaxLimitToSelectAllSpans), metric.WithAttributes(attrResponseType.String(attrResponseTypeWindowed)))
return m
}
func (m *module) GetWaterfall(ctx context.Context, traceID string, req *spantypes.PostableWaterfall) (*spantypes.GettableWaterfallTrace, error) {
@@ -93,9 +80,6 @@ func (m *module) GetWaterfallV4(ctx context.Context, traceID string, selectedSpa
}
effectiveLimit := min(selectAllLimit, m.config.Waterfall.MaxLimitToSelectAllSpans)
if summary.NumSpans > uint64(effectiveLimit) {
attrs := metric.WithAttributes(attrResponseType.String(attrResponseTypeWindowed))
m.metrics.waterfallRequestCount.Add(ctx, 1, attrs)
m.metrics.waterfallSpanCount.Add(ctx, int64(summary.NumSpans), attrs)
return m.getWindowedWaterfall(ctx, traceID, selectedSpanID, uncollapsedSpans, summary.Start, summary.End)
}
return m.getFullWaterfall(ctx, traceID, summary)

View File

@@ -1,55 +0,0 @@
package impltracedetail
import (
"github.com/SigNoz/signoz/pkg/errors"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
)
const (
attrResponseType = attribute.Key("response_type")
attrResponseTypeWindowed = "windowed"
)
type moduleMetrics struct {
waterfallSpanLimit metric.Int64Gauge
waterfallRequestCount metric.Int64Counter
waterfallSpanCount metric.Int64Counter
}
func newModuleMetrics(meter metric.Meter) (*moduleMetrics, error) {
var errs error
spanLimit, err := meter.Int64Gauge(
"signoz.traces.waterfall.span.limit",
metric.WithDescription("The span count limit above which windowed waterfall is returned instead of the full waterfall."),
metric.WithUnit("{span}"),
)
if err != nil {
errs = errors.Join(errs, err)
}
requestCount, err := meter.Int64Counter(
"signoz.traces.waterfall.request.count",
metric.WithDescription("Total number of waterfall requests, by response_type."),
metric.WithUnit("{request}"),
)
if err != nil {
errs = errors.Join(errs, err)
}
spanCount, err := meter.Int64Counter(
"signoz.traces.waterfall.span.count",
metric.WithDescription("Total number of spans across waterfall requests, by response_type."),
metric.WithUnit("{span}"),
)
if err != nil {
errs = errors.Join(errs, err)
}
return &moduleMetrics{
waterfallSpanLimit: spanLimit,
waterfallRequestCount: requestCount,
waterfallSpanCount: spanCount,
}, errs
}

View File

@@ -54,7 +54,7 @@ func (c Config) Validate() error {
if c.MaxConcurrentQueries <= 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "max_concurrent_queries must be positive, got %v", c.MaxConcurrentQueries)
}
if c.SkipResourceFingerprint.Enabled && c.SkipResourceFingerprint.Threshold == 0 {
if c.SkipResourceFingerprint.Enabled && c.SkipResourceFingerprint.Threshold <= 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "skip_resource_fingerprint.threshold must be > 0 when enabled")
}
return nil

View File

@@ -217,7 +217,7 @@ func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtype
}
}
preseededResults := make(map[string]any)
for _, name := range missingMetricQueries {
for _, name := range missingMetricQueries { // at this point missing metrics will not have any non existent metrics, only normal ones
switch req.RequestType {
case qbtypes.RequestTypeTimeSeries:
preseededResults[name] = &qbtypes.TimeSeriesData{QueryName: name}
@@ -375,24 +375,11 @@ func (q *querier) resolveMetricMetadata(ctx context.Context, queries []qbtypes.Q
return missingMetricQueries, "", nil
}
isInternalMetric := func(n string) bool { return strings.HasPrefix(n, "signoz.") || strings.HasPrefix(n, "signoz_") }
externalMissingMetrics := make([]string, 0, len(missingMetrics))
for _, m := range missingMetrics {
if !isInternalMetric(m) {
externalMissingMetrics = append(externalMissingMetrics, m)
}
}
if len(externalMissingMetrics) == 0 {
// this means all missing metrics are internal, and since internal metrics
// aren't user-controlled, skip errors/warnings for them since users can't act on them
return missingMetricQueries, "", nil
}
// Classify each missing metric: never-seen → NotFound error; seen-but-no-
// data-in-window → dormant warning.
lastSeenInfo, _ := q.metadataStore.FetchLastSeenInfoMulti(ctx, externalMissingMetrics...)
lastSeenInfo, _ := q.metadataStore.FetchLastSeenInfoMulti(ctx, missingMetrics...)
nonExistentMetrics := []string{}
for _, name := range externalMissingMetrics {
for _, name := range missingMetrics {
if ts, ok := lastSeenInfo[name]; ok && ts > 0 {
continue
}
@@ -413,11 +400,11 @@ func (q *querier) resolveMetricMetadata(ctx context.Context, queries []qbtypes.Q
}
return name
}
if len(externalMissingMetrics) == 1 {
if len(missingMetrics) == 1 {
dormantWarning = fmt.Sprintf("no data found for the metric %s in the query time range", lastSeenStr(missingMetrics[0]))
} else {
parts := make([]string, len(externalMissingMetrics))
for i, m := range externalMissingMetrics {
parts := make([]string, len(missingMetrics))
for i, m := range missingMetrics {
parts[i] = lastSeenStr(m)
}
dormantWarning = fmt.Sprintf("no data found for the following metrics in the query time range: %s", strings.Join(parts, ", "))

View File

@@ -143,7 +143,7 @@ type Config struct {
CloudIntegration cloudintegration.Config `mapstructure:"cloudintegration"`
// TraceDetail config
TraceDetail tracedetail.Config `mapstructure:"traces"`
TraceDetail tracedetail.Config `mapstructure:"tracedetail"`
// Authz config
Authz authz.Config `mapstructure:"authz"`

View File

@@ -716,6 +716,22 @@ func aggOrderBy(k qbtypes.OrderBy, q qbtypes.QueryBuilderQuery[qbtypes.LogAggreg
return 0, false
}
// maybeAttachResourceFilter decides whether to pre-filter on the resource table.
//
// The resource table maps resource attributes (e.g. service.name) to fingerprints.
// When it helps, we look up the matching fingerprints there first and feed them to the
// main query as a CTE, so the main table only scans those fingerprints.
//
// There are three outcomes:
//
// 1. Skip it — the filter has nothing we can pre-resolve. This happens when the filter
// is empty,no resource filter, or when its resource conditions sit under an OR (e.g.
// `name='GET' OR service.name='abc'`), because then we can't reduce to a fixed set
// of fingerprints. The main query filters on resource attributes inline instead.
// 2. Skip it — too many fingerprints match (over the configured threshold), so the CTE
// would not be selective enough to be worth it. Again, filter inline on the main table.
// 3. Use it — attach the matching fingerprints as the __resource_filter CTE and join
// the main table on resource_fingerprint.
func (b *logQueryStatementBuilder) maybeAttachResourceFilter(
ctx context.Context,
sb *sqlbuilder.SelectBuilder,

View File

@@ -1230,7 +1230,7 @@ func TestSkipResourceFingerprintLogs(t *testing.T) {
mockStore := telemetrystoretest.New(telemetrystore.Config{}, &regexQueryMatcher{})
mock := mockStore.Mock()
mock.ExpectQueryRow(`SELECT count\(\) FROM \(SELECT fingerprint FROM signoz_logs\.distributed_logs_v2_resource`).
mock.ExpectQueryRow(`SELECT uniq\(fingerprint\) FROM signoz_logs\.distributed_logs_v2_resource`).
WillReturnRow(cmock.NewRow([]cmock.ColumnType{
{Name: "count", Type: "UInt64"},
}, []any{uint64(2)}))
@@ -1250,7 +1250,7 @@ func TestSkipResourceFingerprintLogs(t *testing.T) {
mockStore := telemetrystoretest.New(telemetrystore.Config{}, &regexQueryMatcher{})
mock := mockStore.Mock()
mock.ExpectQueryRow(`SELECT count\(\) FROM \(SELECT fingerprint FROM signoz_logs\.distributed_logs_v2_resource`).
mock.ExpectQueryRow(`SELECT uniq\(fingerprint\) FROM signoz_logs\.distributed_logs_v2_resource`).
WillReturnRow(cmock.NewRow([]cmock.ColumnType{
{Name: "count", Type: "UInt64"},
}, []any{threshold}))

View File

@@ -98,17 +98,7 @@ func (b *resourceFilterStatementBuilder[T]) Build(
query qbtypes.QueryBuilderQuery[T],
variables map[string]qbtypes.VariableItem,
) (*qbtypes.Statement, error) {
q := sqlbuilder.NewSelectBuilder()
q.Select("fingerprint")
q.From(fmt.Sprintf("%s.%s", b.dbName, b.tableName))
keySelectors := b.getKeySelectors(query)
keys, _, err := b.metadataStore.GetKeysMulti(ctx, keySelectors)
if err != nil {
return nil, err
}
isNoOp, err := b.addConditions(ctx, q, start, end, query, keys, variables)
q, isNoOp, err := b.buildQuery(ctx, start, end, "fingerprint", query, variables)
if err != nil {
return nil, err
}
@@ -127,8 +117,37 @@ func (b *resourceFilterStatementBuilder[T]) Build(
}, nil
}
// buildQuery selects selectExpr from the resource table and applies the filter and
// time conditions. isNoOp is true when the filter resolves to no resource conditions.
func (b *resourceFilterStatementBuilder[T]) buildQuery(
ctx context.Context,
start, end uint64,
selectExpr string,
query qbtypes.QueryBuilderQuery[T],
variables map[string]qbtypes.VariableItem,
) (*sqlbuilder.SelectBuilder, bool, error) {
q := sqlbuilder.NewSelectBuilder()
q.Select(selectExpr)
q.From(fmt.Sprintf("%s.%s", b.dbName, b.tableName))
keySelectors := b.getKeySelectors(query)
keys, _, err := b.metadataStore.GetKeysMulti(ctx, keySelectors)
if err != nil {
return nil, false, err
}
isNoOp, err := b.addConditions(ctx, q, start, end, query, keys, variables)
if err != nil {
return nil, false, err
}
return q, isNoOp, nil
}
// BuildCount returns a statement that counts the distinct fingerprints matching
// the resource filter. Returns (nil, nil) when the filter is a no-op.
//
// It uses uniq() rather than count() over a GROUP BY: uniq's approximation is well
// within tolerance for a threshold check and is ~2x faster with far less memory.
func (b *resourceFilterStatementBuilder[T]) BuildCount(
ctx context.Context,
start uint64,
@@ -136,13 +155,18 @@ func (b *resourceFilterStatementBuilder[T]) BuildCount(
query qbtypes.QueryBuilderQuery[T],
variables map[string]qbtypes.VariableItem,
) (*qbtypes.Statement, error) {
inner, err := b.Build(ctx, start, end, qbtypes.RequestTypeRaw, query, variables)
if err != nil || inner == nil {
q, isNoOp, err := b.buildQuery(ctx, start, end, "uniq(fingerprint)", query, variables)
if err != nil {
return nil, err
}
if isNoOp {
return nil, nil //nolint:nilnil
}
stmt, args := q.BuildWithFlavor(sqlbuilder.ClickHouse)
return &qbtypes.Statement{
Query: fmt.Sprintf("SELECT count() FROM (%s)", inner.Query),
Args: inner.Args,
Query: stmt,
Args: args,
}, nil
}

View File

@@ -818,6 +818,22 @@ func aggOrderBy(k qbtypes.OrderBy, q qbtypes.QueryBuilderQuery[qbtypes.TraceAggr
return 0, false
}
// maybeAttachResourceFilter decides whether to pre-filter on the resource table.
//
// The resource table maps resource attributes (e.g. service.name) to fingerprints.
// When it helps, we look up the matching fingerprints there first and feed them to the
// main query as a CTE, so the main table only scans those fingerprints.
//
// There are three outcomes:
//
// 1. Skip it — the filter has nothing we can pre-resolve. This happens when the filter
// is empty,no resource filter, or when its resource conditions sit under an OR (e.g.
// `name='GET' OR service.name='abc'`), because then we can't reduce to a fixed set
// of fingerprints. The main query filters on resource attributes inline instead.
// 2. Skip it — too many fingerprints match (over the configured threshold), so the CTE
// would not be selective enough to be worth it. Again, filter inline on the main table.
// 3. Use it — attach the matching fingerprints as the __resource_filter CTE and join
// the main table on resource_fingerprint.
func (b *traceQueryStatementBuilder) maybeAttachResourceFilter(
ctx context.Context,
sb *sqlbuilder.SelectBuilder,

View File

@@ -1629,7 +1629,7 @@ func TestSkipResourceFingerprint(t *testing.T) {
// Only the count query runs against the telemetry store; the CTE
// itself is embedded as SQL in the main query (no extra round trip).
mock.ExpectQueryRow(`SELECT count\(\) FROM \(SELECT fingerprint FROM signoz_traces\.distributed_traces_v3_resource`).
mock.ExpectQueryRow(`SELECT uniq\(fingerprint\) FROM signoz_traces\.distributed_traces_v3_resource`).
WillReturnRow(cmock.NewRow([]cmock.ColumnType{
{Name: "count", Type: "UInt64"},
}, []any{uint64(2)}))
@@ -1649,7 +1649,7 @@ func TestSkipResourceFingerprint(t *testing.T) {
mockStore := telemetrystoretest.New(telemetrystore.Config{}, &regexQueryMatcher{})
mock := mockStore.Mock()
mock.ExpectQueryRow(`SELECT count\(\) FROM \(SELECT fingerprint FROM signoz_traces\.distributed_traces_v3_resource`).
mock.ExpectQueryRow(`SELECT uniq\(fingerprint\) FROM signoz_traces\.distributed_traces_v3_resource`).
WillReturnRow(cmock.NewRow([]cmock.ColumnType{
{Name: "count", Type: "UInt64"},
}, []any{threshold}))

View File

@@ -27,7 +27,6 @@ var (
// Azure services.
AzureServiceStorageAccountsBlob = ServiceID{valuer.NewString("storageaccountsblob")}
AzureServiceCDNProfile = ServiceID{valuer.NewString("cdnprofile")}
AzureServiceAppService = ServiceID{valuer.NewString("appservice")}
)
func (ServiceID) Enum() []any {
@@ -47,7 +46,6 @@ func (ServiceID) Enum() []any {
AWSServiceSQS,
AzureServiceStorageAccountsBlob,
AzureServiceCDNProfile,
AzureServiceAppService,
}
}
@@ -71,7 +69,6 @@ var SupportedServices = map[CloudProviderType][]ServiceID{
CloudProviderTypeAzure: {
AzureServiceStorageAccountsBlob,
AzureServiceCDNProfile,
AzureServiceAppService,
},
}

View File

@@ -640,32 +640,6 @@ def test_non_existent_metrics_returns_404(
assert get_error_message(response.json()) == "could not find the metric whatevergoennnsgoeshere"
def test_non_existent_internal_metrics_returns_no_warning(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
metric_name = "signoz_calls_total"
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
query = build_builder_query(
"A",
metric_name,
"doesnotreallymatter",
"sum",
)
end_ms = int(now.timestamp() * 1000)
start_2h = int((now - timedelta(hours=2)).timestamp() * 1000)
response = make_query_request(signoz, token, start_2h, end_ms, [query])
assert response.status_code == HTTPStatus.OK
data = response.json()
assert get_all_warnings(data) == []
# Verify /api/v1/fields/values filters label values by metricNamespace prefix.
# Inserts metrics under ns.a and ns.b, then asserts a specific prefix returns
# only matching values while a common prefix returns both.

View File

@@ -1,11 +1,18 @@
"""
Transparency check for the skip_resource_fingerprint optimization (traces and logs).
At or above the configured fingerprint threshold the optimization pushes resource
conditions onto the main spans/logs table instead of the fingerprint CTE. That
rewrite must change ClickHouse performance, never the rows: each test runs the same
query against the primary instance (optimization on, threshold=2) and
`signoz_fingerprint` (optimization off) and asserts the responses are identical.
The optimization changes how a query's resource conditions are resolved depending on
how selective they are, but must change only ClickHouse performance, never the rows.
Each test runs the same query against the primary instance (optimization on,
threshold=2) and `signoz_fingerprint` (optimization off) and asserts the responses
are identical, covering all three resolver outcomes:
- CTE: a filter matching fewer fingerprints than the threshold resolves through the
fingerprint CTE.
- Fallback: a filter matching at or above the threshold pushes resource conditions
onto the main spans/logs table instead.
- No-op: a filter with no resource conditions to pre-resolve (no resource field, or
resource fields only under an OR) filters inline on the main table.
"""
from collections.abc import Callable
@@ -65,6 +72,122 @@ def test_skip_resource_fingerprint_traces_fallback_matches_fingerprint(
assert_identical_query_response(optimized, fingerprint)
def test_skip_resource_fingerprint_traces_cte_matches_fingerprint(
signoz: types.SigNoz,
signoz_fingerprint: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""A < 2-fingerprint filter resolves through the fingerprint CTE; rows must match the baseline."""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
# One service shares the env (1 fingerprint < threshold 2 -> CTE); two spans on it
# exercise dedup. A second service in a different env must be excluded.
env = {"deployment.environment": "skip-cte"}
insert_traces(
[
Traces(timestamp=now - timedelta(seconds=10), resources={"service.name": "skip-cte-svc-a", **env}),
Traces(timestamp=now - timedelta(seconds=9), resources={"service.name": "skip-cte-svc-a", **env}),
Traces(timestamp=now - timedelta(seconds=8), resources={"service.name": "skip-cte-other", "deployment.environment": "skip-cte-other-env"}),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
query = BuilderQuery(
signal="traces",
limit=50,
order=[OrderBy(TelemetryFieldKey("timestamp"), "asc")],
filter_expression="deployment.environment = 'skip-cte'",
select_fields=[TelemetryFieldKey("service.name", "string", "resource")],
).to_dict()
start_ms = int((datetime.now(tz=UTC) - timedelta(minutes=5)).timestamp() * 1000)
end_ms = int(datetime.now(tz=UTC).timestamp() * 1000)
optimized = make_query_request(signoz, token, start_ms=start_ms, end_ms=end_ms, request_type="raw", queries=[query])
fingerprint = make_query_request(signoz_fingerprint, token, start_ms=start_ms, end_ms=end_ms, request_type="raw", queries=[query])
assert len(get_rows(optimized)) == 2
assert_identical_query_response(optimized, fingerprint)
def test_skip_resource_fingerprint_traces_or_filter_matches_fingerprint(
signoz: types.SigNoz,
signoz_fingerprint: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""A resource condition under an OR has no fixed fingerprint set, so it filters inline; rows must match the baseline."""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
# `name = ... OR service.name = ...` can't reduce to a fingerprint set (no-op path).
# span-1 matches on name, span-2 on service.name, span-3 matches neither.
env = {"deployment.environment": "skip-or"}
insert_traces(
[
Traces(timestamp=now - timedelta(seconds=10), name="tr-or-name", resources={"service.name": "tr-or-svc-x", **env}),
Traces(timestamp=now - timedelta(seconds=9), name="tr-or-other", resources={"service.name": "tr-or-svc-a", **env}),
Traces(timestamp=now - timedelta(seconds=8), name="tr-or-other", resources={"service.name": "tr-or-svc-b", **env}),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
query = BuilderQuery(
signal="traces",
limit=50,
order=[OrderBy(TelemetryFieldKey("timestamp"), "asc")],
filter_expression="name = 'tr-or-name' OR service.name = 'tr-or-svc-a'",
select_fields=[TelemetryFieldKey("service.name", "string", "resource")],
).to_dict()
start_ms = int((datetime.now(tz=UTC) - timedelta(minutes=5)).timestamp() * 1000)
end_ms = int(datetime.now(tz=UTC).timestamp() * 1000)
optimized = make_query_request(signoz, token, start_ms=start_ms, end_ms=end_ms, request_type="raw", queries=[query])
fingerprint = make_query_request(signoz_fingerprint, token, start_ms=start_ms, end_ms=end_ms, request_type="raw", queries=[query])
assert len(get_rows(optimized)) == 2
assert_identical_query_response(optimized, fingerprint)
def test_skip_resource_fingerprint_traces_no_resource_filter_matches_fingerprint(
signoz: types.SigNoz,
signoz_fingerprint: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""A filter with no resource field has nothing to pre-resolve, so it filters inline; rows must match the baseline."""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
# Filtering only on the span name (an intrinsic, non-resource field) is a no-op for
# the resolver; span-1 matches, span-2 does not.
env = {"deployment.environment": "skip-nr"}
insert_traces(
[
Traces(timestamp=now - timedelta(seconds=10), name="tr-nr-name", resources={"service.name": "tr-nr-svc-a", **env}),
Traces(timestamp=now - timedelta(seconds=9), name="tr-nr-other", resources={"service.name": "tr-nr-svc-b", **env}),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
query = BuilderQuery(
signal="traces",
limit=50,
order=[OrderBy(TelemetryFieldKey("timestamp"), "asc")],
filter_expression="name = 'tr-nr-name'",
select_fields=[TelemetryFieldKey("service.name", "string", "resource")],
).to_dict()
start_ms = int((datetime.now(tz=UTC) - timedelta(minutes=5)).timestamp() * 1000)
end_ms = int(datetime.now(tz=UTC).timestamp() * 1000)
optimized = make_query_request(signoz, token, start_ms=start_ms, end_ms=end_ms, request_type="raw", queries=[query])
fingerprint = make_query_request(signoz_fingerprint, token, start_ms=start_ms, end_ms=end_ms, request_type="raw", queries=[query])
assert len(get_rows(optimized)) == 1
assert_identical_query_response(optimized, fingerprint)
def test_skip_resource_fingerprint_logs_fallback_matches_fingerprint(
signoz: types.SigNoz,
signoz_fingerprint: types.SigNoz,
@@ -103,3 +226,119 @@ def test_skip_resource_fingerprint_logs_fallback_matches_fingerprint(
assert len(get_rows(optimized)) == 3
assert_identical_query_response(optimized, fingerprint)
def test_skip_resource_fingerprint_logs_cte_matches_fingerprint(
signoz: types.SigNoz,
signoz_fingerprint: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
"""A < 2-fingerprint filter resolves through the fingerprint CTE; rows must match the baseline."""
now = datetime.now(tz=UTC)
# One service shares the env (1 fingerprint < threshold 2 -> CTE); two logs on it
# exercise dedup. A second service in a different env must be excluded.
env = {"deployment.environment": "skip-logs-cte"}
insert_logs(
[
Logs(timestamp=now - timedelta(seconds=10), resources={"service.name": "skip-logs-cte-svc-a", **env}, body="a"),
Logs(timestamp=now - timedelta(seconds=9), resources={"service.name": "skip-logs-cte-svc-a", **env}, body="b"),
Logs(timestamp=now - timedelta(seconds=8), resources={"service.name": "skip-logs-cte-other", "deployment.environment": "skip-logs-cte-other-env"}, body="noise"),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
query = BuilderQuery(
signal="logs",
limit=50,
order=[OrderBy(TelemetryFieldKey("timestamp"), "asc")],
filter_expression="deployment.environment = 'skip-logs-cte'",
select_fields=[TelemetryFieldKey("service.name", "string", "resource"), TelemetryFieldKey("body")],
).to_dict()
start_ms = int((datetime.now(tz=UTC) - timedelta(minutes=5)).timestamp() * 1000)
end_ms = int(datetime.now(tz=UTC).timestamp() * 1000)
optimized = make_query_request(signoz, token, start_ms=start_ms, end_ms=end_ms, request_type="raw", queries=[query])
fingerprint = make_query_request(signoz_fingerprint, token, start_ms=start_ms, end_ms=end_ms, request_type="raw", queries=[query])
assert len(get_rows(optimized)) == 2
assert_identical_query_response(optimized, fingerprint)
def test_skip_resource_fingerprint_logs_or_filter_matches_fingerprint(
signoz: types.SigNoz,
signoz_fingerprint: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
"""A resource condition under an OR has no fixed fingerprint set, so it filters inline; rows must match the baseline."""
now = datetime.now(tz=UTC)
# `test.marker = ... OR service.name = ...` can't reduce to a fingerprint set (no-op path).
# log-1 matches on the attribute, log-2 on service.name, log-3 matches neither.
env = {"deployment.environment": "skip-logs-or"}
insert_logs(
[
Logs(timestamp=now - timedelta(seconds=10), resources={"service.name": "logs-or-svc-x", **env}, attributes={"test.marker": "logs-or-hit"}, body="a"),
Logs(timestamp=now - timedelta(seconds=9), resources={"service.name": "logs-or-svc-a", **env}, body="b"),
Logs(timestamp=now - timedelta(seconds=8), resources={"service.name": "logs-or-svc-b", **env}, body="noise"),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
query = BuilderQuery(
signal="logs",
limit=50,
order=[OrderBy(TelemetryFieldKey("timestamp"), "asc")],
filter_expression="test.marker = 'logs-or-hit' OR service.name = 'logs-or-svc-a'",
select_fields=[TelemetryFieldKey("service.name", "string", "resource"), TelemetryFieldKey("body")],
).to_dict()
start_ms = int((datetime.now(tz=UTC) - timedelta(minutes=5)).timestamp() * 1000)
end_ms = int(datetime.now(tz=UTC).timestamp() * 1000)
optimized = make_query_request(signoz, token, start_ms=start_ms, end_ms=end_ms, request_type="raw", queries=[query])
fingerprint = make_query_request(signoz_fingerprint, token, start_ms=start_ms, end_ms=end_ms, request_type="raw", queries=[query])
assert len(get_rows(optimized)) == 2
assert_identical_query_response(optimized, fingerprint)
def test_skip_resource_fingerprint_logs_no_resource_filter_matches_fingerprint(
signoz: types.SigNoz,
signoz_fingerprint: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
"""A filter with no resource field has nothing to pre-resolve, so it filters inline; rows must match the baseline."""
now = datetime.now(tz=UTC)
# Filtering only on an attribute (a non-resource field) is a no-op for the resolver;
# log-1 matches, log-2 does not.
env = {"deployment.environment": "skip-logs-nr"}
insert_logs(
[
Logs(timestamp=now - timedelta(seconds=10), resources={"service.name": "logs-nr-svc-a", **env}, attributes={"test.marker": "logs-nr-hit"}, body="a"),
Logs(timestamp=now - timedelta(seconds=9), resources={"service.name": "logs-nr-svc-b", **env}, body="b"),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
query = BuilderQuery(
signal="logs",
limit=50,
order=[OrderBy(TelemetryFieldKey("timestamp"), "asc")],
filter_expression="test.marker = 'logs-nr-hit'",
select_fields=[TelemetryFieldKey("service.name", "string", "resource"), TelemetryFieldKey("body")],
).to_dict()
start_ms = int((datetime.now(tz=UTC) - timedelta(minutes=5)).timestamp() * 1000)
end_ms = int(datetime.now(tz=UTC).timestamp() * 1000)
optimized = make_query_request(signoz, token, start_ms=start_ms, end_ms=end_ms, request_type="raw", queries=[query])
fingerprint = make_query_request(signoz_fingerprint, token, start_ms=start_ms, end_ms=end_ms, request_type="raw", queries=[query])
assert len(get_rows(optimized)) == 1
assert_identical_query_response(optimized, fingerprint)