Compare commits

...

7 Commits

Author SHA1 Message Date
Gaurav Tewari
5d661217e9 test: merge feat/gcp-cloud-integration + fix/gcp-list-accounts for QA 2026-07-22 10:19:31 +05:30
swapnil-signoz
f5f8da4226 refactor: updating cloudintegration integration tests 2026-07-20 22:45:58 +05:30
swapnil-signoz
e8fae56354 fix: list gcp accounts was missing config 2026-07-20 20:07:21 +05:30
Vinicius Lourenço
429fca62be fix(query-search): do not override token classes (#12139)
* fix(query-search): do not override token classes

* test(token-highlight): add regression test

* fix(pr): address comments
2026-07-20 13:07:07 +00:00
Aditya Singh
20518a98e9 fix(logs-explorer): prevent long json body from overflowing (#12173)
* feat: allow body content to wrap preventing content overflow

* feat: align key name and pin to the top
2026-07-20 13:03:28 +00:00
Gaurav Tewari
0b7c266b09 fix: css fixes for llm o11y model pricing and overview (#12145)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* fix: css fixes for llm o11y model pricing and overview

* fix: spacing issue

* chore: update modal drawer cost

* chore: remove comments

* chore: spacing between chips right

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-07-20 11:19:48 +00:00
Gaurav Tewari
8014c065e8 feat: rename llm o11y to ai o11y [2/2] (#12141)
* feat: add llm o11y to side nav

* chore: rename route

* chore: review comments

* chore: update constants

* chore: change name to ai

* chore: rename draft

* chore: move model pricing out of settings

* chore: update comment

* revert: undo LLM->AI file/dir moves, keep constant rename

Reverts the two file-relocation commits (c25060950 rename LLMObservability
dir -> AIObservability; 1a18bc848 move ModelPricing out of Settings) while
preserving the LLM->AI string/constant renames (route constants + paths,
side-nav entry/labels, page titles, permission keys). Component files stay
under container/LLMObservability with their original names.

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-07-20 11:18:08 +00:00
27 changed files with 337 additions and 114 deletions

View File

@@ -62,7 +62,7 @@
"TRACES_FUNNELS_DETAIL": "SigNoz | Funnel",
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
"PUBLIC_DASHBOARD": "SigNoz | Dashboard",
"LLM_OBSERVABILITY_OVERVIEW": "SigNoz | LLM Observability Overview",
"LLM_OBSERVABILITY_CONFIGURATION": "SigNoz | LLM Observability Configuration",
"LLM_OBSERVABILITY_ATTRIBUTE_MAPPING": "SigNoz | LLM Observability Attribute Mapping"
"AI_OBSERVABILITY_OVERVIEW": "SigNoz | AI Observability Overview",
"AI_OBSERVABILITY_CONFIGURATION": "SigNoz | AI Observability Configuration",
"AI_OBSERVABILITY_ATTRIBUTE_MAPPING": "SigNoz | AI Observability Attribute Mapping"
}

View File

@@ -87,7 +87,7 @@
"TRACES_FUNNELS_DETAIL": "SigNoz | Funnel",
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
"PUBLIC_DASHBOARD": "SigNoz | Dashboard",
"LLM_OBSERVABILITY_OVERVIEW": "SigNoz | LLM Observability Overview",
"LLM_OBSERVABILITY_CONFIGURATION": "SigNoz | LLM Observability Configuration",
"LLM_OBSERVABILITY_ATTRIBUTE_MAPPING": "SigNoz | LLM Observability Attribute Mapping"
"AI_OBSERVABILITY_OVERVIEW": "SigNoz | AI Observability Overview",
"AI_OBSERVABILITY_CONFIGURATION": "SigNoz | AI Observability Configuration",
"AI_OBSERVABILITY_ATTRIBUTE_MAPPING": "SigNoz | AI Observability Attribute Mapping"
}

View File

@@ -134,8 +134,8 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
}
if (
(pathname.startsWith(`${ROUTES.LLM_OBSERVABILITY_BASE}/`) ||
pathname === ROUTES.LLM_OBSERVABILITY_BASE) &&
(pathname.startsWith(`${ROUTES.AI_OBSERVABILITY_BASE}/`) ||
pathname === ROUTES.AI_OBSERVABILITY_BASE) &&
!isAIObservabilityEnabled
) {
return <Redirect to={ROUTES.HOME} />;

View File

@@ -514,24 +514,24 @@ const routes: AppRoutes[] = [
isPrivate: true,
},
{
path: ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING,
path: ROUTES.AI_OBSERVABILITY_ATTRIBUTE_MAPPING,
exact: true,
component: LLMObservabilityPage,
key: 'LLM_OBSERVABILITY_ATTRIBUTE_MAPPING',
key: 'AI_OBSERVABILITY_ATTRIBUTE_MAPPING',
isPrivate: true,
},
{
path: ROUTES.LLM_OBSERVABILITY_OVERVIEW,
path: ROUTES.AI_OBSERVABILITY_OVERVIEW,
exact: true,
component: LLMObservabilityPage,
key: 'LLM_OBSERVABILITY_OVERVIEW',
key: 'AI_OBSERVABILITY_OVERVIEW',
isPrivate: true,
},
{
path: ROUTES.LLM_OBSERVABILITY_CONFIGURATION,
path: ROUTES.AI_OBSERVABILITY_CONFIGURATION,
exact: true,
component: LLMObservabilityPage,
key: 'LLM_OBSERVABILITY_CONFIGURATION',
key: 'AI_OBSERVABILITY_CONFIGURATION',
isPrivate: true,
},
];

View File

@@ -247,11 +247,6 @@
var(--l2-background)
) !important;
&,
.ͼ1a {
color: var(--query-builder-v2-color, var(--l2-foreground));
}
::-moz-selection {
background: var(
--query-builder-v2-selection-background-color,

View File

@@ -293,11 +293,6 @@ $max-recents-shown: 5;
var(--l2-background)
) !important;
&,
.ͼ1a {
color: var(--query-builder-v2-color, var(--l2-foreground));
}
::-moz-selection {
background: var(
--query-builder-v2-selection-background-color,

View File

@@ -89,10 +89,10 @@ const ROUTES = {
AI_ASSISTANT_BASE: '/ai-assistant',
AI_ASSISTANT_ICON_PREVIEW: '/ai-assistant-icon-preview',
MCP_SERVER: '/settings/mcp-server',
LLM_OBSERVABILITY_ATTRIBUTE_MAPPING: '/llm-observability/attribute-mapping',
LLM_OBSERVABILITY_BASE: '/llm-observability',
LLM_OBSERVABILITY_OVERVIEW: '/llm-observability/overview',
LLM_OBSERVABILITY_CONFIGURATION: '/llm-observability/configuration',
AI_OBSERVABILITY_ATTRIBUTE_MAPPING: '/ai-observability/attribute-mapping',
AI_OBSERVABILITY_BASE: '/ai-observability',
AI_OBSERVABILITY_OVERVIEW: '/ai-observability/overview',
AI_OBSERVABILITY_CONFIGURATION: '/ai-observability/configuration',
} as const;
export default ROUTES;

View File

@@ -9,6 +9,4 @@
:global([class*='dashboardPageHeader']) {
display: none;
}
// remove margin added by the hidden header to avoid extra whitespace at the top of the page
margin-top: calc(var(--spacing-8) * -1);
}

View File

@@ -18,7 +18,7 @@
--dialog-footer-padding: var(--spacing-8) var(--spacing-12);
display: flex;
overflow-y: auto;
overflow: hidden;
// The drawer body — children render inside [data-slot='drawer-description']
// (this is the @signozhq drawer, not antd, so .ant-drawer-body was a no-op).
@@ -27,6 +27,8 @@
flex-direction: column;
gap: var(--spacing-12);
padding: var(--spacing-10) var(--spacing-12);
min-height: 0;
overflow-y: auto;
}
[data-slot='select-content'] {

View File

@@ -51,7 +51,6 @@
gap: var(--spacing-5);
padding: var(--spacing-6);
border-radius: 6px;
background: var(--l2-background);
border: 1px solid var(--l2-border);
}

View File

@@ -9,7 +9,6 @@
.patternBox {
display: flex;
flex-direction: column;
gap: var(--spacing-6);
padding: var(--spacing-6);
border-radius: 6px;
border: 1px solid var(--l2-border);
@@ -18,14 +17,14 @@
.patternChips {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-3);
min-height: 28px;
gap: var(--spacing-0) var(--spacing-3);
}
.patternChip {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
margin-bottom: var(--spacing-4);
}
.patternChipRemove {

View File

@@ -30,7 +30,6 @@
padding: var(--spacing-5) var(--spacing-6);
border-radius: var(--radius-2);
border: 1px solid transparent;
background: var(--l3-background);
margin: 0;
width: 100%;
// Include padding + border in the 100% width so the card fits inside
@@ -67,16 +66,11 @@
// Radix RadioGroupItem renders <button data-state="checked|unchecked">.
// Use :has() to highlight the wrapper card when its inner button is checked.
&.sourceRadioAuto:has(button[data-state='checked']) {
&:has(button[data-state='checked']) {
background: color-mix(in srgb, var(--accent-primary) 10%, transparent);
border-color: color-mix(in srgb, var(--accent-primary) 30%, transparent);
}
&.sourceRadioOverride:has(button[data-state='checked']) {
background: color-mix(in srgb, var(--accent-amber) 10%, transparent);
border-color: color-mix(in srgb, var(--accent-amber) 30%, transparent);
}
&:hover {
background: var(--l3-background-hover);
}

View File

@@ -60,7 +60,7 @@ function SourceSelector({
>
<RadioGroupItem
value="auto"
containerClassName={cx(styles.sourceRadio, styles.sourceRadioAuto)}
containerClassName={styles.sourceRadio}
testId="drawer-source-auto"
disabled={disableAuto}
>
@@ -73,7 +73,7 @@ function SourceSelector({
</RadioGroupItem>
<RadioGroupItem
value="override"
containerClassName={cx(styles.sourceRadio, styles.sourceRadioOverride)}
containerClassName={styles.sourceRadio}
testId="drawer-source-override"
>
<div className={styles.sourceRadioTitle}>User override</div>

View File

@@ -13,6 +13,7 @@
border: 1px solid color-mix(in srgb, var(--bg-amber-400) 30%, transparent);
background: color-mix(in srgb, var(--bg-amber-400) 8%, transparent);
color: var(--bg-amber-300, var(--bg-amber-400));
margin-top: var(--spacing-4);
}
.bannerIcon {

View File

@@ -37,7 +37,7 @@ describe('LLMObservability (integration)', () => {
it('renders the overview panel and the tab bar on the overview route', () => {
render(<LLMObservability />, undefined, {
initialRoute: ROUTES.LLM_OBSERVABILITY_OVERVIEW,
initialRoute: ROUTES.AI_OBSERVABILITY_OVERVIEW,
});
expect(screen.getByTestId('llm-observability-tabs')).toBeInTheDocument();
@@ -55,32 +55,32 @@ describe('LLMObservability (integration)', () => {
it('navigates to the configuration route when the Model pricing tab is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<LLMObservability />, undefined, {
initialRoute: ROUTES.LLM_OBSERVABILITY_OVERVIEW,
initialRoute: ROUTES.AI_OBSERVABILITY_OVERVIEW,
});
await user.click(screen.getByRole('tab', { name: 'Model pricing' }));
expect(safeNavigateMock).toHaveBeenCalledWith(
ROUTES.LLM_OBSERVABILITY_CONFIGURATION,
ROUTES.AI_OBSERVABILITY_CONFIGURATION,
);
});
it('navigates to the attribute mapping route when that tab is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<LLMObservability />, undefined, {
initialRoute: ROUTES.LLM_OBSERVABILITY_OVERVIEW,
initialRoute: ROUTES.AI_OBSERVABILITY_OVERVIEW,
});
await user.click(screen.getByRole('tab', { name: 'Attribute Mapping' }));
expect(safeNavigateMock).toHaveBeenCalledWith(
ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING,
ROUTES.AI_OBSERVABILITY_ATTRIBUTE_MAPPING,
);
});
it('renders the attribute mapping page on the attribute mapping route', () => {
render(<LLMObservability />, undefined, {
initialRoute: ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING,
initialRoute: ROUTES.AI_OBSERVABILITY_ATTRIBUTE_MAPPING,
});
expect(
@@ -91,7 +91,7 @@ describe('LLMObservability (integration)', () => {
it('renders the model-pricing page on the configuration route', async () => {
setupList();
render(<LLMObservability />, undefined, {
initialRoute: ROUTES.LLM_OBSERVABILITY_CONFIGURATION,
initialRoute: ROUTES.AI_OBSERVABILITY_CONFIGURATION,
});
await waitFor(() =>

View File

@@ -8,9 +8,9 @@ import LLMObservabilityAttributeMapping from '../AttributeMapping/LLMObservabili
import Overview from '../Overview/Overview';
import LLMObservabilityModelPricing from '../Settings/ModelPricing/LLMObservabilityModelPricing';
const OVERVIEW_KEY = ROUTES.LLM_OBSERVABILITY_OVERVIEW;
const CONFIGURATION_KEY = ROUTES.LLM_OBSERVABILITY_CONFIGURATION;
const ATTRIBUTE_MAPPING_KEY = ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING;
const OVERVIEW_KEY = ROUTES.AI_OBSERVABILITY_OVERVIEW;
const CONFIGURATION_KEY = ROUTES.AI_OBSERVABILITY_CONFIGURATION;
const ATTRIBUTE_MAPPING_KEY = ROUTES.AI_OBSERVABILITY_ATTRIBUTE_MAPPING;
interface UseLLMObservabilityTabsResult {
items: TabItemProps[];

View File

@@ -20,6 +20,7 @@
.ant-table-cell {
border: 1px solid var(--l1-border);
background: var(--l2-background);
vertical-align: top;
}
.attribute-name {
@@ -33,12 +34,12 @@
.attribute-pin {
cursor: pointer;
padding: 0;
vertical-align: middle;
padding: 14px 8px 8px;
vertical-align: top;
text-align: center;
.log-attribute-pin {
padding: 8px;
padding: 0;
display: flex;
justify-content: center;

View File

@@ -15,11 +15,13 @@
.ant-tree-node-content-wrapper {
user-select: text !important;
cursor: text !important;
min-width: 0;
}
.ant-tree-title {
user-select: text !important;
cursor: text !important;
overflow-wrap: anywhere;
}
}

View File

@@ -294,7 +294,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
if (item.key === ROUTES.INTEGRATIONS) {
return shouldShowIntegrationsValue;
}
if (item.key === ROUTES.LLM_OBSERVABILITY_OVERVIEW) {
if (item.key === ROUTES.AI_OBSERVABILITY_OVERVIEW) {
return isAIObservabilityEnabled;
}
return item.isEnabled;

View File

@@ -290,14 +290,14 @@ export const defaultMoreMenuItems: SidebarItem[] = [
itemKey: 'external-apis',
},
{
key: ROUTES.LLM_OBSERVABILITY_OVERVIEW,
label: 'LLM Observability',
key: ROUTES.AI_OBSERVABILITY_OVERVIEW,
label: 'AI Observability',
icon: <Brain size={16} />,
isNew: true,
// Gated behind the `enable_ai_observability` feature flag in
// SideNav's `computedSecondaryMenuItems`; disabled by default.
isEnabled: false,
itemKey: 'llm-observability',
itemKey: 'ai-observability',
},
{
key: ROUTES.METER,
@@ -576,7 +576,7 @@ export const NEW_ROUTES_MENU_ITEM_KEY_MAP: Record<string, string> = {
ROUTES.INFRASTRUCTURE_MONITORING_HOSTS,
[ROUTES.API_MONITORING_BASE]: ROUTES.API_MONITORING,
[ROUTES.MESSAGING_QUEUES_BASE]: ROUTES.MESSAGING_QUEUES_OVERVIEW,
[ROUTES.LLM_OBSERVABILITY_BASE]: ROUTES.LLM_OBSERVABILITY_OVERVIEW,
[ROUTES.AI_OBSERVABILITY_BASE]: ROUTES.AI_OBSERVABILITY_OVERVIEW,
// `getActiveMenuKeyFromPath` strips the URL down to its first segment;
// `/ai-assistant/<id>` reduces to `/ai-assistant`, which we point back
// to the AI Assistant menu item's concrete key.

View File

@@ -203,9 +203,9 @@ export const routesToSkip = [
ROUTES.METER_EXPLORER_VIEWS,
ROUTES.METRICS_EXPLORER_VOLUME_CONTROL,
ROUTES.SOMETHING_WENT_WRONG,
ROUTES.LLM_OBSERVABILITY_OVERVIEW,
ROUTES.LLM_OBSERVABILITY_CONFIGURATION,
ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING,
ROUTES.AI_OBSERVABILITY_OVERVIEW,
ROUTES.AI_OBSERVABILITY_CONFIGURATION,
ROUTES.AI_OBSERVABILITY_ATTRIBUTE_MAPPING,
];
export const routesToDisable = [ROUTES.LOGS_EXPLORER, ROUTES.LIVE_LOGS];

View File

@@ -136,8 +136,8 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
AI_ASSISTANT_ICON_PREVIEW: ['ADMIN', 'EDITOR', 'VIEWER'],
MCP_SERVER: ['ADMIN', 'EDITOR', 'VIEWER'],
AI_ASSISTANT_BASE: ['ADMIN', 'EDITOR', 'VIEWER'],
LLM_OBSERVABILITY_ATTRIBUTE_MAPPING: ['ADMIN', 'EDITOR', 'VIEWER'],
LLM_OBSERVABILITY_BASE: ['ADMIN', 'EDITOR', 'VIEWER'],
LLM_OBSERVABILITY_OVERVIEW: ['ADMIN', 'EDITOR', 'VIEWER'],
LLM_OBSERVABILITY_CONFIGURATION: ['ADMIN', 'EDITOR', 'VIEWER'],
AI_OBSERVABILITY_ATTRIBUTE_MAPPING: ['ADMIN', 'EDITOR', 'VIEWER'],
AI_OBSERVABILITY_BASE: ['ADMIN', 'EDITOR', 'VIEWER'],
AI_OBSERVABILITY_OVERVIEW: ['ADMIN', 'EDITOR', 'VIEWER'],
AI_OBSERVABILITY_CONFIGURATION: ['ADMIN', 'EDITOR', 'VIEWER'],
};

View File

@@ -137,6 +137,13 @@ func NewAccountFromStorable(storableAccount *StorableCloudIntegration) (*Account
return nil, err
}
account.Config.Azure = azureConfig
case CloudProviderTypeGCP:
gcpConfig := new(GCPAccountConfig)
err := json.Unmarshal([]byte(storableAccount.Config), gcpConfig)
if err != nil {
return nil, err
}
account.Config.GCP = gcpConfig
}
if storableAccount.LastAgentReport != nil {

View File

@@ -0,0 +1,36 @@
import { Page } from '@playwright/test';
const EDITOR_CONTENT = '.query-where-clause-editor .cm-content';
const SPANS = '.query-where-clause-editor .cm-line span';
export async function typeExpression(page: Page, expr: string): Promise<void> {
const editor = page.locator(EDITOR_CONTENT).first();
await editor.waitFor({ state: 'visible', timeout: 15_000 });
await editor.click();
await page.keyboard.press('ControlOrMeta+a');
await page.keyboard.press('Delete');
await page.keyboard.type(expr);
await page.waitForTimeout(300);
}
export async function tokenColor(
page: Page,
text: string,
): Promise<string | null> {
return page.evaluate(
({ selector, target }) => {
const el = Array.from(
document.querySelectorAll<HTMLSpanElement>(selector),
).find((s) => s.textContent === target);
return el ? window.getComputedStyle(el).color : null;
},
{ selector: SPANS, target: text },
);
}
export async function setupTheme(
page: Page,
theme: 'dark' | 'light',
): Promise<void> {
await page.addInitScript((t) => localStorage.setItem('THEME', t), theme);
}

View File

@@ -0,0 +1,66 @@
import { expect, test } from '../../fixtures/auth';
import { setupTheme, tokenColor, typeExpression } from '@helpers/query-builder';
const LOGS_EXPLORER = '/logs/logs-explorer';
type ThemeConfig = {
name: string;
theme: 'dark' | 'light';
colors: {
identifier: string;
operator: string;
property: string;
string: string;
};
};
const THEMES: ThemeConfig[] = [
{
name: 'dark (copilot)',
theme: 'dark',
colors: {
identifier: 'rgb(147, 157, 165)',
operator: 'rgb(186, 142, 247)',
property: 'rgb(255, 234, 107)',
string: 'rgb(91, 236, 149)',
},
},
{
name: 'light (githubLight)',
theme: 'light',
colors: {
identifier: 'rgb(0, 92, 197)',
operator: 'rgb(0, 92, 197)',
property: 'rgb(111, 66, 193)',
string: 'rgb(3, 47, 98)',
},
},
];
for (const { name, theme, colors } of THEMES) {
test.describe(`QueryBuilder token highlighting — ${name}`, () => {
test(`k8s.namespace.name — each segment gets its own color`, async ({
authedPage: page,
}) => {
await setupTheme(page, theme);
await page.goto(LOGS_EXPLORER);
await typeExpression(page, 'k8s.namespace.name');
expect(await tokenColor(page, 'k8s')).toBe(colors.identifier);
expect(await tokenColor(page, '.')).toBe(colors.operator);
expect(await tokenColor(page, 'namespace')).toBe(colors.property);
expect(await tokenColor(page, 'name')).toBe(colors.property);
});
test(`['value', 'value2'] — string tokens have native theme color`, async ({
authedPage: page,
}) => {
await setupTheme(page, theme);
await page.goto(LOGS_EXPLORER);
await typeExpression(page, "['value', 'value2']");
expect(await tokenColor(page, "'value'")).toBe(colors.string);
expect(await tokenColor(page, "'value2'")).toBe(colors.string);
});
});
}

View File

@@ -1,6 +1,7 @@
"""Fixtures for cloud integration tests."""
from collections.abc import Callable
from dataclasses import dataclass, field
from http import HTTPStatus
import pytest
@@ -20,6 +21,84 @@ from fixtures.logger import setup_logger
logger = setup_logger(__name__)
# ---------------------------------------------------------------------------
# Provider account specs
#
# Each cloud provider stores account config under a different shape (AWS uses
# `regions`, GCP uses `deploymentProjectId`/`projectIds`, Azure uses
# `resourceGroups`). A ProviderAccountSpec is the single source of truth for one
# provider's config shape: how to build the POST/PUT `config` block, and how to
# read back the value we assert on. Both `create_cloud_integration_account` and
# the parametrized account tests consume these, so adding a new provider is one
# new entry in PROVIDER_ACCOUNT_SPECS — no test-body changes required.
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class ProviderAccountSpec:
# provider slug used in the URL path and config key (e.g. "aws", "gcp").
provider: str
# params for the account created by default.
initial_params: dict
# params for the config an update (PUT) test sends.
updated_params: dict
# params -> the provider-keyed `config` block for a POST/PUT body.
build_config: Callable[[dict], dict]
# params -> the full config block the API is expected to return under
# config[provider] on GET/list. This may differ from what build_config sends:
# e.g. AWS accepts deploymentRegion on POST but the API does not echo it back.
expected_config: Callable[[dict], dict]
# id shown in parametrized test names; defaults to the provider slug.
id: str = field(default="")
def __post_init__(self) -> None:
if not self.id:
object.__setattr__(self, "id", self.provider)
AWS_ACCOUNT_SPEC = ProviderAccountSpec(
provider="aws",
initial_params={"deployment_region": "us-east-1", "regions": ["us-east-1"]},
updated_params={"deployment_region": "us-east-1", "regions": ["us-east-1", "us-west-2", "eu-west-1"]},
# POST requires deploymentRegion (validated server-side); the GET response
# only echoes back `regions`, so expected_config omits deploymentRegion.
build_config=lambda p: {"aws": {"deploymentRegion": p["deployment_region"], "regions": p["regions"]}},
expected_config=lambda p: {"regions": p["regions"]},
)
GCP_ACCOUNT_SPEC = ProviderAccountSpec(
provider="gcp",
initial_params={
"deployment_project_id": "signoz-test-project",
"deployment_region": "us-central1",
"project_ids": ["signoz-test-project"],
},
updated_params={
"deployment_project_id": "signoz-test-project",
"deployment_region": "us-central1",
"project_ids": ["signoz-test-project", "signoz-test-project-2"],
},
# GCP echoes back all three fields on GET, so expected_config asserts every
# one round-trips (this is exactly the config the "list gcp accounts was
# missing config" fix restored).
build_config=lambda p: {
"gcp": {
"deploymentProjectId": p["deployment_project_id"],
"deploymentRegion": p["deployment_region"],
"projectIds": p["project_ids"],
}
},
expected_config=lambda p: {
"deploymentProjectId": p["deployment_project_id"],
"deploymentRegion": p["deployment_region"],
"projectIds": p["project_ids"],
},
)
# Providers covered by the parametrized account tests. Add a provider here (e.g.
# an AZURE_ACCOUNT_SPEC with resourceGroups) to extend coverage without touching
# any test body.
PROVIDER_ACCOUNT_SPECS = [AWS_ACCOUNT_SPEC, GCP_ACCOUNT_SPEC]
@pytest.fixture(scope="function")
def deprecated_create_cloud_integration_account(
request: pytest.FixtureRequest,
@@ -97,19 +176,26 @@ def create_cloud_integration_account(
cloud_provider: str = "aws",
deployment_region: str = "us-east-1",
regions: list[str] | None = None,
config: dict | None = None,
) -> dict:
if regions is None:
regions = ["us-east-1"]
endpoint = f"/api/v1/cloud_integrations/{cloud_provider}/accounts"
request_payload = {
"config": {
# `config`, when given, is the fully-formed provider-keyed config block
# (e.g. built via a ProviderAccountSpec.build_config) and is used as-is.
# Otherwise fall back to the AWS shape built from deployment_region/regions,
# preserving existing AWS callers.
if config is None:
if regions is None:
regions = ["us-east-1"]
config = {
cloud_provider: {
"deploymentRegion": deployment_region,
"regions": regions,
}
},
}
endpoint = f"/api/v1/cloud_integrations/{cloud_provider}/accounts"
request_payload = {
"config": config,
"credentials": {
"sigNozApiURL": "https://test-deployment.test.signoz.cloud",
"sigNozApiKey": "test-api-key-789",

View File

@@ -2,16 +2,28 @@ import uuid
from collections.abc import Callable
from http import HTTPStatus
import pytest
import requests
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, add_license
from fixtures.cloudintegrations import simulate_agent_checkin
from fixtures.cloudintegrations import (
PROVIDER_ACCOUNT_SPECS,
ProviderAccountSpec,
simulate_agent_checkin,
)
from fixtures.logger import setup_logger
logger = setup_logger(__name__)
CLOUD_PROVIDER = "aws"
# Every account test runs once per provider in PROVIDER_ACCOUNT_SPECS. Each spec
# owns its provider's config shape (build_config / expected_config), so the test
# bodies stay provider-agnostic and adding a provider needs no changes here.
provider_spec = pytest.mark.parametrize(
"spec",
PROVIDER_ACCOUNT_SPECS,
ids=[s.id for s in PROVIDER_ACCOUNT_SPECS],
)
def test_apply_license(
@@ -24,16 +36,18 @@ def test_apply_license(
add_license(signoz, make_http_mocks, get_token)
@provider_spec
def test_list_accounts_empty(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
spec: ProviderAccountSpec,
) -> None:
"""List accounts returns an empty list when no accounts have checked in."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -46,24 +60,30 @@ def test_list_accounts_empty(
assert len(data["accounts"]) == 0, "accounts list should be empty when no accounts have checked in"
@provider_spec
def test_list_accounts_after_checkin(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
spec: ProviderAccountSpec,
) -> None:
"""List accounts returns an account after it has checked in."""
"""List accounts returns an account, with its provider config, after check-in."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER, regions=["us-east-1"])
account = create_cloud_integration_account(
admin_token,
spec.provider,
config=spec.build_config(spec.initial_params),
)
account_id = account["id"]
provider_account_id = str(uuid.uuid4())
checkin = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account_id, provider_account_id)
checkin = simulate_agent_checkin(signoz, admin_token, spec.provider, account_id, provider_account_id)
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -74,25 +94,31 @@ def test_list_accounts_after_checkin(
found = next((a for a in data["accounts"] if a["id"] == account_id), None)
assert found is not None, f"Account {account_id} should appear in list after check-in"
assert found["providerAccountId"] == provider_account_id, "providerAccountId should match"
assert found["config"]["aws"]["regions"] == ["us-east-1"], "regions should match account config"
assert found["config"][spec.provider] == spec.expected_config(spec.initial_params), "config should match account config"
assert found["agentReport"] is not None, "agentReport should be present after check-in"
assert found["removedAt"] is None, "removedAt should be null for a live account"
@provider_spec
def test_get_account(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
spec: ProviderAccountSpec,
) -> None:
"""Get a specific account by ID returns the account with correct fields."""
"""Get a specific account by ID returns the account with its provider config."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER, regions=["us-east-1", "eu-west-1"])
account = create_cloud_integration_account(
admin_token,
spec.provider,
config=spec.build_config(spec.initial_params),
)
account_id = account["id"]
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -101,23 +127,22 @@ def test_get_account(
data = response.json()["data"]
assert data["id"] == account_id, "id should match"
assert data["config"]["aws"]["regions"] == [
"us-east-1",
"eu-west-1",
], "regions should match"
assert data["config"][spec.provider] == spec.expected_config(spec.initial_params), "config should match"
assert data["removedAt"] is None, "removedAt should be null"
@provider_spec
def test_get_account_not_found(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
spec: ProviderAccountSpec,
) -> None:
"""Get a non-existent account returns 404."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{uuid.uuid4()}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{uuid.uuid4()}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -125,46 +150,52 @@ def test_get_account_not_found(
assert response.status_code == HTTPStatus.NOT_FOUND, f"Expected 404, got {response.status_code}"
@provider_spec
def test_update_account(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
spec: ProviderAccountSpec,
) -> None:
"""Update account config and verify the change is persisted via GET."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER, regions=["us-east-1"])
account = create_cloud_integration_account(
admin_token,
spec.provider,
config=spec.build_config(spec.initial_params),
)
account_id = account["id"]
checkin = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account_id, str(uuid.uuid4()))
checkin = simulate_agent_checkin(signoz, admin_token, spec.provider, account_id, str(uuid.uuid4()))
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
updated_regions = ["us-east-1", "us-west-2", "eu-west-1"]
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
json={"config": {"aws": {"regions": updated_regions}}},
json={"config": spec.build_config(spec.updated_params)},
timeout=10,
)
assert response.status_code == HTTPStatus.NO_CONTENT, f"Expected 204, got {response.status_code}"
get_response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
assert get_response.status_code == HTTPStatus.OK
assert get_response.json()["data"]["config"]["aws"]["regions"] == updated_regions, "Regions should reflect the update"
assert get_response.json()["data"]["config"][spec.provider] == spec.expected_config(spec.updated_params), "Config should reflect the update"
@provider_spec
def test_update_account_after_checkin_preserves_connected_status(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
spec: ProviderAccountSpec,
) -> None:
"""Updating config after agent check-in must not remove the account from the connected list.
@@ -175,17 +206,21 @@ def test_update_account_after_checkin_preserves_connected_status(
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# 1. Create account
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER, regions=["us-east-1"])
account = create_cloud_integration_account(
admin_token,
spec.provider,
config=spec.build_config(spec.initial_params),
)
account_id = account["id"]
provider_account_id = str(uuid.uuid4())
# 2. Agent checks in — sets account_id and last_agent_report
checkin = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account_id, provider_account_id)
checkin = simulate_agent_checkin(signoz, admin_token, spec.provider, account_id, provider_account_id)
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
# 3. Verify the account appears in the connected list
list_response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -195,18 +230,17 @@ def test_update_account_after_checkin_preserves_connected_status(
assert found_before is not None, "Account should be listed after check-in"
# 4. Update account config
updated_regions = ["us-east-1", "us-west-2"]
update_response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
json={"config": {"aws": {"regions": updated_regions}}},
json={"config": spec.build_config(spec.updated_params)},
timeout=10,
)
assert update_response.status_code == HTTPStatus.NO_CONTENT, f"Expected 204, got {update_response.status_code}"
# 5. Verify the account still appears in the connected list with correct fields
list_response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -216,27 +250,33 @@ def test_update_account_after_checkin_preserves_connected_status(
assert found_after is not None, "Account must still be listed after config update (account_id should not be reset)"
assert found_after["providerAccountId"] == provider_account_id, "providerAccountId should be preserved after update"
assert found_after["agentReport"] is not None, "agentReport should be preserved after update"
assert found_after["config"]["aws"]["regions"] == updated_regions, "Config should reflect the update"
assert found_after["config"][spec.provider] == spec.expected_config(spec.updated_params), "Config should reflect the update"
assert found_after["removedAt"] is None, "removedAt should still be null"
@provider_spec
def test_disconnect_account(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
spec: ProviderAccountSpec,
) -> None:
"""Disconnect an account removes it from the connected list."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
account = create_cloud_integration_account(
admin_token,
spec.provider,
config=spec.build_config(spec.initial_params),
)
account_id = account["id"]
checkin = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account_id, str(uuid.uuid4()))
checkin = simulate_agent_checkin(signoz, admin_token, spec.provider, account_id, str(uuid.uuid4()))
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -244,7 +284,7 @@ def test_disconnect_account(
assert response.status_code == HTTPStatus.NO_CONTENT, f"Expected 204, got {response.status_code}"
list_response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -252,16 +292,18 @@ def test_disconnect_account(
assert not any(a["id"] == account_id for a in accounts), "Disconnected account should not appear in the connected list"
@provider_spec
def test_disconnect_account_idempotent(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
spec: ProviderAccountSpec,
) -> None:
"""Disconnect on a non-existent account ID returns 204 (blind update, no existence check)."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{uuid.uuid4()}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{uuid.uuid4()}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)