Compare commits

..

2 Commits

Author SHA1 Message Date
Ashwin Bhatkal
20e6d44289 fix(api): send array query params as repeated keys (#13457)
Some checks are pending
build-staging / staging (push) Blocked by required conditions
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
cacheci / tests (push) Waiting to run
Release Drafter / update_release_draft (push) Waiting to run
#### Description

- The generated API client now sends array query params as repeated keys
(`states=firing&states=pending`) instead of axios' default `states[]=`.
The backend binds only repeated keys, so a `states` filter sent through
a generated hook was ignored and every rule came back.
- The alert list page and the home page alerts card drop their own
requests that worked around this.
- `states` on `GET /api/v3/rules` is the only array query param in the
generated and AI assistant clients today, so no other request changes.

#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/719
2026-11-12 16:50:41 +00:00
Ashwin Bhatkal
9fef8eea69 test(alerts): stop the saved views tests timing out on CI (#13481)
#### Description

- `ListAlertRules.views.test.tsx` now gives each test 15 s instead of
Jest's default 5 s. Other slow page tests use the same timeout.
- "deleting the active view falls back to All" is the slowest test in
the file, about 0.8 s locally. CI runs the file about 4x slower (48 to
52 s against 13 s locally), which pushes it past 5 s. It failed the
merge queue for [#13476](https://github.com/SigNoz/signoz/pull/13476)
and [#13301](https://github.com/SigNoz/signoz/pull/13301), and two
unrelated PRs.
- Each step in it finishes well inside the 1 s `waitFor` limit. Only the
total runs over, so this is load, not a stuck wait.

#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/726
2026-11-12 15:46:37 +00:00
18 changed files with 496 additions and 854 deletions

View File

@@ -88,7 +88,7 @@ jobs:
postgres:15 \
clickhouse/clickhouse-server:25.12.5 \
clickhouse/clickhouse-keeper:25.12.5 \
signoz/signoz-otel-collector:v0.144.16-rc.1 \
signoz/signoz-schema-migrator:v0.144.9 \
wiremock/wiremock:2.35.1-1 \
| xargs -P 0 -n 1 docker pull -q
- name: python

View File

@@ -40,7 +40,6 @@ jobs:
suite:
- alertmanager
- alertmanagerrotation
- auditquerier
- basepath
- callbackauthn
- cloudintegrations
@@ -84,8 +83,8 @@ jobs:
- wal
clickhouse-version:
- 25.12.5
signoz-otel-collector-version:
- v0.144.16-rc.1
schema-migrator-version:
- v0.144.6
postgres-version:
- 15
if: |
@@ -156,4 +155,4 @@ jobs:
--sqlite-mode ${{matrix.sqlite-mode}} \
--postgres-version ${{matrix.postgres-version}} \
--clickhouse-version ${{matrix.clickhouse-version}} \
--signoz-otel-collector-version ${{matrix.signoz-otel-collector-version}}
--schema-migrator-version ${{matrix.schema-migrator-version}}

View File

@@ -256,7 +256,7 @@ Tests can be configured using pytest options:
- `--sqlite-mode` — SQLite journal mode: `delete` or `wal` (default: `delete`). Only relevant when `--sqlstore-provider=sqlite`.
- `--postgres-version` — PostgreSQL version (default: `15`)
- `--clickhouse-version` — ClickHouse version, also used for ClickHouse Keeper (default: `25.12.5`)
- `--signoz-otel-collector-version`: SigNoz OTel collector version, used to run the ClickHouse migrations (default: `v0.144.16-rc.1`)
- `--schema-migrator-version` — SigNoz schema migrator version (default: `v0.144.9`)
- `--with-web` — Build the frontend into the SigNoz image (required for e2e)
Example:

View File

@@ -11,6 +11,8 @@ import axios, { AxiosError, AxiosRequestConfig } from 'axios';
// generated API Instance
const generatedAPIAxiosInstance = axios.create({
baseURL: ENVIRONMENT.baseURL,
// The backend binds repeated keys (`states=a&states=b`); axios' default sends `states[]=`.
paramsSerializer: { indexes: null },
});
let generatedAPIQueryKeyHeaderContext: Record<string, unknown> | undefined;

View File

@@ -1,12 +1,10 @@
import { useMemo } from 'react';
import { useListRulesV3 } from 'api/generated/services/rules';
import {
type ListRulesV3200,
type ListRulesV3Params,
RuletypesAlertStateDTO,
type RuletypesListableRuleDTO,
} from 'api/generated/services/sigNoz.schemas';
import { GeneratedAPIInstance } from 'api/generatedAPIInstance';
const HOME_ALERT_RULES_LIMIT = 5;
@@ -23,18 +21,7 @@ interface HomeAlertRules {
export function useHomeAlertRules(): HomeAlertRules {
const firing = useListRulesV3(FIRING_RULES_PARAMS, {
query: {
cacheTime: 0,
// The backend binds repeated `states=` keys; axios' default sends `states[]=`.
queryFn: ({ signal }) =>
GeneratedAPIInstance<ListRulesV3200>({
url: '/api/v3/rules',
method: 'GET',
params: FIRING_RULES_PARAMS,
paramsSerializer: { indexes: null },
signal,
}),
},
query: { cacheTime: 0 },
});
const firingRules = firing.data?.data.rules;
const latest = useListRulesV3(

View File

@@ -31,6 +31,8 @@ jest.mock('@floating-ui/dom', () => ({
Promise.resolve({ x: 0, y: 0, placement, strategy, middlewareData: {} }),
}));
jest.setTimeout(15_000);
const VIEW_1_ORDER_BY = JSON.stringify({ columnName: 'name', order: 'asc' });
const VIEW_1_QUERY = "severity = 'critical'";

View File

@@ -3,13 +3,11 @@ import logEvent from 'api/common/logEvent';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { useListRulesV3 } from 'api/generated/services/rules';
import type {
ListRulesV3200,
ListRulesV3Params,
RenderErrorResponseDTO,
RuletypesLabelPairDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { AxiosError } from 'axios';
import { GeneratedAPIInstance } from 'api/generatedAPIInstance';
import { isUndefined } from 'lodash-es';
import type { AlertRule } from './types';
@@ -35,18 +33,7 @@ export function useAlertRulesData(
const hasLoggedEvent = useRef(false);
const rulesResponse = useListRulesV3(params, {
query: {
keepPreviousData: true,
// The backend binds repeated `states=` keys; axios' default sends `states[]=`.
queryFn: ({ signal }) =>
GeneratedAPIInstance<ListRulesV3200>({
url: '/api/v3/rules',
method: 'GET',
params,
paramsSerializer: { indexes: null },
signal,
}),
},
query: { keepPreviousData: true },
});
const rules = useMemo(

View File

@@ -114,8 +114,8 @@ def pytest_addoption(parser: pytest.Parser):
help="clickhouse version",
)
parser.addoption(
"--signoz-otel-collector-version",
"--schema-migrator-version",
action="store",
default="v0.144.16-rc.1",
help="signoz-otel-collector version, used to run clickhouse migrations",
default="v0.144.9", # todo(nikhil): change to 0.144.10
help="schema migrator version",
)

View File

@@ -11,12 +11,6 @@ from ksuid import KsuidMs
from fixtures import types
from fixtures.fingerprint import LogsOrTracesFingerprint
ALICE_CREATED_DASHBOARD = "alice@acme.com (user-001) created dashboard (dash-001)"
ALICE_UPDATED_DASHBOARD = "alice@acme.com (user-001) updated dashboard (dash-001)"
VIEWER_FAILED_DELETE_DASHBOARD = "viewer@acme.com (user-002) failed to delete dashboard (dash-001): forbidden"
SA_CREATED_ALERT_RULE = "sa-001 created alert-rule (alert-001)"
ALICE_UPDATED_USER = "alice@acme.com (user-001) updated user (user-020)"
class AuditResource(ABC):
labels: str
@@ -186,83 +180,3 @@ def insert_audit_logs(
cluster = clickhouse.env["SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_CLUSTER"]
for table in ["logs", "logs_resource"]:
clickhouse.conn.query(f"TRUNCATE TABLE signoz_audit.{table} ON CLUSTER '{cluster}' SYNC")
def sample_audit_logs(now: datetime.datetime) -> list[AuditLog]:
return [
AuditLog(
timestamp=now - datetime.timedelta(seconds=5),
resources={"service.name": "signoz", "signoz.resource.kind": "dashboard", "signoz.resource.id": "dash-001"},
attributes={
"signoz.principal.id": "user-001",
"signoz.principal.email": "alice@acme.com",
"signoz.principal.type": "user",
"signoz.audit.verb": "create",
"signoz.audit.outcome": "success",
"signoz.audit.fields_changed": 3,
},
body=ALICE_CREATED_DASHBOARD,
event_name="signoz.audit.create",
scope_version="1.0.0",
),
AuditLog(
timestamp=now - datetime.timedelta(seconds=4),
resources={"service.name": "signoz", "signoz.resource.kind": "dashboard", "signoz.resource.id": "dash-001"},
attributes={
"signoz.principal.id": "user-001",
"signoz.principal.email": "alice@acme.com",
"signoz.principal.type": "user",
"signoz.audit.verb": "update",
"signoz.audit.outcome": "success",
"signoz.audit.fields_changed": 8,
},
body=ALICE_UPDATED_DASHBOARD,
event_name="signoz.audit.update",
scope_version="1.0.0",
),
AuditLog(
timestamp=now - datetime.timedelta(seconds=3),
resources={"service.name": "signoz", "signoz.resource.kind": "dashboard", "signoz.resource.id": "dash-001"},
attributes={
"signoz.principal.id": "user-002",
"signoz.principal.email": "viewer@acme.com",
"signoz.principal.type": "user",
"signoz.audit.verb": "delete",
"signoz.audit.outcome": "failure",
"exception.type": "forbidden",
},
body=VIEWER_FAILED_DELETE_DASHBOARD,
event_name="signoz.audit.delete",
severity_text="ERROR",
scope_version="1.0.0",
),
AuditLog(
timestamp=now - datetime.timedelta(seconds=2),
resources={"service.name": "signoz", "signoz.resource.kind": "alert-rule", "signoz.resource.id": "alert-001"},
attributes={
"signoz.principal.id": "sa-001",
"signoz.principal.type": "service_account",
"signoz.audit.verb": "create",
"signoz.audit.outcome": "success",
"signoz.audit.fields_changed": 12,
},
body=SA_CREATED_ALERT_RULE,
event_name="signoz.audit.create",
scope_version="1.0.0",
),
AuditLog(
timestamp=now - datetime.timedelta(seconds=1),
resources={"service.name": "signoz", "signoz.resource.kind": "user", "signoz.resource.id": "user-020"},
attributes={
"signoz.principal.id": "user-001",
"signoz.principal.email": "alice@acme.com",
"signoz.principal.type": "user",
"signoz.audit.verb": "update",
"signoz.audit.action_category": "access_control",
"signoz.audit.outcome": "success",
},
body=ALICE_UPDATED_USER,
event_name="signoz.audit.update",
scope_version="2.0.0",
),
]

View File

@@ -990,7 +990,7 @@ def insert_reduced_metrics_to_clickhouse(
) -> None:
"""Insert reduced series into distributed_time_series_v4_reduced and 60s
buckets into the reduced samples tables. These tables exist only when
the signoz-otel-collector version includes the metrics cardinality-control
the schema migrator version includes the metrics cardinality-control
migration."""
if time_series:
conn.insert(

View File

@@ -19,28 +19,37 @@ def create_migrator( # pylint: disable=too-many-arguments,too-many-positional-a
"""
Factory function for running schema migrations. Accepts optional
env_overrides to customize the migrator environment; the release comes
from the --signoz-otel-collector-version option.
from the --schema-migrator-version option.
"""
def create() -> None:
collector_version = request.config.getoption("--signoz-otel-collector-version")
migrator_version = request.config.getoption("--schema-migrator-version")
client = docker.from_env()
environment = {
"SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN": clickhouse.env["SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_DSN"],
"SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_CLUSTER": "cluster",
"SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_REPLICATION": "true",
"SIGNOZ_OTEL_COLLECTOR_TIMEOUT": "10m",
**(env_overrides or {}),
}
environment = dict(env_overrides) if env_overrides else {}
container = client.containers.run(
image=f"signoz/signoz-otel-collector:{collector_version}",
entrypoint="/bin/sh",
command=[
"-c",
"/signoz-otel-collector migrate bootstrap && /signoz-otel-collector migrate sync up && /signoz-otel-collector migrate async up",
],
image=f"signoz/signoz-schema-migrator:{migrator_version}",
command=f"sync --replication=true --cluster-name=cluster --up= --dsn={clickhouse.env['SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_DSN']}",
detach=True,
auto_remove=False,
network=network.id,
environment=environment,
)
result = container.wait()
if result["StatusCode"] != 0:
logs = container.logs().decode(encoding="utf-8")
container.remove()
print(logs)
raise RuntimeError("failed to run migrations on clickhouse")
container.remove()
container = client.containers.run(
image=f"signoz/signoz-schema-migrator:{migrator_version}",
command=f"async --replication=true --cluster-name=cluster --up= --dsn={clickhouse.env['SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_DSN']}",
detach=True,
auto_remove=False,
network=network.id,

View File

@@ -0,0 +1,457 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
import pytest
from fixtures import types
from fixtures.audit import AuditLog
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.querier import (
BuilderQuery,
build_aggregation,
build_order_by,
build_scalar_query,
make_query_request,
)
def test_audit_list_all(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_audit_logs: Callable[[list[AuditLog]], None],
) -> None:
"""List audit events across multiple resource types — verify count, ordering, and fields."""
now = datetime.now(tz=UTC)
insert_audit_logs(
[
AuditLog(
timestamp=now - timedelta(seconds=3),
resources={
"service.name": "signoz",
"signoz.resource.kind": "alert-rule",
"signoz.resource.id": "alert-001",
},
attributes={
"signoz.principal.id": "user-010",
"signoz.principal.email": "ops@acme.com",
"signoz.principal.type": "user",
"signoz.audit.verb": "create",
"signoz.audit.outcome": "success",
},
body="ops@acme.com (user-010) created alert-rule (alert-001)",
event_name="signoz.audit.create",
severity_text="INFO",
),
AuditLog(
timestamp=now - timedelta(seconds=2),
resources={
"service.name": "signoz",
"signoz.resource.kind": "saved-view",
"signoz.resource.id": "view-001",
},
attributes={
"signoz.principal.id": "user-010",
"signoz.principal.email": "ops@acme.com",
"signoz.principal.type": "user",
"signoz.audit.verb": "update",
"signoz.audit.outcome": "success",
},
body="ops@acme.com (user-010) updated saved-view (view-001)",
event_name="signoz.audit.update",
severity_text="INFO",
),
AuditLog(
timestamp=now - timedelta(seconds=1),
resources={
"service.name": "signoz",
"signoz.resource.kind": "user",
"signoz.resource.id": "user-020",
},
attributes={
"signoz.principal.id": "user-010",
"signoz.principal.email": "ops@acme.com",
"signoz.principal.type": "user",
"signoz.audit.verb": "update",
"signoz.audit.action_category": "access_control",
"signoz.audit.outcome": "success",
},
body="ops@acme.com (user-010) updated user (user-020)",
event_name="signoz.audit.update",
severity_text="INFO",
),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
now = datetime.now(tz=UTC)
response = make_query_request(
signoz,
token,
start_ms=int((now - timedelta(seconds=30)).timestamp() * 1000),
end_ms=int(now.timestamp() * 1000),
queries=[
BuilderQuery(
signal="logs",
source="audit",
limit=100,
order=[build_order_by("timestamp"), build_order_by("id")],
).to_dict()
],
request_type="raw",
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
rows = response.json()["data"]["data"]["results"][0]["rows"]
assert len(rows) == 3
# Most recent first
assert [row["data"]["event_name"] for row in rows] == ["signoz.audit.update", "signoz.audit.update", "signoz.audit.create"]
assert [row["data"]["body"]["message"] for row in rows] == [
"ops@acme.com (user-010) updated user (user-020)",
"ops@acme.com (user-010) updated saved-view (view-001)",
"ops@acme.com (user-010) created alert-rule (alert-001)",
]
# Verify event_name and body are present
assert rows[0]["data"]["body"] == {"message": "ops@acme.com (user-010) updated user (user-020)"}
assert rows[0]["data"]["severity_text"] == "INFO"
assert rows[0]["data"]["resource"] == {
"attributes": {
"service.name": "signoz",
"signoz.resource.kind": "user",
"signoz.resource.id": "user-020",
}
}
assert rows[0]["data"]["attributes"]["signoz.audit.action_category"] == "access_control"
assert rows[0]["data"]["scope"]["name"] == "signoz.audit"
@pytest.mark.parametrize(
"filter_expression,expected_count,expected_event_names",
[
pytest.param(
"signoz.principal.id = 'user-001'",
3,
{"signoz.audit.update", "signoz.audit.create"},
id="filter_by_principal_id",
),
pytest.param(
"signoz.audit.outcome = 'failure'",
1,
{"signoz.audit.delete"},
id="filter_by_outcome_failure",
),
pytest.param(
"signoz.resource.kind = 'dashboard' AND signoz.resource.id = 'dash-001'",
3,
{"signoz.audit.delete", "signoz.audit.update", "signoz.audit.create"},
id="filter_by_resource_kind_and_id",
),
pytest.param(
"signoz.principal.type = 'service_account'",
1,
{"signoz.audit.create"},
id="filter_by_principal_type",
),
pytest.param(
"signoz.resource.kind = 'dashboard' AND signoz.audit.verb = 'delete'",
1,
{"signoz.audit.delete"},
id="filter_by_resource_kind_and_action",
),
pytest.param(
"resource.signoz.resource.id = 'sa-001'",
1,
{"signoz.audit.create"},
id="filter_by_resource_prefix",
),
pytest.param(
"body CONTAINS 'failed'",
1,
{"signoz.audit.delete"},
id="filter_by_body_contains",
),
],
)
def test_audit_filter(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_audit_logs: Callable[[list[AuditLog]], None],
filter_expression: str,
expected_count: int,
expected_event_names: set,
) -> None:
"""Parametrized audit filter tests covering the documented query patterns."""
now = datetime.now(tz=UTC)
insert_audit_logs(
[
AuditLog(
timestamp=now - timedelta(seconds=5),
resources={
"service.name": "signoz",
"signoz.resource.kind": "dashboard",
"signoz.resource.id": "dash-001",
},
attributes={
"signoz.principal.id": "user-001",
"signoz.principal.email": "alice@acme.com",
"signoz.principal.type": "user",
"signoz.audit.verb": "create",
"signoz.audit.action_category": "configuration_changes",
"signoz.audit.outcome": "success",
},
body="alice@acme.com created dashboard",
event_name="signoz.audit.create",
),
AuditLog(
timestamp=now - timedelta(seconds=4),
resources={
"service.name": "signoz",
"signoz.resource.kind": "dashboard",
"signoz.resource.id": "dash-001",
},
attributes={
"signoz.principal.id": "user-001",
"signoz.principal.email": "alice@acme.com",
"signoz.principal.type": "user",
"signoz.audit.verb": "update",
"signoz.audit.action_category": "configuration_changes",
"signoz.audit.outcome": "success",
},
body="alice@acme.com updated dashboard",
event_name="signoz.audit.update",
),
AuditLog(
timestamp=now - timedelta(seconds=3),
resources={
"service.name": "signoz",
"signoz.resource.kind": "dashboard",
"signoz.resource.id": "dash-001",
},
attributes={
"signoz.principal.id": "user-002",
"signoz.principal.email": "viewer@acme.com",
"signoz.principal.type": "user",
"signoz.audit.verb": "delete",
"signoz.audit.action_category": "configuration_changes",
"signoz.audit.outcome": "failure",
"exception.type": "forbidden",
},
body="viewer@acme.com failed to delete dashboard",
event_name="signoz.audit.delete",
severity_text="ERROR",
),
AuditLog(
timestamp=now - timedelta(seconds=2),
resources={
"service.name": "signoz",
"signoz.resource.kind": "serviceaccount",
"signoz.resource.id": "sa-001",
},
attributes={
"signoz.principal.id": "sa-001",
"signoz.principal.type": "service_account",
"signoz.audit.verb": "create",
"signoz.audit.action_category": "access_control",
"signoz.audit.outcome": "success",
},
body="sa-001 created serviceaccount",
event_name="signoz.audit.create",
),
AuditLog(
timestamp=now - timedelta(seconds=1),
resources={
"service.name": "signoz",
"signoz.resource.kind": "session",
"signoz.resource.id": "*",
},
attributes={
"signoz.principal.id": "user-001",
"signoz.principal.email": "alice@acme.com",
"signoz.principal.type": "user",
"signoz.audit.verb": "create",
"signoz.audit.action_category": "access_control",
"signoz.audit.outcome": "success",
},
body="alice@acme.com created session",
event_name="signoz.audit.create",
),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
now = datetime.now(tz=UTC)
response = make_query_request(
signoz,
token,
start_ms=int((now - timedelta(seconds=30)).timestamp() * 1000),
end_ms=int(now.timestamp() * 1000),
queries=[
BuilderQuery(
signal="logs",
source="audit",
limit=100,
filter_expression=filter_expression,
order=[build_order_by("timestamp"), build_order_by("id")],
).to_dict()
],
request_type="raw",
)
assert response.status_code == HTTPStatus.OK
rows = response.json()["data"]["data"]["results"][0]["rows"]
assert len(rows) == expected_count
actual_event_names = {row["data"]["event_name"] for row in rows}
assert actual_event_names == expected_event_names
def test_audit_scalar_count_failures(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_audit_logs: Callable[[list[AuditLog]], None],
) -> None:
"""Alert query — count multiple failures from different principals."""
now = datetime.now(tz=UTC)
insert_audit_logs(
[
AuditLog(
timestamp=now - timedelta(seconds=3),
resources={
"service.name": "signoz",
"signoz.resource.kind": "dashboard",
"signoz.resource.id": "dash-100",
},
attributes={
"signoz.principal.id": "user-050",
"signoz.principal.type": "user",
"signoz.audit.verb": "delete",
"signoz.audit.outcome": "failure",
},
body="user-050 failed to delete dashboard",
event_name="signoz.audit.delete",
severity_text="ERROR",
),
AuditLog(
timestamp=now - timedelta(seconds=2),
resources={
"service.name": "signoz",
"signoz.resource.kind": "alert-rule",
"signoz.resource.id": "alert-200",
},
attributes={
"signoz.principal.id": "user-060",
"signoz.principal.type": "user",
"signoz.audit.verb": "update",
"signoz.audit.outcome": "failure",
},
body="user-060 failed to update alert-rule",
event_name="signoz.audit.update",
severity_text="ERROR",
),
AuditLog(
timestamp=now - timedelta(seconds=1),
resources={
"service.name": "signoz",
"signoz.resource.kind": "dashboard",
"signoz.resource.id": "dash-100",
},
attributes={
"signoz.principal.id": "user-050",
"signoz.principal.type": "user",
"signoz.audit.verb": "update",
"signoz.audit.outcome": "success",
},
body="user-050 updated dashboard",
event_name="signoz.audit.update",
),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
now = datetime.now(tz=UTC)
response = make_query_request(
signoz,
token,
start_ms=int((now - timedelta(seconds=30)).timestamp() * 1000),
end_ms=int(now.timestamp() * 1000),
queries=[
build_scalar_query(
name="A",
signal="logs",
source="audit",
aggregations=[build_aggregation("count()")],
filter_expression="signoz.audit.outcome = 'failure'",
)
],
request_type="scalar",
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
scalar_data = response.json()["data"]["data"]["results"][0].get("data", [])
assert len(scalar_data) == 1
assert scalar_data[0][0] == 2
def test_audit_does_not_leak_into_logs(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_audit_logs: Callable[[list[AuditLog]], None],
) -> None:
"""A single audit event in signoz_audit must not appear in regular log queries."""
now = datetime.now(tz=UTC)
insert_audit_logs(
[
AuditLog(
timestamp=now - timedelta(seconds=1),
resources={
"service.name": "signoz",
"signoz.resource.kind": "organization",
"signoz.resource.id": "org-999",
},
attributes={
"signoz.principal.id": "user-admin",
"signoz.principal.type": "user",
"signoz.audit.verb": "update",
"signoz.audit.outcome": "success",
},
body="user-admin updated organization (org-999)",
event_name="signoz.audit.update",
),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
now = datetime.now(tz=UTC)
response = make_query_request(
signoz,
token,
start_ms=int((now - timedelta(seconds=30)).timestamp() * 1000),
end_ms=int(now.timestamp() * 1000),
queries=[
BuilderQuery(
signal="logs",
limit=100,
order=[build_order_by("timestamp"), build_order_by("id")],
).to_dict()
],
request_type="raw",
)
assert response.status_code == HTTPStatus.OK
rows = response.json()["data"]["data"]["results"][0].get("rows") or []
audit_bodies = [row["data"]["body"] for row in rows if "signoz.audit" in row["data"].get("attributes_string", {}).get("signoz.audit.verb", "")]
assert len(audit_bodies) == 0

View File

@@ -1,177 +0,0 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
from fixtures import types
from fixtures.audit import AuditLog
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.querier import BuilderQuery, build_order_by, make_query_request
def test_audit_list_all(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_audit_logs: Callable[[list[AuditLog]], None],
) -> None:
"""List audit events across multiple resource types — verify count, ordering, and fields."""
now = datetime.now(tz=UTC)
insert_audit_logs(
[
AuditLog(
timestamp=now - timedelta(seconds=3),
resources={
"service.name": "signoz",
"signoz.resource.kind": "alert-rule",
"signoz.resource.id": "alert-001",
},
attributes={
"signoz.principal.id": "user-010",
"signoz.principal.email": "ops@acme.com",
"signoz.principal.type": "user",
"signoz.audit.verb": "create",
"signoz.audit.outcome": "success",
},
body="ops@acme.com (user-010) created alert-rule (alert-001)",
event_name="signoz.audit.create",
severity_text="INFO",
),
AuditLog(
timestamp=now - timedelta(seconds=2),
resources={
"service.name": "signoz",
"signoz.resource.kind": "saved-view",
"signoz.resource.id": "view-001",
},
attributes={
"signoz.principal.id": "user-010",
"signoz.principal.email": "ops@acme.com",
"signoz.principal.type": "user",
"signoz.audit.verb": "update",
"signoz.audit.outcome": "success",
},
body="ops@acme.com (user-010) updated saved-view (view-001)",
event_name="signoz.audit.update",
severity_text="INFO",
),
AuditLog(
timestamp=now - timedelta(seconds=1),
resources={
"service.name": "signoz",
"signoz.resource.kind": "user",
"signoz.resource.id": "user-020",
},
attributes={
"signoz.principal.id": "user-010",
"signoz.principal.email": "ops@acme.com",
"signoz.principal.type": "user",
"signoz.audit.verb": "update",
"signoz.audit.action_category": "access_control",
"signoz.audit.outcome": "success",
},
body="ops@acme.com (user-010) updated user (user-020)",
event_name="signoz.audit.update",
severity_text="INFO",
),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
now = datetime.now(tz=UTC)
response = make_query_request(
signoz,
token,
start_ms=int((now - timedelta(seconds=30)).timestamp() * 1000),
end_ms=int(now.timestamp() * 1000),
queries=[
BuilderQuery(
signal="logs",
source="audit",
limit=100,
order=[build_order_by("timestamp"), build_order_by("id")],
).to_dict()
],
request_type="raw",
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
rows = response.json()["data"]["data"]["results"][0]["rows"]
assert len(rows) == 3
# Most recent first
assert [row["data"]["event_name"] for row in rows] == ["signoz.audit.update", "signoz.audit.update", "signoz.audit.create"]
assert [row["data"]["body"]["message"] for row in rows] == [
"ops@acme.com (user-010) updated user (user-020)",
"ops@acme.com (user-010) updated saved-view (view-001)",
"ops@acme.com (user-010) created alert-rule (alert-001)",
]
# Verify event_name and body are present
assert rows[0]["data"]["body"] == {"message": "ops@acme.com (user-010) updated user (user-020)"}
assert rows[0]["data"]["severity_text"] == "INFO"
assert rows[0]["data"]["resource"] == {
"attributes": {
"service.name": "signoz",
"signoz.resource.kind": "user",
"signoz.resource.id": "user-020",
}
}
assert rows[0]["data"]["attributes"]["signoz.audit.action_category"] == "access_control"
assert rows[0]["data"]["scope"]["name"] == "signoz.audit"
def test_audit_does_not_leak_into_logs(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_audit_logs: Callable[[list[AuditLog]], None],
) -> None:
"""A single audit event in signoz_audit must not appear in regular log queries."""
now = datetime.now(tz=UTC)
insert_audit_logs(
[
AuditLog(
timestamp=now - timedelta(seconds=1),
resources={
"service.name": "signoz",
"signoz.resource.kind": "organization",
"signoz.resource.id": "org-999",
},
attributes={
"signoz.principal.id": "user-admin",
"signoz.principal.type": "user",
"signoz.audit.verb": "update",
"signoz.audit.outcome": "success",
},
body="user-admin updated organization (org-999)",
event_name="signoz.audit.update",
),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
now = datetime.now(tz=UTC)
response = make_query_request(
signoz,
token,
start_ms=int((now - timedelta(seconds=30)).timestamp() * 1000),
end_ms=int(now.timestamp() * 1000),
queries=[
BuilderQuery(
signal="logs",
limit=100,
order=[build_order_by("timestamp"), build_order_by("id")],
).to_dict()
],
request_type="raw",
)
assert response.status_code == HTTPStatus.OK
rows = response.json()["data"]["data"]["results"][0].get("rows") or []
audit_bodies = [row["data"]["body"] for row in rows if "signoz.audit" in row["data"].get("attributes_string", {}).get("signoz.audit.verb", "")]
assert len(audit_bodies) == 0

View File

@@ -1,214 +0,0 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
from fixtures import types
from fixtures.audit import AuditLog
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.querier import (
RequestType,
build_aggregation,
build_group_by_field,
build_scalar_query,
get_all_series,
index_series_by_label,
make_query_request,
)
def test_audit_scalar_count_failures(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_audit_logs: Callable[[list[AuditLog]], None],
) -> None:
"""Alert query — count multiple failures from different principals."""
now = datetime.now(tz=UTC)
insert_audit_logs(
[
AuditLog(
timestamp=now - timedelta(seconds=3),
resources={
"service.name": "signoz",
"signoz.resource.kind": "dashboard",
"signoz.resource.id": "dash-100",
},
attributes={
"signoz.principal.id": "user-050",
"signoz.principal.type": "user",
"signoz.audit.verb": "delete",
"signoz.audit.outcome": "failure",
},
body="user-050 failed to delete dashboard",
event_name="signoz.audit.delete",
severity_text="ERROR",
),
AuditLog(
timestamp=now - timedelta(seconds=2),
resources={
"service.name": "signoz",
"signoz.resource.kind": "alert-rule",
"signoz.resource.id": "alert-200",
},
attributes={
"signoz.principal.id": "user-060",
"signoz.principal.type": "user",
"signoz.audit.verb": "update",
"signoz.audit.outcome": "failure",
},
body="user-060 failed to update alert-rule",
event_name="signoz.audit.update",
severity_text="ERROR",
),
AuditLog(
timestamp=now - timedelta(seconds=1),
resources={
"service.name": "signoz",
"signoz.resource.kind": "dashboard",
"signoz.resource.id": "dash-100",
},
attributes={
"signoz.principal.id": "user-050",
"signoz.principal.type": "user",
"signoz.audit.verb": "update",
"signoz.audit.outcome": "success",
},
body="user-050 updated dashboard",
event_name="signoz.audit.update",
),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
now = datetime.now(tz=UTC)
response = make_query_request(
signoz,
token,
start_ms=int((now - timedelta(seconds=30)).timestamp() * 1000),
end_ms=int(now.timestamp() * 1000),
queries=[
build_scalar_query(
name="A",
signal="logs",
source="audit",
aggregations=[build_aggregation("count()")],
filter_expression="signoz.audit.outcome = 'failure'",
)
],
request_type="scalar",
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
scalar_data = response.json()["data"]["data"]["results"][0].get("data", [])
assert len(scalar_data) == 1
assert scalar_data[0][0] == 2
def test_audit_scalar_distinct_principals_by_resource_kind(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_audit_logs: Callable[[list[AuditLog]], None],
) -> None:
"""count_distinct(principal) grouped by a resource field: dashboards touched by two principals, alert rules by one."""
now = datetime.now(tz=UTC)
insert_audit_logs(
[
AuditLog(
timestamp=now - timedelta(seconds=seconds_ago),
resources={"service.name": "signoz", "signoz.resource.kind": kind, "signoz.resource.id": resource_id},
attributes={"signoz.principal.id": principal, "signoz.principal.type": "user", "signoz.audit.verb": verb, "signoz.audit.outcome": "success"},
body=f"{principal} {verb} {kind} ({resource_id})",
event_name=f"signoz.audit.{verb}",
)
for seconds_ago, kind, resource_id, principal, verb in [
(5, "dashboard", "dash-001", "user-001", "update"),
(4, "dashboard", "dash-001", "user-001", "update"),
(3, "dashboard", "dash-002", "user-002", "delete"),
(2, "alert-rule", "alert-001", "user-001", "create"),
(1, "alert-rule", "alert-002", "user-001", "update"),
]
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_query_request(
signoz,
token,
start_ms=int((now - timedelta(seconds=30)).timestamp() * 1000),
end_ms=int(now.timestamp() * 1000),
queries=[
build_scalar_query(
name="A",
signal="logs",
source="audit",
aggregations=[build_aggregation("count_distinct(signoz.principal.id)")],
group_by=[build_group_by_field("signoz.resource.kind", "string", "resource")],
)
],
request_type=RequestType.SCALAR,
)
assert response.status_code == HTTPStatus.OK, response.text
rows = response.json()["data"]["data"]["results"][0]["data"]
assert {row[0]: row[-1] for row in rows} == {"dashboard": 2, "alert-rule": 1}
def test_audit_time_series_count_by_verb(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_audit_logs: Callable[[list[AuditLog]], None],
) -> None:
"""count() per minute grouped by verb: two updates two minutes ago, one update and one delete a minute ago."""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
one_minute_ago = now - timedelta(minutes=1)
two_minutes_ago = now - timedelta(minutes=2)
insert_audit_logs(
[
AuditLog(
timestamp=timestamp,
resources={"service.name": "signoz", "signoz.resource.kind": "dashboard", "signoz.resource.id": resource_id},
attributes={"signoz.principal.id": principal, "signoz.principal.type": "user", "signoz.audit.verb": verb, "signoz.audit.outcome": "success"},
body=f"{principal} {verb} dashboard ({resource_id})",
event_name=f"signoz.audit.{verb}",
)
for timestamp, resource_id, principal, verb in [
(two_minutes_ago + timedelta(seconds=10), "dash-001", "user-001", "update"),
(two_minutes_ago + timedelta(seconds=20), "dash-002", "user-001", "update"),
(one_minute_ago + timedelta(seconds=10), "dash-001", "user-002", "update"),
(one_minute_ago + timedelta(seconds=30), "dash-002", "user-002", "delete"),
]
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_query_request(
signoz,
token,
start_ms=int((now - timedelta(minutes=5)).timestamp() * 1000),
end_ms=int((now + timedelta(seconds=5)).timestamp() * 1000),
queries=[
build_scalar_query(
name="A",
signal="logs",
source="audit",
aggregations=[build_aggregation("count()")],
group_by=[build_group_by_field("signoz.audit.verb", "string", "attribute")],
)
],
request_type=RequestType.TIME_SERIES,
)
assert response.status_code == HTTPStatus.OK, response.text
series_by_verb = index_series_by_label(get_all_series(response.json(), "A"), "signoz.audit.verb")
counts_by_verb = {verb: {point["timestamp"]: point["value"] for point in series["values"] if point["value"]} for verb, series in series_by_verb.items()}
two_minutes_ago_ms = int(two_minutes_ago.timestamp() * 1000)
one_minute_ago_ms = int(one_minute_ago.timestamp() * 1000)
assert counts_by_verb == {
"update": {two_minutes_ago_ms: 2, one_minute_ago_ms: 1},
"delete": {one_minute_ago_ms: 1},
}

View File

@@ -1,74 +0,0 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
import pytest
from fixtures import types
from fixtures.audit import (
ALICE_CREATED_DASHBOARD,
ALICE_UPDATED_DASHBOARD,
ALICE_UPDATED_USER,
SA_CREATED_ALERT_RULE,
VIEWER_FAILED_DELETE_DASHBOARD,
AuditLog,
sample_audit_logs,
)
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.querier import BuilderQuery, build_order_by, make_query_request
@pytest.mark.parametrize(
"filter_expression,expected_bodies",
[
pytest.param("signoz.principal.id = 'user-001'", {ALICE_CREATED_DASHBOARD, ALICE_UPDATED_DASHBOARD, ALICE_UPDATED_USER}, id="equal"),
pytest.param("signoz.audit.verb != 'update'", {ALICE_CREATED_DASHBOARD, VIEWER_FAILED_DELETE_DASHBOARD, SA_CREATED_ALERT_RULE}, id="not_equal"),
pytest.param("signoz.audit.verb IN ['create', 'delete']", {ALICE_CREATED_DASHBOARD, VIEWER_FAILED_DELETE_DASHBOARD, SA_CREATED_ALERT_RULE}, id="in_list"),
pytest.param("signoz.resource.kind NOT IN ['dashboard']", {SA_CREATED_ALERT_RULE, ALICE_UPDATED_USER}, id="not_in_list"),
pytest.param("signoz.resource.kind = 'dashboard' AND signoz.resource.id = 'dash-001'", {ALICE_CREATED_DASHBOARD, ALICE_UPDATED_DASHBOARD, VIEWER_FAILED_DELETE_DASHBOARD}, id="and"),
pytest.param("signoz.audit.outcome = 'failure' OR signoz.principal.type = 'service_account'", {VIEWER_FAILED_DELETE_DASHBOARD, SA_CREATED_ALERT_RULE}, id="or"),
pytest.param("NOT signoz.resource.kind = 'dashboard'", {SA_CREATED_ALERT_RULE, ALICE_UPDATED_USER}, id="not"),
pytest.param("signoz.resource.kind = 'dashboard' AND (signoz.audit.verb = 'create' OR signoz.audit.verb = 'delete')", {ALICE_CREATED_DASHBOARD, VIEWER_FAILED_DELETE_DASHBOARD}, id="and_with_grouped_or"),
pytest.param("exception.type EXISTS", {VIEWER_FAILED_DELETE_DASHBOARD}, id="exists"),
pytest.param("exception.type NOT EXISTS", {ALICE_CREATED_DASHBOARD, ALICE_UPDATED_DASHBOARD, SA_CREATED_ALERT_RULE, ALICE_UPDATED_USER}, id="not_exists"),
pytest.param("body CONTAINS 'failed'", {VIEWER_FAILED_DELETE_DASHBOARD}, id="contains"),
pytest.param("body NOT CONTAINS 'dashboard'", {SA_CREATED_ALERT_RULE, ALICE_UPDATED_USER}, id="not_contains"),
pytest.param("body LIKE '%failed%'", {VIEWER_FAILED_DELETE_DASHBOARD}, id="like"),
pytest.param("body ILIKE '%ALERT-RULE%'", {SA_CREATED_ALERT_RULE}, id="ilike"),
pytest.param("body REGEXP '^sa-001'", {SA_CREATED_ALERT_RULE}, id="regexp"),
pytest.param("signoz.audit.fields_changed > 5", {ALICE_UPDATED_DASHBOARD, SA_CREATED_ALERT_RULE}, id="gt_number"),
pytest.param("signoz.audit.fields_changed <= 8", {ALICE_CREATED_DASHBOARD, ALICE_UPDATED_DASHBOARD}, id="lte_number"),
],
)
def test_audit_filter_operators(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_audit_logs: Callable[[list[AuditLog]], None],
filter_expression: str,
expected_bodies: set[str],
) -> None:
now = datetime.now(tz=UTC)
insert_audit_logs(sample_audit_logs(now))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_query_request(
signoz,
token,
start_ms=int((now - timedelta(seconds=30)).timestamp() * 1000),
end_ms=int(now.timestamp() * 1000),
queries=[
BuilderQuery(
signal="logs",
source="audit",
limit=100,
filter_expression=filter_expression,
order=[build_order_by("timestamp"), build_order_by("id")],
).to_dict()
],
request_type="raw",
)
assert response.status_code == HTTPStatus.OK, response.text
rows = response.json()["data"]["data"]["results"][0].get("rows") or []
assert {row["data"]["body"]["message"] for row in rows} == expected_bodies

View File

@@ -1,78 +0,0 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
from fixtures import types
from fixtures.audit import AuditLog
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.querier import (
BuilderQuery,
RequestType,
build_aggregation,
build_order_by,
build_scalar_query,
make_query_request,
)
def test_audit_time_range_is_start_inclusive_end_exclusive(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_audit_logs: Callable[[list[AuditLog]], None],
) -> None:
"""Events at start, inside, and at end minus one second are returned; one second before start, at end, and two hours earlier are not. The same window bounds a scalar count."""
end = datetime.now(tz=UTC).replace(microsecond=0)
start = end - timedelta(minutes=1)
insert_audit_logs(
[
AuditLog(
timestamp=timestamp,
resources={"service.name": "signoz", "signoz.resource.kind": "dashboard", "signoz.resource.id": "dash-001"},
attributes={"signoz.principal.id": "user-001", "signoz.principal.type": "user", "signoz.audit.verb": "update", "signoz.audit.outcome": "success"},
body=body,
event_name="signoz.audit.update",
)
for timestamp, body in [
(start - timedelta(seconds=1), "one second before start"),
(start, "at start"),
(start + timedelta(seconds=30), "inside"),
(end - timedelta(seconds=1), "one second before end"),
(end, "at end"),
(end - timedelta(hours=2), "two hours earlier"),
]
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
start_ms = int(start.timestamp() * 1000)
end_ms = int(end.timestamp() * 1000)
response = make_query_request(
signoz,
token,
start_ms=start_ms,
end_ms=end_ms,
queries=[
BuilderQuery(
signal="logs",
source="audit",
limit=100,
order=[build_order_by("timestamp"), build_order_by("id")],
).to_dict()
],
request_type=RequestType.RAW,
)
assert response.status_code == HTTPStatus.OK, response.text
rows = response.json()["data"]["data"]["results"][0].get("rows") or []
assert [row["data"]["body"]["message"] for row in rows] == ["one second before end", "inside", "at start"]
response = make_query_request(
signoz,
token,
start_ms=start_ms,
end_ms=end_ms,
queries=[build_scalar_query(name="A", signal="logs", source="audit", aggregations=[build_aggregation("count()")])],
request_type=RequestType.SCALAR,
)
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["data"]["data"]["results"][0]["data"][0][0] == 3

View File

@@ -1,99 +0,0 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
import pytest
from fixtures import types
from fixtures.audit import AuditLog
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.querier import BuilderQuery, build_order_by, make_query_request
ATTACHED_ROLE_001_TO_USER_020 = "ops@acme.com (user-010) attached role (role-001) to user (user-020)"
DETACHED_ROLE_001_FROM_USER_021 = "ops@acme.com (user-010) detached role (role-001) from user (user-021)"
ATTACHED_ROLE_002_TO_USER_020 = "ops@acme.com (user-010) attached role (role-002) to user (user-020)"
UPDATED_ROLE_001 = "ops@acme.com (user-010) updated role (role-001)"
@pytest.mark.parametrize(
"filter_expression,expected_bodies",
[
pytest.param("event_name = 'signoz.audit.attach'", {ATTACHED_ROLE_001_TO_USER_020, ATTACHED_ROLE_002_TO_USER_020}, id="attach_events"),
pytest.param("event_name = 'signoz.audit.detach'", {DETACHED_ROLE_001_FROM_USER_021}, id="detach_events"),
pytest.param("signoz.resource.target.id = 'user-020'", {ATTACHED_ROLE_001_TO_USER_020, ATTACHED_ROLE_002_TO_USER_020}, id="by_target_id"),
pytest.param("signoz.resource.id = 'role-001' AND signoz.resource.target.kind = 'user'", {ATTACHED_ROLE_001_TO_USER_020, DETACHED_ROLE_001_FROM_USER_021}, id="by_resource_and_target_kind"),
pytest.param("signoz.resource.target.kind EXISTS", {ATTACHED_ROLE_001_TO_USER_020, DETACHED_ROLE_001_FROM_USER_021, ATTACHED_ROLE_002_TO_USER_020}, id="with_target"),
pytest.param("signoz.resource.target.kind NOT EXISTS", {UPDATED_ROLE_001}, id="without_target"),
],
)
def test_audit_relation_filters(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_audit_logs: Callable[[list[AuditLog]], None],
filter_expression: str,
expected_bodies: set[str],
) -> None:
"""Attach and detach events carry the counterpart as target attributes on the record; a plain update on the same resource carries none."""
now = datetime.now(tz=UTC)
principal = {"signoz.principal.id": "user-010", "signoz.principal.email": "ops@acme.com", "signoz.principal.type": "user", "signoz.audit.outcome": "success"}
insert_audit_logs(
[
AuditLog(
timestamp=now - timedelta(seconds=4),
resources={"service.name": "signoz", "signoz.resource.kind": "role", "signoz.resource.id": "role-001"},
attributes={**principal, "signoz.audit.verb": "attach", "signoz.resource.target.kind": "user", "signoz.resource.target.id": "user-020"},
body=ATTACHED_ROLE_001_TO_USER_020,
event_name="signoz.audit.attach",
),
AuditLog(
timestamp=now - timedelta(seconds=3),
resources={"service.name": "signoz", "signoz.resource.kind": "role", "signoz.resource.id": "role-001"},
attributes={**principal, "signoz.audit.verb": "detach", "signoz.resource.target.kind": "user", "signoz.resource.target.id": "user-021"},
body=DETACHED_ROLE_001_FROM_USER_021,
event_name="signoz.audit.detach",
),
AuditLog(
timestamp=now - timedelta(seconds=2),
resources={"service.name": "signoz", "signoz.resource.kind": "role", "signoz.resource.id": "role-002"},
attributes={**principal, "signoz.audit.verb": "attach", "signoz.resource.target.kind": "user", "signoz.resource.target.id": "user-020"},
body=ATTACHED_ROLE_002_TO_USER_020,
event_name="signoz.audit.attach",
),
AuditLog(
timestamp=now - timedelta(seconds=1),
resources={"service.name": "signoz", "signoz.resource.kind": "role", "signoz.resource.id": "role-001"},
attributes={**principal, "signoz.audit.verb": "update"},
body=UPDATED_ROLE_001,
event_name="signoz.audit.update",
),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_query_request(
signoz,
token,
start_ms=int((now - timedelta(seconds=30)).timestamp() * 1000),
end_ms=int(now.timestamp() * 1000),
queries=[
BuilderQuery(
signal="logs",
source="audit",
limit=100,
filter_expression=filter_expression,
order=[build_order_by("timestamp"), build_order_by("id")],
).to_dict()
],
request_type="raw",
)
assert response.status_code == HTTPStatus.OK, response.text
rows = response.json()["data"]["data"]["results"][0].get("rows") or []
assert {row["data"]["body"]["message"] for row in rows} == expected_bodies
for row in rows:
if row["data"]["event_name"] == "signoz.audit.update":
assert "signoz.resource.target.kind" not in row["data"]["attributes"]
else:
assert row["data"]["attributes"]["signoz.resource.target.kind"] == "user"
assert row["data"]["resource"]["attributes"]["signoz.resource.kind"] == "role"

View File

@@ -1,73 +0,0 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
import pytest
from fixtures import types
from fixtures.audit import (
ALICE_CREATED_DASHBOARD,
ALICE_UPDATED_DASHBOARD,
ALICE_UPDATED_USER,
SA_CREATED_ALERT_RULE,
VIEWER_FAILED_DELETE_DASHBOARD,
AuditLog,
sample_audit_logs,
)
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.querier import BuilderQuery, build_order_by, make_query_request
ALL_SAMPLE_BODIES = {ALICE_CREATED_DASHBOARD, ALICE_UPDATED_DASHBOARD, VIEWER_FAILED_DELETE_DASHBOARD, SA_CREATED_ALERT_RULE, ALICE_UPDATED_USER}
BARE_SCOPE_KEYS_SKIP = pytest.mark.skip(reason="bare scope_name and scope_version resolve to attributes in the audit schema; only the scope. prefix reaches the scope column")
@pytest.mark.parametrize(
"filter_expression,expected_bodies",
[
pytest.param("signoz.resource.id = 'alert-001'", {SA_CREATED_ALERT_RULE}, id="resource_key_bare"),
pytest.param("resource.signoz.resource.id = 'alert-001'", {SA_CREATED_ALERT_RULE}, id="resource_key_prefixed"),
pytest.param("signoz.principal.email = 'alice@acme.com'", {ALICE_CREATED_DASHBOARD, ALICE_UPDATED_DASHBOARD, ALICE_UPDATED_USER}, id="attribute_key_bare"),
pytest.param("attribute.signoz.principal.email = 'alice@acme.com'", {ALICE_CREATED_DASHBOARD, ALICE_UPDATED_DASHBOARD, ALICE_UPDATED_USER}, id="attribute_key_prefixed"),
pytest.param("severity_text = 'ERROR'", {VIEWER_FAILED_DELETE_DASHBOARD}, id="intrinsic_key_bare"),
pytest.param("log.severity_text = 'ERROR'", {VIEWER_FAILED_DELETE_DASHBOARD}, id="intrinsic_key_prefixed"),
pytest.param("event_name = 'signoz.audit.create'", {ALICE_CREATED_DASHBOARD, SA_CREATED_ALERT_RULE}, id="intrinsic_event_name"),
pytest.param("scope.name = 'signoz.audit'", ALL_SAMPLE_BODIES, id="scope_name_prefixed"),
pytest.param("scope.name = 'other'", set(), id="scope_name_prefixed_no_match"),
pytest.param("scope.version = '2.0.0'", {ALICE_UPDATED_USER}, id="scope_version_prefixed"),
pytest.param("scope_name = 'signoz.audit'", ALL_SAMPLE_BODIES, marks=BARE_SCOPE_KEYS_SKIP, id="scope_name_bare"),
pytest.param("scope_version = '2.0.0'", {ALICE_UPDATED_USER}, marks=BARE_SCOPE_KEYS_SKIP, id="scope_version_bare"),
],
)
def test_audit_filter_by_field_context(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_audit_logs: Callable[[list[AuditLog]], None],
filter_expression: str,
expected_bodies: set[str],
) -> None:
"""A bare key resolves to the resource column for the keys the audit writer emits, to a built-in column for intrinsics, and to attributes otherwise; a context prefix pins it."""
now = datetime.now(tz=UTC)
insert_audit_logs(sample_audit_logs(now))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_query_request(
signoz,
token,
start_ms=int((now - timedelta(seconds=30)).timestamp() * 1000),
end_ms=int(now.timestamp() * 1000),
queries=[
BuilderQuery(
signal="logs",
source="audit",
limit=100,
filter_expression=filter_expression,
order=[build_order_by("timestamp"), build_order_by("id")],
).to_dict()
],
request_type="raw",
)
assert response.status_code == HTTPStatus.OK, response.text
rows = response.json()["data"]["data"]["results"][0].get("rows") or []
assert {row["data"]["body"]["message"] for row in rows} == expected_bodies