mirror of
https://github.com/SigNoz/signoz.git
synced 2026-04-30 23:50:27 +01:00
Compare commits
2 Commits
release/v0
...
issue_4360
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95d100aedf | ||
|
|
2a858adae7 |
@@ -190,7 +190,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.121.2
|
||||
image: signoz/signoz:v0.121.0
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
# - "6060:6060" # pprof port
|
||||
|
||||
@@ -117,7 +117,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.121.2
|
||||
image: signoz/signoz:v0.121.0
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
volumes:
|
||||
|
||||
@@ -181,7 +181,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.121.2}
|
||||
image: signoz/signoz:${VERSION:-v0.121.0}
|
||||
container_name: signoz
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
|
||||
@@ -109,7 +109,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.121.2}
|
||||
image: signoz/signoz:${VERSION:-v0.121.0}
|
||||
container_name: signoz
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
|
||||
@@ -17273,9 +17273,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- ADMIN
|
||||
summary: Get host info from Zeus.
|
||||
tags:
|
||||
- zeus
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/cache/memorycache"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/modules/llmpricingrule/impllmpricingrule"
|
||||
|
||||
"github.com/gorilla/handlers"
|
||||
|
||||
@@ -112,9 +113,11 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
|
||||
}
|
||||
|
||||
// initiate agent config handler
|
||||
llmCostFeature := impllmpricingrule.NewLLMCostFeature(signoz.Modules.LLMPricingRule)
|
||||
|
||||
agentConfMgr, err := agentConf.Initiate(&agentConf.ManagerOptions{
|
||||
Store: signoz.SQLStore,
|
||||
AgentFeatures: []agentConf.AgentFeature{logParsingPipelineController},
|
||||
AgentFeatures: []agentConf.AgentFeature{logParsingPipelineController, llmCostFeature},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -14,7 +14,6 @@ import { usePanelContextMenu } from '../../hooks/usePanelContextMenu';
|
||||
import { prepareBarPanelConfig, prepareBarPanelData } from './utils';
|
||||
|
||||
import '../Panel.styles.scss';
|
||||
import get from 'lodash/get';
|
||||
|
||||
function BarPanel(props: PanelWrapperProps): JSX.Element {
|
||||
const {
|
||||
@@ -115,7 +114,7 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
|
||||
}, []);
|
||||
|
||||
const groupBy = useMemo(() => {
|
||||
return get(widget, 'query.builder.queryData[0].groupBy', []);
|
||||
return widget.query.builder.queryData[0].groupBy;
|
||||
}, [widget.query]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -10,7 +10,6 @@ import { ContextMenu } from 'periscope/components/ContextMenu';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import uPlot from 'uplot';
|
||||
import { getTimeRange } from 'utils/getTimeRange';
|
||||
import get from 'lodash/get';
|
||||
|
||||
import { prepareChartData, prepareUPlotConfig } from '../TimeSeriesPanel/utils';
|
||||
|
||||
@@ -106,7 +105,7 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
|
||||
]);
|
||||
|
||||
const groupBy = useMemo(() => {
|
||||
return get(widget, 'query.builder.queryData[0].groupBy', []);
|
||||
return widget.query.builder.queryData[0].groupBy;
|
||||
}, [widget.query]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -67,40 +67,4 @@ describe('AuthCard', () => {
|
||||
|
||||
expect(mockOnCreateServiceAccount).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows URL for non-admin (all roles can fetch instance URL)', () => {
|
||||
render(<AuthCard {...defaultProps} isAdmin={false} />);
|
||||
|
||||
expect(screen.getByTestId('mcp-instance-url')).toHaveTextContent(
|
||||
'http://localhost',
|
||||
);
|
||||
});
|
||||
|
||||
describe('isLoadingInstanceUrl', () => {
|
||||
it('shows a skeleton and hides the URL while loading', () => {
|
||||
render(<AuthCard {...defaultProps} isAdmin isLoadingInstanceUrl />);
|
||||
|
||||
expect(screen.queryByTestId('mcp-instance-url')).not.toBeInTheDocument();
|
||||
expect(document.querySelector('.ant-skeleton-input')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render the copy button while loading', () => {
|
||||
render(<AuthCard {...defaultProps} isAdmin isLoadingInstanceUrl />);
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Copy SigNoz instance URL' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the URL and copy button once loading is done', () => {
|
||||
render(<AuthCard {...defaultProps} isAdmin isLoadingInstanceUrl={false} />);
|
||||
|
||||
expect(screen.getByTestId('mcp-instance-url')).toHaveTextContent(
|
||||
'http://localhost',
|
||||
);
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Copy SigNoz instance URL' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Skeleton } from 'antd';
|
||||
import { Badge, Button } from '@signozhq/ui';
|
||||
import { Info, KeyRound } from '@signozhq/icons';
|
||||
import CopyIconButton from '../CopyIconButton';
|
||||
@@ -8,7 +7,6 @@ import './AuthCard.styles.scss';
|
||||
interface AuthCardProps {
|
||||
isAdmin: boolean;
|
||||
instanceUrl: string;
|
||||
isLoadingInstanceUrl?: boolean;
|
||||
onCopyInstanceUrl: () => void;
|
||||
onCreateServiceAccount: () => void;
|
||||
}
|
||||
@@ -16,7 +14,6 @@ interface AuthCardProps {
|
||||
function AuthCard({
|
||||
isAdmin,
|
||||
instanceUrl,
|
||||
isLoadingInstanceUrl = false,
|
||||
onCopyInstanceUrl,
|
||||
onCreateServiceAccount,
|
||||
}: AuthCardProps): JSX.Element {
|
||||
@@ -35,18 +32,13 @@ function AuthCard({
|
||||
|
||||
<div className="mcp-auth-card__field">
|
||||
<span className="mcp-auth-card__field-label">SigNoz Instance URL</span>
|
||||
{isLoadingInstanceUrl ? (
|
||||
<Skeleton.Input active size="small" />
|
||||
) : (
|
||||
<div className="mcp-auth-card__endpoint-value">
|
||||
<span data-testid="mcp-instance-url">{instanceUrl}</span>
|
||||
<CopyIconButton
|
||||
ariaLabel="Copy SigNoz instance URL"
|
||||
onCopy={onCopyInstanceUrl}
|
||||
disabled={isLoadingInstanceUrl}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="mcp-auth-card__endpoint-value">
|
||||
<span data-testid="mcp-instance-url">{instanceUrl}</span>
|
||||
<CopyIconButton
|
||||
ariaLabel="Copy SigNoz instance URL"
|
||||
onCopy={onCopyInstanceUrl}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mcp-auth-card__field">
|
||||
|
||||
@@ -6,8 +6,6 @@ const mockLogEvent = jest.fn();
|
||||
const mockCopyToClipboard = jest.fn();
|
||||
const mockHistoryPush = jest.fn();
|
||||
const mockUseGetGlobalConfig = jest.fn();
|
||||
const mockUseGetHosts = jest.fn();
|
||||
const mockUseGetTenantLicense = jest.fn();
|
||||
const mockToastSuccess = jest.fn();
|
||||
const mockToastWarning = jest.fn();
|
||||
|
||||
@@ -21,14 +19,6 @@ jest.mock('api/generated/services/global', () => ({
|
||||
mockUseGetGlobalConfig(...args),
|
||||
}));
|
||||
|
||||
jest.mock('api/generated/services/zeus', () => ({
|
||||
useGetHosts: (...args: unknown[]): unknown => mockUseGetHosts(...args),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useGetTenantLicense', () => ({
|
||||
useGetTenantLicense: (): unknown => mockUseGetTenantLicense(),
|
||||
}));
|
||||
|
||||
jest.mock('react-use', () => ({
|
||||
__esModule: true,
|
||||
useCopyToClipboard: (): [unknown, jest.Mock] => [null, mockCopyToClipboard],
|
||||
@@ -57,23 +47,6 @@ jest.mock('utils/basePath', () => ({
|
||||
}));
|
||||
|
||||
const MCP_URL = 'https://mcp.us.signoz.cloud/mcp';
|
||||
const CUSTOM_HOST_URL = 'https://myteam.signoz.cloud';
|
||||
const DEFAULT_HOST_URL = 'https://default.signoz.cloud';
|
||||
|
||||
function setupLicense({
|
||||
isCloudUser = true,
|
||||
isEnterpriseSelfHostedUser = false,
|
||||
}: {
|
||||
isCloudUser?: boolean;
|
||||
isEnterpriseSelfHostedUser?: boolean;
|
||||
} = {}): void {
|
||||
mockUseGetTenantLicense.mockReturnValue({
|
||||
isCloudUser,
|
||||
isEnterpriseSelfHostedUser,
|
||||
isCommunityUser: !isCloudUser && !isEnterpriseSelfHostedUser,
|
||||
isCommunityEnterpriseUser: false,
|
||||
});
|
||||
}
|
||||
|
||||
function setupGlobalConfig({ mcpUrl }: { mcpUrl: string | null }): void {
|
||||
mockUseGetGlobalConfig.mockReturnValue({
|
||||
@@ -82,29 +55,7 @@ function setupGlobalConfig({ mcpUrl }: { mcpUrl: string | null }): void {
|
||||
});
|
||||
}
|
||||
|
||||
function setupHosts({
|
||||
hosts = [],
|
||||
isLoading = false,
|
||||
isError = false,
|
||||
}: {
|
||||
hosts?: { name?: string; url?: string; is_default?: boolean }[];
|
||||
isLoading?: boolean;
|
||||
isError?: boolean;
|
||||
} = {}): void {
|
||||
mockUseGetHosts.mockReturnValue({
|
||||
data: isLoading || isError ? undefined : { data: { hosts } },
|
||||
isLoading,
|
||||
isError,
|
||||
});
|
||||
}
|
||||
|
||||
describe('MCPServerSettings', () => {
|
||||
beforeEach(() => {
|
||||
// Default: cloud user, hosts loaded but empty → instanceUrl falls back to getBaseUrl()
|
||||
setupLicense();
|
||||
setupHosts();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
@@ -207,145 +158,4 @@ describe('MCPServerSettings', () => {
|
||||
'Instance URL copied to clipboard',
|
||||
);
|
||||
});
|
||||
|
||||
describe('instance URL resolution', () => {
|
||||
it('uses the active custom host URL when available', async () => {
|
||||
setupGlobalConfig({ mcpUrl: MCP_URL });
|
||||
setupHosts({
|
||||
hosts: [
|
||||
{ name: 'default', url: DEFAULT_HOST_URL, is_default: true },
|
||||
{ name: 'myteam', url: CUSTOM_HOST_URL, is_default: false },
|
||||
],
|
||||
});
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<MCPServerSettings />);
|
||||
|
||||
expect(screen.getByTestId('mcp-instance-url')).toHaveTextContent(
|
||||
CUSTOM_HOST_URL,
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Copy SigNoz instance URL' }),
|
||||
);
|
||||
|
||||
expect(mockCopyToClipboard).toHaveBeenCalledWith(CUSTOM_HOST_URL);
|
||||
});
|
||||
|
||||
it('falls back to the default host URL when no custom host exists', async () => {
|
||||
setupGlobalConfig({ mcpUrl: MCP_URL });
|
||||
setupHosts({
|
||||
hosts: [{ name: 'default', url: DEFAULT_HOST_URL, is_default: true }],
|
||||
});
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<MCPServerSettings />);
|
||||
|
||||
expect(screen.getByTestId('mcp-instance-url')).toHaveTextContent(
|
||||
DEFAULT_HOST_URL,
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Copy SigNoz instance URL' }),
|
||||
);
|
||||
|
||||
expect(mockCopyToClipboard).toHaveBeenCalledWith(DEFAULT_HOST_URL);
|
||||
});
|
||||
|
||||
it('falls back to browser URL when hosts request errors', async () => {
|
||||
setupGlobalConfig({ mcpUrl: MCP_URL });
|
||||
setupHosts({ isError: true });
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<MCPServerSettings />);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Copy SigNoz instance URL' }),
|
||||
);
|
||||
|
||||
expect(mockCopyToClipboard).toHaveBeenCalledWith('http://localhost');
|
||||
});
|
||||
|
||||
it('shows URL skeleton while hosts are loading', () => {
|
||||
setupGlobalConfig({ mcpUrl: MCP_URL });
|
||||
setupHosts({ isLoading: true });
|
||||
|
||||
render(<MCPServerSettings />);
|
||||
|
||||
expect(screen.queryByTestId('mcp-instance-url')).not.toBeInTheDocument();
|
||||
expect(document.querySelector('.ant-skeleton-input')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not copy while hosts are still loading', async () => {
|
||||
setupGlobalConfig({ mcpUrl: MCP_URL });
|
||||
setupHosts({ isLoading: true });
|
||||
userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<MCPServerSettings />);
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Copy SigNoz instance URL' }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(mockCopyToClipboard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('disables the hosts query for non-cloud deployments', () => {
|
||||
setupGlobalConfig({ mcpUrl: MCP_URL });
|
||||
setupLicense({ isCloudUser: false, isEnterpriseSelfHostedUser: true });
|
||||
|
||||
render(<MCPServerSettings />, undefined, { role: 'ADMIN' });
|
||||
|
||||
const callOptions = mockUseGetHosts.mock.calls[0]?.[0];
|
||||
expect(callOptions?.query?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('uses browser URL immediately for enterprise self-hosted (no skeleton)', async () => {
|
||||
setupGlobalConfig({ mcpUrl: MCP_URL });
|
||||
setupLicense({ isCloudUser: false, isEnterpriseSelfHostedUser: true });
|
||||
setupHosts({ isLoading: false });
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<MCPServerSettings />, undefined, { role: 'ADMIN' });
|
||||
|
||||
expect(
|
||||
document.querySelector('.ant-skeleton-input'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('mcp-instance-url')).toHaveTextContent(
|
||||
'http://localhost',
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Copy SigNoz instance URL' }),
|
||||
);
|
||||
|
||||
expect(mockCopyToClipboard).toHaveBeenCalledWith('http://localhost');
|
||||
});
|
||||
|
||||
it('enables the hosts query for all cloud users including viewers', () => {
|
||||
setupGlobalConfig({ mcpUrl: MCP_URL });
|
||||
setupLicense({ isCloudUser: true });
|
||||
|
||||
render(<MCPServerSettings />, undefined, { role: 'VIEWER' });
|
||||
|
||||
const callOptions = mockUseGetHosts.mock.calls[0]?.[0];
|
||||
expect(callOptions?.query?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('shows instance URL for cloud viewer', () => {
|
||||
setupGlobalConfig({ mcpUrl: MCP_URL });
|
||||
setupLicense({ isCloudUser: true });
|
||||
setupHosts({
|
||||
hosts: [{ name: 'default', url: DEFAULT_HOST_URL, is_default: true }],
|
||||
});
|
||||
|
||||
render(<MCPServerSettings />, undefined, { role: 'VIEWER' });
|
||||
|
||||
expect(
|
||||
document.querySelector('.ant-skeleton-input'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('mcp-instance-url')).toHaveTextContent(
|
||||
DEFAULT_HOST_URL,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
|
||||
import { useGetGlobalConfig } from 'api/generated/services/global';
|
||||
import { useGetHosts } from 'api/generated/services/zeus';
|
||||
import history from 'lib/history';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import { getBaseUrl } from 'utils/basePath';
|
||||
|
||||
@@ -36,23 +34,7 @@ function MCPServerSettings(): JSX.Element {
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
|
||||
const isAdmin = user.role === USER_ROLES.ADMIN;
|
||||
const { isCloudUser } = useGetTenantLicense();
|
||||
|
||||
const {
|
||||
data: hostsData,
|
||||
isLoading: isLoadingHosts,
|
||||
isError: isHostsError,
|
||||
} = useGetHosts({ query: { enabled: isCloudUser } });
|
||||
|
||||
const instanceUrl = useMemo(() => {
|
||||
if (isLoadingHosts || isHostsError || !hostsData) {
|
||||
return getBaseUrl();
|
||||
}
|
||||
const hosts = hostsData.data?.hosts ?? [];
|
||||
const activeHost =
|
||||
hosts.find((h) => !h.is_default) ?? hosts.find((h) => h.is_default);
|
||||
return activeHost?.url ?? getBaseUrl();
|
||||
}, [hostsData, isLoadingHosts, isHostsError]);
|
||||
const instanceUrl = getBaseUrl();
|
||||
|
||||
const { data: globalConfig, isLoading: isConfigLoading } =
|
||||
useGetGlobalConfig();
|
||||
@@ -88,13 +70,10 @@ function MCPServerSettings(): JSX.Element {
|
||||
}, []);
|
||||
|
||||
const handleCopyInstanceUrl = useCallback(() => {
|
||||
if (isLoadingHosts) {
|
||||
return;
|
||||
}
|
||||
copyToClipboard(instanceUrl);
|
||||
toast.success('Instance URL copied to clipboard');
|
||||
void logEvent(ANALYTICS.INSTANCE_URL_COPIED, {});
|
||||
}, [copyToClipboard, instanceUrl, isLoadingHosts]);
|
||||
}, [copyToClipboard, instanceUrl]);
|
||||
|
||||
const handleDocsLinkClick = useCallback((target: string) => {
|
||||
void logEvent(ANALYTICS.DOCS_LINK_CLICKED, { target });
|
||||
@@ -153,7 +132,6 @@ function MCPServerSettings(): JSX.Element {
|
||||
<AuthCard
|
||||
isAdmin={isAdmin}
|
||||
instanceUrl={instanceUrl}
|
||||
isLoadingInstanceUrl={isLoadingHosts}
|
||||
onCopyInstanceUrl={handleCopyInstanceUrl}
|
||||
onCreateServiceAccount={handleCreateServiceAccount}
|
||||
/>
|
||||
|
||||
@@ -36,7 +36,6 @@ function SettingsPage(): JSX.Element {
|
||||
|
||||
const isAdmin = user.role === USER_ROLES.ADMIN;
|
||||
const isEditor = user.role === USER_ROLES.EDITOR;
|
||||
const isViewer = user.role === USER_ROLES.VIEWER;
|
||||
|
||||
const isWorkspaceBlocked = trialInfo?.workSpaceBlock || false;
|
||||
|
||||
@@ -103,13 +102,6 @@ function SettingsPage(): JSX.Element {
|
||||
: item.isEnabled,
|
||||
}));
|
||||
}
|
||||
|
||||
if (isViewer) {
|
||||
updatedItems = updatedItems.map((item) => ({
|
||||
...item,
|
||||
isEnabled: item.key === ROUTES.MCP_SERVER ? true : item.isEnabled,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if (isEnterpriseSelfHostedUser) {
|
||||
@@ -142,13 +134,6 @@ function SettingsPage(): JSX.Element {
|
||||
: item.isEnabled,
|
||||
}));
|
||||
}
|
||||
|
||||
if (isViewer) {
|
||||
updatedItems = updatedItems.map((item) => ({
|
||||
...item,
|
||||
isEnabled: item.key === ROUTES.MCP_SERVER ? true : item.isEnabled,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if (!isCloudUser && !isEnterpriseSelfHostedUser) {
|
||||
@@ -181,7 +166,6 @@ function SettingsPage(): JSX.Element {
|
||||
}, [
|
||||
isAdmin,
|
||||
isEditor,
|
||||
isViewer,
|
||||
isCloudUser,
|
||||
isEnterpriseSelfHostedUser,
|
||||
isFetchingActiveLicense,
|
||||
|
||||
@@ -82,13 +82,12 @@ describe('SettingsPage nav sections', () => {
|
||||
expect(screen.getByTestId(id)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each(['billing', 'roles'])('does not render "%s" element', (id) => {
|
||||
expect(screen.queryByTestId(id)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "mcp-server" element', () => {
|
||||
expect(screen.getByTestId('mcp-server')).toBeInTheDocument();
|
||||
});
|
||||
it.each(['billing', 'roles', 'mcp-server'])(
|
||||
'does not render "%s" element',
|
||||
(id) => {
|
||||
expect(screen.queryByTestId(id)).not.toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('Self-hosted Admin', () => {
|
||||
|
||||
@@ -27,7 +27,7 @@ func (provider *provider) addZeusRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/zeus/hosts", handler.New(provider.authZ.ViewAccess(provider.zeusHandler.GetHosts), handler.OpenAPIDef{
|
||||
if err := router.Handle("/api/v2/zeus/hosts", handler.New(provider.authZ.AdminAccess(provider.zeusHandler.GetHosts), handler.OpenAPIDef{
|
||||
ID: "GetHosts",
|
||||
Tags: []string{"zeus"},
|
||||
Summary: "Get host info from Zeus.",
|
||||
@@ -39,7 +39,7 @@ func (provider *provider) addZeusRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package impllmpricingrule
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/modules/llmpricingrule"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
|
||||
"github.com/SigNoz/signoz/pkg/types/llmpricingruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/opamptypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
const LLMCostFeatureType agentConf.AgentFeatureType = "llm_pricing"
|
||||
|
||||
// LLMCostFeature implements agentConf.AgentFeature. It reads pricing rules
|
||||
// from the module and generates the signozllmpricing processor config for
|
||||
// deployment to OTel collectors via OpAMP.
|
||||
type LLMCostFeature struct {
|
||||
module llmpricingrule.Module
|
||||
}
|
||||
|
||||
func NewLLMCostFeature(module llmpricingrule.Module) *LLMCostFeature {
|
||||
return &LLMCostFeature{module: module}
|
||||
}
|
||||
|
||||
func (f *LLMCostFeature) AgentFeatureType() agentConf.AgentFeatureType {
|
||||
return LLMCostFeatureType
|
||||
}
|
||||
|
||||
func (f *LLMCostFeature) RecommendAgentConfig(
|
||||
orgId valuer.UUID,
|
||||
currentConfYaml []byte,
|
||||
configVersion *opamptypes.AgentConfigVersion,
|
||||
) ([]byte, string, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
rules, err := f.getEnabledRules(ctx, orgId)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
updatedConf, err := generateCollectorConfigWithLLMPricingProcessor(currentConfYaml, rules)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
serialized, err := json.Marshal(rules)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
return updatedConf, string(serialized), nil
|
||||
}
|
||||
|
||||
// getEnabledRules fetches all enabled pricing rules for the given org.
|
||||
func (f *LLMCostFeature) getEnabledRules(ctx context.Context, orgId valuer.UUID) ([]*llmpricingruletypes.LLMPricingRule, error) {
|
||||
if f.module == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
rules, _, err := f.module.List(ctx, orgId, 0, 10000)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
enabled := make([]*llmpricingruletypes.LLMPricingRule, 0, len(rules))
|
||||
for _, r := range rules {
|
||||
if r.Enabled {
|
||||
enabled = append(enabled, r)
|
||||
}
|
||||
}
|
||||
return enabled, nil
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package impllmpricingrule
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/llmpricingruletypes"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const processorName = "signozllmpricing"
|
||||
|
||||
// buildProcessorConfig converts pricing rules into the signozllmpricing processor config.
|
||||
func buildProcessorConfig(rules []*llmpricingruletypes.LLMPricingRule) *llmpricingruletypes.LLMPricingRuleProcessorConfig {
|
||||
pricingRules := make([]llmpricingruletypes.LLMPricingRuleProcessor, 0, len(rules))
|
||||
for _, r := range rules {
|
||||
var cache llmpricingruletypes.LLMPricingRuleProcessorCache
|
||||
if r.Pricing.Cache != nil {
|
||||
cache = llmpricingruletypes.LLMPricingRuleProcessorCache{
|
||||
Mode: r.Pricing.Cache.Mode.StringValue(),
|
||||
Read: r.Pricing.Cache.Read,
|
||||
Write: r.Pricing.Cache.Write,
|
||||
}
|
||||
}
|
||||
pricingRules = append(pricingRules, llmpricingruletypes.LLMPricingRuleProcessor{
|
||||
Name: r.Model,
|
||||
Pattern: r.ModelPattern,
|
||||
Cache: cache,
|
||||
In: r.Pricing.Input,
|
||||
Out: r.Pricing.Output,
|
||||
})
|
||||
}
|
||||
|
||||
return &llmpricingruletypes.LLMPricingRuleProcessorConfig{
|
||||
Attrs: llmpricingruletypes.LLMPricingRuleProcessorAttrs{
|
||||
Model: "gen_ai.request.model",
|
||||
In: "gen_ai.usage.input_tokens",
|
||||
Out: "gen_ai.usage.output_tokens",
|
||||
CacheRead: "gen_ai.usage.cache_read.input_tokens",
|
||||
CacheWrite: "gen_ai.usage.cache_creation.input_tokens",
|
||||
},
|
||||
DefaultPricing: llmpricingruletypes.LLMPricingRuleProcessorDefaultPricing{
|
||||
Unit: "per_million_tokens",
|
||||
Rules: pricingRules,
|
||||
},
|
||||
OutputAttrs: llmpricingruletypes.LLMPricingRuleProcessorOutputAttrs{
|
||||
In: "_signoz.gen_ai.cost_input",
|
||||
Out: "_signoz.gen_ai.cost_output",
|
||||
CacheRead: "_signoz.gen_ai.cost_cache_read",
|
||||
CacheWrite: "_signoz.gen_ai.cost_cache_write",
|
||||
Total: "_signoz.gen_ai.total_cost",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// generateCollectorConfigWithLLMPricingProcessor injects (or replaces) the signozllmpricing
|
||||
// processor block in the collector YAML with one built from the given rules.
|
||||
// Pipeline wiring is handled by the collector's baseline config, not here.
|
||||
func generateCollectorConfigWithLLMPricingProcessor(
|
||||
currentConfYaml []byte,
|
||||
rules []*llmpricingruletypes.LLMPricingRule,
|
||||
) ([]byte, error) {
|
||||
// Empty input: nothing to inject into. Pass through unchanged so we don't
|
||||
// turn it into "null\n" or fail on yaml.v3's EOF.
|
||||
if len(bytes.TrimSpace(currentConfYaml)) == 0 {
|
||||
return currentConfYaml, nil
|
||||
}
|
||||
|
||||
var collectorConf map[string]any
|
||||
if err := yaml.Unmarshal(currentConfYaml, &collectorConf); err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInvalidInput, llmpricingruletypes.ErrCodeInvalidCollectorConfig, "failed to unmarshal collector config")
|
||||
}
|
||||
// rare but don't do anything in this case, also means it's just comments
|
||||
if collectorConf == nil {
|
||||
return currentConfYaml, nil
|
||||
}
|
||||
|
||||
processors := map[string]any{}
|
||||
if existing, ok := collectorConf["processors"]; ok && existing != nil {
|
||||
p, ok := existing.(map[string]any)
|
||||
if !ok {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, llmpricingruletypes.ErrCodeInvalidCollectorConfig, "collector config 'processors' must be a mapping, got %T", existing)
|
||||
}
|
||||
processors = p
|
||||
}
|
||||
|
||||
processors[processorName] = buildProcessorConfig(rules)
|
||||
collectorConf["processors"] = processors
|
||||
|
||||
out, err := yaml.Marshal(collectorConf)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, llmpricingruletypes.ErrCodeBuildPricingProcessorConf, "failed to marshal llm pricing processor config")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package impllmpricingrule
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/llmpricingruletypes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// assertYAMLEqualToFile decodes both sides into any and compares structurally,
|
||||
// so map key ordering is irrelevant.
|
||||
func assertYAMLEqualToFile(t *testing.T, name string, actual []byte) {
|
||||
t.Helper()
|
||||
expected, err := os.ReadFile(filepath.Join("testdata", name))
|
||||
require.NoError(t, err)
|
||||
|
||||
var e, a any
|
||||
require.NoError(t, yaml.Unmarshal(expected, &e))
|
||||
require.NoError(t, yaml.Unmarshal(actual, &a))
|
||||
assert.Equal(t, e, a)
|
||||
}
|
||||
|
||||
func makePricingRule(model string, patterns []string, cacheMode llmpricingruletypes.LLMPricingRuleCacheMode, costIn, costOut, cacheRead, cacheWrite float64) *llmpricingruletypes.LLMPricingRule {
|
||||
return &llmpricingruletypes.LLMPricingRule{
|
||||
Model: model,
|
||||
ModelPattern: llmpricingruletypes.StringSlice(patterns),
|
||||
Unit: llmpricingruletypes.UnitPerMillionTokens,
|
||||
Pricing: llmpricingruletypes.LLMRulePricing{
|
||||
Input: costIn,
|
||||
Output: costOut,
|
||||
Cache: &llmpricingruletypes.LLMPricingCacheCosts{
|
||||
Mode: cacheMode,
|
||||
Read: cacheRead,
|
||||
Write: cacheWrite,
|
||||
},
|
||||
},
|
||||
Enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateCollectorConfigWithLLMPricingProcessor(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rules []*llmpricingruletypes.LLMPricingRule
|
||||
expectedFile string
|
||||
}{
|
||||
{
|
||||
name: "with_rule",
|
||||
rules: []*llmpricingruletypes.LLMPricingRule{
|
||||
makePricingRule("gpt-4o", []string{"gpt-4o*"}, llmpricingruletypes.LLMPricingRuleCacheModeSubtract, 5.0, 15.0, 2.5, 0),
|
||||
},
|
||||
expectedFile: "collector_with_rule.yaml",
|
||||
},
|
||||
// We deploy the processor even with zero rules so rules can be added
|
||||
// later (by a user or by Zeus) without any config-shape change.
|
||||
// Pipeline wiring is handled by the collector's baseline config.
|
||||
{
|
||||
name: "no_rules",
|
||||
rules: nil,
|
||||
expectedFile: "collector_no_rules.yaml",
|
||||
},
|
||||
}
|
||||
|
||||
input, err := os.ReadFile(filepath.Join("testdata", "collector_baseline.yaml"))
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
out, err := generateCollectorConfigWithLLMPricingProcessor(input, tc.rules)
|
||||
require.NoError(t, err)
|
||||
assertYAMLEqualToFile(t, tc.expectedFile, out)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateCollectorConfig_EmptyInputPassthrough(t *testing.T) {
|
||||
// yaml.v3 errors on empty/whitespace input; the generator passes such
|
||||
// input through unchanged instead.
|
||||
rules := []*llmpricingruletypes.LLMPricingRule{
|
||||
makePricingRule("gpt-4o", []string{"gpt-4o*"}, llmpricingruletypes.LLMPricingRuleCacheModeSubtract, 5.0, 15.0, 2.5, 0),
|
||||
}
|
||||
|
||||
for _, in := range [][]byte{nil, []byte(" \n")} {
|
||||
out, err := generateCollectorConfigWithLLMPricingProcessor(in, rules)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, in, out)
|
||||
}
|
||||
}
|
||||
90
pkg/modules/llmpricingrule/impllmpricingrule/module.go
Normal file
90
pkg/modules/llmpricingrule/impllmpricingrule/module.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package impllmpricingrule
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/modules/llmpricingrule"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
|
||||
"github.com/SigNoz/signoz/pkg/types/llmpricingruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type module struct {
|
||||
store llmpricingruletypes.Store
|
||||
}
|
||||
|
||||
func NewModule(store llmpricingruletypes.Store) llmpricingrule.Module {
|
||||
return &module{store: store}
|
||||
}
|
||||
|
||||
func (module *module) List(ctx context.Context, orgID valuer.UUID, offset, limit int) ([]*llmpricingruletypes.LLMPricingRule, int, error) {
|
||||
return module.store.List(ctx, orgID, offset, limit)
|
||||
}
|
||||
|
||||
func (module *module) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*llmpricingruletypes.LLMPricingRule, error) {
|
||||
return module.store.Get(ctx, orgID, id)
|
||||
}
|
||||
|
||||
// CreateOrUpdate applies a batch of pricing rule changes:
|
||||
// - ID set → match by id, overwrite fields.
|
||||
// - SourceID set → match by source_id; if found overwrite, else insert.
|
||||
// - neither set → insert a new user-created row (is_override = true).
|
||||
//
|
||||
// When UpdatableLLMPricingRule.IsOverride is nil AND the matched row has
|
||||
// is_override = true, the row is fully preserved — only synced_at is stamped.
|
||||
func (module *module) CreateOrUpdate(ctx context.Context, orgID valuer.UUID, userEmail string, rules []llmpricingruletypes.UpdatableLLMPricingRule) error {
|
||||
now := time.Now()
|
||||
|
||||
err := module.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
for _, u := range rules {
|
||||
existing, err := module.findExisting(ctx, orgID, u)
|
||||
if err != nil {
|
||||
if !errors.Ast(err, errors.TypeNotFound) {
|
||||
return err
|
||||
}
|
||||
if err := module.store.Create(ctx, llmpricingruletypes.NewLLMPricingRuleFromUpdatable(u, orgID, userEmail, now)); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
existing.Update(u, userEmail, now)
|
||||
if err := module.store.Update(ctx, existing); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
agentConf.NotifyConfigUpdate(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (module *module) Delete(ctx context.Context, orgID, id valuer.UUID) error {
|
||||
if err := module.store.Delete(ctx, orgID, id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
agentConf.NotifyConfigUpdate(ctx)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// findExisting returns the row matching the updatable's ID or SourceID.
|
||||
// Returns a TypeNotFound error when neither matches; the caller treats that
|
||||
// as "insert new".
|
||||
func (module *module) findExisting(ctx context.Context, orgID valuer.UUID, u llmpricingruletypes.UpdatableLLMPricingRule) (*llmpricingruletypes.LLMPricingRule, error) {
|
||||
switch {
|
||||
case u.ID != nil:
|
||||
return module.store.Get(ctx, orgID, *u.ID)
|
||||
case u.SourceID != nil:
|
||||
return module.store.GetBySourceID(ctx, orgID, *u.SourceID)
|
||||
default:
|
||||
return nil, errors.Newf(errors.TypeNotFound, llmpricingruletypes.ErrCodePricingRuleNotFound, "rule has neither id nor sourceId")
|
||||
}
|
||||
}
|
||||
131
pkg/modules/llmpricingrule/impllmpricingrule/store.go
Normal file
131
pkg/modules/llmpricingrule/impllmpricingrule/store.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package impllmpricingrule
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/llmpricingruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type store struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
}
|
||||
|
||||
func NewStore(sqlstore sqlstore.SQLStore) llmpricingruletypes.Store {
|
||||
return &store{sqlstore: sqlstore}
|
||||
}
|
||||
|
||||
func (store *store) List(ctx context.Context, orgID valuer.UUID, offset, limit int) ([]*llmpricingruletypes.LLMPricingRule, int, error) {
|
||||
rules := make([]*llmpricingruletypes.LLMPricingRule, 0)
|
||||
|
||||
count, err := store.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(&rules).
|
||||
Where("org_id = ?", orgID).
|
||||
Order("created_at DESC").
|
||||
Offset(offset).
|
||||
Limit(limit).
|
||||
ScanAndCount(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return rules, count, nil
|
||||
}
|
||||
|
||||
func (store *store) Get(ctx context.Context, orgID, id valuer.UUID) (*llmpricingruletypes.LLMPricingRule, error) {
|
||||
rule := new(llmpricingruletypes.LLMPricingRule)
|
||||
|
||||
err := store.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(rule).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("id = ?", id).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(err, llmpricingruletypes.ErrCodePricingRuleNotFound, "pricing rule %s not found", id)
|
||||
}
|
||||
|
||||
return rule, nil
|
||||
}
|
||||
|
||||
func (store *store) GetBySourceID(ctx context.Context, orgID, sourceID valuer.UUID) (*llmpricingruletypes.LLMPricingRule, error) {
|
||||
rule := new(llmpricingruletypes.LLMPricingRule)
|
||||
|
||||
err := store.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(rule).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("source_id = ?", sourceID).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(err, llmpricingruletypes.ErrCodePricingRuleNotFound, "pricing rule with source_id %s not found", sourceID)
|
||||
}
|
||||
|
||||
return rule, nil
|
||||
}
|
||||
|
||||
func (store *store) Create(ctx context.Context, rule *llmpricingruletypes.LLMPricingRule) error {
|
||||
_, err := store.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewInsert().
|
||||
Model(rule).
|
||||
Exec(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
func (store *store) Update(ctx context.Context, rule *llmpricingruletypes.LLMPricingRule) error {
|
||||
res, err := store.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewUpdate().
|
||||
Model(rule).
|
||||
Where("org_id = ?", rule.OrgID).
|
||||
Where("id = ?", rule.ID).
|
||||
ExcludeColumn("id", "org_id", "created_at", "created_by").
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rowsAffected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return errors.Newf(errors.TypeNotFound, llmpricingruletypes.ErrCodePricingRuleNotFound, "pricing rule %s not found", rule.ID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) Delete(ctx context.Context, orgID, id valuer.UUID) error {
|
||||
res, err := store.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewDelete().
|
||||
Model((*llmpricingruletypes.LLMPricingRule)(nil)).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("id = ?", id).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rowsAffected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return errors.Newf(errors.TypeNotFound, llmpricingruletypes.ErrCodePricingRuleNotFound, "pricing rule %s not found", id)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *store) RunInTx(ctx context.Context, cb func(ctx context.Context) error) error {
|
||||
return s.sqlstore.RunInTxCtx(ctx, nil, cb)
|
||||
}
|
||||
31
pkg/modules/llmpricingrule/impllmpricingrule/testdata/collector_baseline.yaml
vendored
Normal file
31
pkg/modules/llmpricingrule/impllmpricingrule/testdata/collector_baseline.yaml
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
receivers:
|
||||
otlp:
|
||||
protocols:
|
||||
grpc:
|
||||
processors:
|
||||
signozllmpricing:
|
||||
attrs:
|
||||
model: gen_ai.request.model
|
||||
in: gen_ai.usage.input_tokens
|
||||
out: gen_ai.usage.output_tokens
|
||||
cache_read: gen_ai.usage.cache_read.input_tokens
|
||||
cache_write: gen_ai.usage.cache_creation.input_tokens
|
||||
default_pricing:
|
||||
unit: per_million_tokens
|
||||
rules: []
|
||||
output_attrs:
|
||||
in: _signoz.gen_ai.cost_input
|
||||
out: _signoz.gen_ai.cost_output
|
||||
cache_read: _signoz.gen_ai.cost_cache_read
|
||||
cache_write: _signoz.gen_ai.cost_cache_write
|
||||
total: _signoz.gen_ai.total_cost
|
||||
batch: {}
|
||||
exporters:
|
||||
otlp:
|
||||
endpoint: localhost:4317
|
||||
service:
|
||||
pipelines:
|
||||
traces:
|
||||
receivers: [otlp]
|
||||
processors: [batch, signozllmpricing]
|
||||
exporters: [otlp]
|
||||
35
pkg/modules/llmpricingrule/impllmpricingrule/testdata/collector_no_rules.yaml
vendored
Normal file
35
pkg/modules/llmpricingrule/impllmpricingrule/testdata/collector_no_rules.yaml
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
exporters:
|
||||
otlp:
|
||||
endpoint: localhost:4317
|
||||
processors:
|
||||
batch: {}
|
||||
signozllmpricing:
|
||||
attrs:
|
||||
model: gen_ai.request.model
|
||||
in: gen_ai.usage.input_tokens
|
||||
out: gen_ai.usage.output_tokens
|
||||
cache_read: gen_ai.usage.cache_read.input_tokens
|
||||
cache_write: gen_ai.usage.cache_creation.input_tokens
|
||||
default_pricing:
|
||||
unit: per_million_tokens
|
||||
rules: []
|
||||
output_attrs:
|
||||
in: _signoz.gen_ai.cost_input
|
||||
out: _signoz.gen_ai.cost_output
|
||||
cache_read: _signoz.gen_ai.cost_cache_read
|
||||
cache_write: _signoz.gen_ai.cost_cache_write
|
||||
total: _signoz.gen_ai.total_cost
|
||||
receivers:
|
||||
otlp:
|
||||
protocols:
|
||||
grpc: null
|
||||
service:
|
||||
pipelines:
|
||||
traces:
|
||||
exporters:
|
||||
- otlp
|
||||
processors:
|
||||
- batch
|
||||
- signozllmpricing
|
||||
receivers:
|
||||
- otlp
|
||||
44
pkg/modules/llmpricingrule/impllmpricingrule/testdata/collector_with_rule.yaml
vendored
Normal file
44
pkg/modules/llmpricingrule/impllmpricingrule/testdata/collector_with_rule.yaml
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
exporters:
|
||||
otlp:
|
||||
endpoint: localhost:4317
|
||||
processors:
|
||||
batch: {}
|
||||
signozllmpricing:
|
||||
attrs:
|
||||
model: gen_ai.request.model
|
||||
in: gen_ai.usage.input_tokens
|
||||
out: gen_ai.usage.output_tokens
|
||||
cache_read: gen_ai.usage.cache_read.input_tokens
|
||||
cache_write: gen_ai.usage.cache_creation.input_tokens
|
||||
default_pricing:
|
||||
unit: per_million_tokens
|
||||
rules:
|
||||
- name: gpt-4o
|
||||
pattern:
|
||||
- gpt-4o*
|
||||
cache:
|
||||
mode: subtract
|
||||
read: 2.5
|
||||
write: 0
|
||||
in: 5
|
||||
out: 15
|
||||
output_attrs:
|
||||
in: _signoz.gen_ai.cost_input
|
||||
out: _signoz.gen_ai.cost_output
|
||||
cache_read: _signoz.gen_ai.cost_cache_read
|
||||
cache_write: _signoz.gen_ai.cost_cache_write
|
||||
total: _signoz.gen_ai.total_cost
|
||||
receivers:
|
||||
otlp:
|
||||
protocols:
|
||||
grpc: null
|
||||
service:
|
||||
pipelines:
|
||||
traces:
|
||||
exporters:
|
||||
- otlp
|
||||
processors:
|
||||
- batch
|
||||
- signozllmpricing
|
||||
receivers:
|
||||
- otlp
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/cache/memorycache"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/modules/llmpricingrule/impllmpricingrule"
|
||||
"github.com/SigNoz/signoz/pkg/queryparser"
|
||||
|
||||
"github.com/gorilla/handlers"
|
||||
@@ -130,11 +131,14 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
|
||||
|
||||
opAmpModel.Init(signoz.SQLStore, signoz.Instrumentation.Logger(), signoz.Modules.OrgGetter)
|
||||
|
||||
llmCostFeature := impllmpricingrule.NewLLMCostFeature(signoz.Modules.LLMPricingRule)
|
||||
|
||||
agentConfMgr, err := agentConf.Initiate(
|
||||
&agentConf.ManagerOptions{
|
||||
Store: signoz.SQLStore,
|
||||
AgentFeatures: []agentConf.AgentFeature{
|
||||
logParsingPipelineController,
|
||||
llmCostFeature,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -124,6 +124,6 @@ func NewHandlers(
|
||||
AlertmanagerHandler: signozalertmanager.NewHandler(alertmanagerService),
|
||||
TraceDetail: impltracedetail.NewHandler(modules.TraceDetail),
|
||||
RulerHandler: signozruler.NewHandler(rulerService),
|
||||
LLMPricingRuleHandler: impllmpricingrule.NewHandler(nil, providerSettings),
|
||||
LLMPricingRuleHandler: impllmpricingrule.NewHandler(modules.LLMPricingRule, providerSettings),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/inframonitoring"
|
||||
"github.com/SigNoz/signoz/pkg/modules/inframonitoring/implinframonitoring"
|
||||
"github.com/SigNoz/signoz/pkg/modules/llmpricingrule"
|
||||
"github.com/SigNoz/signoz/pkg/modules/llmpricingrule/impllmpricingrule"
|
||||
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer"
|
||||
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer/implmetricsexplorer"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
@@ -80,6 +82,7 @@ type Modules struct {
|
||||
CloudIntegration cloudintegration.Module
|
||||
RuleStateHistory rulestatehistory.Module
|
||||
TraceDetail tracedetail.Module
|
||||
LLMPricingRule llmpricingrule.Module
|
||||
}
|
||||
|
||||
func NewModules(
|
||||
@@ -133,5 +136,6 @@ func NewModules(
|
||||
RuleStateHistory: implrulestatehistory.NewModule(implrulestatehistory.NewStore(telemetryStore, telemetryMetadataStore, providerSettings.Logger)),
|
||||
CloudIntegration: cloudIntegrationModule,
|
||||
TraceDetail: impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore), providerSettings, config.TraceDetail),
|
||||
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,6 +195,7 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewServiceAccountAuthzactory(sqlstore),
|
||||
sqlmigration.NewDropUserDeletedAtFactory(sqlstore, sqlschema),
|
||||
sqlmigration.NewMigrateAWSAllRegionsFactory(sqlstore),
|
||||
sqlmigration.NewAddLLMPricingRulesFactory(sqlstore, sqlschema),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
96
pkg/sqlmigration/079_add_llm_pricing_rules.go
Normal file
96
pkg/sqlmigration/079_add_llm_pricing_rules.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlschema"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
)
|
||||
|
||||
type addLLMPricingRules struct {
|
||||
sqlschema sqlschema.SQLSchema
|
||||
sqlstore sqlstore.SQLStore
|
||||
}
|
||||
|
||||
func NewAddLLMPricingRulesFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("add_llm_pricing_rule"), func(_ context.Context, _ factory.ProviderSettings, _ Config) (SQLMigration, error) {
|
||||
return &addLLMPricingRules{
|
||||
sqlschema: sqlschema,
|
||||
sqlstore: sqlstore,
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (migration *addLLMPricingRules) Register(migrations *migrate.Migrations) error {
|
||||
if err := migrations.Register(migration.Up, migration.Down); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (migration *addLLMPricingRules) Up(ctx context.Context, db *bun.DB) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = tx.Rollback()
|
||||
}()
|
||||
|
||||
sqls := [][]byte{}
|
||||
|
||||
tableSQLs := migration.sqlschema.Operator().CreateTable(&sqlschema.Table{
|
||||
Name: "llm_pricing_rule",
|
||||
Columns: []*sqlschema.Column{
|
||||
{Name: "id", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "created_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
|
||||
{Name: "updated_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
|
||||
{Name: "created_by", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "updated_by", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "org_id", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "source_id", DataType: sqlschema.DataTypeText, Nullable: true},
|
||||
{Name: "model", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "provider", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "model_pattern", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "unit", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "pricing", DataType: sqlschema.DataTypeText, Nullable: false, Default: "'{}'"},
|
||||
{Name: "is_override", DataType: sqlschema.DataTypeBoolean, Nullable: false, Default: "false"},
|
||||
{Name: "synced_at", DataType: sqlschema.DataTypeTimestamp, Nullable: true},
|
||||
{Name: "enabled", DataType: sqlschema.DataTypeBoolean, Nullable: false, Default: "true"},
|
||||
},
|
||||
PrimaryKeyConstraint: &sqlschema.PrimaryKeyConstraint{
|
||||
ColumnNames: []sqlschema.ColumnName{"id"},
|
||||
},
|
||||
ForeignKeyConstraints: []*sqlschema.ForeignKeyConstraint{
|
||||
{
|
||||
ReferencingColumnName: sqlschema.ColumnName("org_id"),
|
||||
ReferencedTableName: sqlschema.TableName("organizations"),
|
||||
ReferencedColumnName: sqlschema.ColumnName("id"),
|
||||
},
|
||||
},
|
||||
})
|
||||
sqls = append(sqls, tableSQLs...)
|
||||
|
||||
for _, sql := range sqls {
|
||||
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Partial unique index: one Zeus-synced rule per (org, source). User-created
|
||||
// rules carry source_id = NULL and are intentionally excluded from the
|
||||
// constraint (a single org may have many).
|
||||
if _, err := tx.ExecContext(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS llm_pricing_rule_org_source_unique ON llm_pricing_rule (org_id, source_id) WHERE source_id IS NOT NULL`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (migration *addLLMPricingRules) Down(context.Context, *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||
@@ -12,8 +12,10 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCodePricingRuleNotFound = errors.MustNewCode("pricing_rule_not_found")
|
||||
ErrCodePricingRuleInvalidInput = errors.MustNewCode("pricing_rule_invalid_input")
|
||||
ErrCodePricingRuleNotFound = errors.MustNewCode("pricing_rule_not_found")
|
||||
ErrCodePricingRuleInvalidInput = errors.MustNewCode("pricing_rule_invalid_input")
|
||||
ErrCodeInvalidCollectorConfig = errors.MustNewCode("invalid_collector_config")
|
||||
ErrCodeBuildPricingProcessorConf = errors.MustNewCode("build_pricing_processor_config")
|
||||
)
|
||||
|
||||
type LLMPricingRuleUnit struct {
|
||||
@@ -183,3 +185,48 @@ func NewGettableLLMPricingRulesFromLLMPricingRules(items []*LLMPricingRule, tota
|
||||
Limit: limit,
|
||||
}
|
||||
}
|
||||
|
||||
func NewLLMPricingRuleFromUpdatable(u UpdatableLLMPricingRule, orgID valuer.UUID, userEmail string, now time.Time) *LLMPricingRule {
|
||||
isOverride := true
|
||||
if u.IsOverride != nil {
|
||||
isOverride = *u.IsOverride
|
||||
} else if u.SourceID != nil {
|
||||
isOverride = false
|
||||
}
|
||||
|
||||
return &LLMPricingRule{
|
||||
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
|
||||
TimeAuditable: types.TimeAuditable{CreatedAt: now, UpdatedAt: now},
|
||||
UserAuditable: types.UserAuditable{CreatedBy: userEmail, UpdatedBy: userEmail},
|
||||
OrgID: orgID,
|
||||
SourceID: u.SourceID,
|
||||
Model: u.Model,
|
||||
Provider: u.Provider,
|
||||
ModelPattern: StringSlice(u.ModelPattern),
|
||||
Unit: u.Unit,
|
||||
Pricing: u.Pricing,
|
||||
IsOverride: isOverride,
|
||||
SyncedAt: &now,
|
||||
Enabled: u.Enabled,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *LLMPricingRule) Update(u UpdatableLLMPricingRule, userEmail string, now time.Time) {
|
||||
if u.IsOverride == nil && r.IsOverride {
|
||||
r.SyncedAt = &now
|
||||
return
|
||||
}
|
||||
|
||||
r.Model = u.Model
|
||||
r.Provider = u.Provider
|
||||
r.ModelPattern = StringSlice(u.ModelPattern)
|
||||
r.Unit = u.Unit
|
||||
r.Pricing = u.Pricing
|
||||
if u.IsOverride != nil {
|
||||
r.IsOverride = *u.IsOverride
|
||||
}
|
||||
r.Enabled = u.Enabled
|
||||
r.SyncedAt = &now
|
||||
r.UpdatedAt = now
|
||||
r.UpdatedBy = userEmail
|
||||
}
|
||||
|
||||
49
pkg/types/llmpricingruletypes/processor_config.go
Normal file
49
pkg/types/llmpricingruletypes/processor_config.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package llmpricingruletypes
|
||||
|
||||
// LLMPricingRuleProcessorConfig is the top-level config for the signozllmpricing
|
||||
// OTel processor that gets deployed to collectors via OpAMP.
|
||||
type LLMPricingRuleProcessorConfig struct {
|
||||
Attrs LLMPricingRuleProcessorAttrs `yaml:"attrs" json:"attrs"`
|
||||
DefaultPricing LLMPricingRuleProcessorDefaultPricing `yaml:"default_pricing" json:"default_pricing"`
|
||||
OutputAttrs LLMPricingRuleProcessorOutputAttrs `yaml:"output_attrs" json:"output_attrs"`
|
||||
}
|
||||
|
||||
// LLMPricingRuleProcessorAttrs maps span attribute names to the processor's input fields.
|
||||
type LLMPricingRuleProcessorAttrs struct {
|
||||
Model string `yaml:"model" json:"model"`
|
||||
In string `yaml:"in" json:"in"`
|
||||
Out string `yaml:"out" json:"out"`
|
||||
CacheRead string `yaml:"cache_read" json:"cache_read"`
|
||||
CacheWrite string `yaml:"cache_write" json:"cache_write"`
|
||||
}
|
||||
|
||||
// LLMPricingRuleProcessorDefaultPricing holds the pricing unit and the list of model-specific rules.
|
||||
type LLMPricingRuleProcessorDefaultPricing struct {
|
||||
Unit string `yaml:"unit" json:"unit"`
|
||||
Rules []LLMPricingRuleProcessor `yaml:"rules" json:"rules"`
|
||||
}
|
||||
|
||||
// LLMPricingRuleProcessor is a single pricing rule inside the processor config.
|
||||
type LLMPricingRuleProcessor struct {
|
||||
Name string `yaml:"name" json:"name"`
|
||||
Pattern []string `yaml:"pattern" json:"pattern"`
|
||||
Cache LLMPricingRuleProcessorCache `yaml:"cache" json:"cache"`
|
||||
In float64 `yaml:"in" json:"in"`
|
||||
Out float64 `yaml:"out" json:"out"`
|
||||
}
|
||||
|
||||
// LLMPricingRuleProcessorCache describes how cached tokens are accounted for.
|
||||
type LLMPricingRuleProcessorCache struct {
|
||||
Mode string `yaml:"mode" json:"mode"`
|
||||
Read float64 `yaml:"read" json:"read"`
|
||||
Write float64 `yaml:"write" json:"write"`
|
||||
}
|
||||
|
||||
// LLMPricingRuleProcessorOutputAttrs maps the processor's computed cost fields to span attribute names.
|
||||
type LLMPricingRuleProcessorOutputAttrs struct {
|
||||
In string `yaml:"in" json:"in"`
|
||||
Out string `yaml:"out" json:"out"`
|
||||
CacheRead string `yaml:"cache_read" json:"cache_read"`
|
||||
CacheWrite string `yaml:"cache_write" json:"cache_write"`
|
||||
Total string `yaml:"total" json:"total"`
|
||||
}
|
||||
@@ -7,10 +7,11 @@ import (
|
||||
)
|
||||
|
||||
type Store interface {
|
||||
List(ctx context.Context, orgID valuer.UUID, offset, limit int) ([]*StorableLLMPricingRule, int, error)
|
||||
Get(ctx context.Context, orgID, id valuer.UUID) (*StorableLLMPricingRule, error)
|
||||
GetBySourceID(ctx context.Context, orgID, sourceID valuer.UUID) (*StorableLLMPricingRule, error)
|
||||
Create(ctx context.Context, rule *StorableLLMPricingRule) error
|
||||
Update(ctx context.Context, rule *StorableLLMPricingRule) error
|
||||
List(ctx context.Context, orgID valuer.UUID, offset, limit int) ([]*LLMPricingRule, int, error)
|
||||
Get(ctx context.Context, orgID, id valuer.UUID) (*LLMPricingRule, error)
|
||||
GetBySourceID(ctx context.Context, orgID, sourceID valuer.UUID) (*LLMPricingRule, error)
|
||||
Create(ctx context.Context, rule *LLMPricingRule) error
|
||||
Update(ctx context.Context, rule *LLMPricingRule) error
|
||||
Delete(ctx context.Context, orgID, id valuer.UUID) error
|
||||
RunInTx(ctx context.Context, cb func(ctx context.Context) error) error
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user