Compare commits

..

72 Commits

Author SHA1 Message Date
Naman Verma
fd45b4fad1 fix: builder query validation (might need to revisit, 3 types seems bad) 2026-04-01 16:43:01 +05:30
Naman Verma
cea88bf51e chore: use perses updated/createdat 2026-04-01 16:26:35 +05:30
Naman Verma
6ead1cd52a chore: perses folder not needed anymore 2026-04-01 16:16:30 +05:30
Naman Verma
7cb700428c fix: go mod fix 2026-04-01 16:15:49 +05:30
Naman Verma
a2dc410be6 Merge branch 'main' into nv/4172 2026-04-01 16:14:02 +05:30
Naman Verma
66ce48434d feat: go struct based schema for dashboardv2 with validations and some tests 2026-04-01 16:13:26 +05:30
Nikhil Soni
23e3c75d24 feat: return all spans for flamegraph under a limit (#10757)
* feat: return all spans for flamegraph under a limit

* feat: increase fg limits and add timestamp boundaries

* fix: set default value for ts boundary

* fix: use correct value for boundary end ts

* chore: change info log of flamegraph to debug
2026-04-01 10:18:36 +00:00
Pandey
42415e0873 feat(audit): handler-level AuditDef, audit middleware, and response capture (#10791)
* feat(audit): handler-level AuditDef and response-capturing wrapper

Add declarative audit instrumentation to the handler package. Routes
declare an AuditDef alongside OpenAPIDef; the handler automatically
captures the response status/body and emits an audit event via
auditor.Audit() after every request.

* refactor(audit): move audit logic to middleware, merge with logging

Move audit event emission from handler to middleware layer. The handler
package keeps only the AuditDef struct and AuditDefProvider interface.
The logging middleware now handles both request logging and audit event
emission using a single response capture, avoiding double-wrapping.

Rename badResponseLoggingWriter to responseCapture with body capture
on all 4xx/5xx responses (previously only 400 and 5xx).

* refactor(audit): rename Logging middleware to Audit, merge into single file

Delete logging.go and merge its contents into audit.go. Rename
Logging/NewLogging to Audit/NewAudit. The response.go file with
responseCapture is unchanged.

* refactor(audit): extract NewAuditEventFromHTTPRequest factory into audittypes

Move event construction to audittypes.NewAuditEventFromHTTPRequest with
an AuditEventContext struct for caller-provided fields. The audittypes
layer reads only transport fields from *http.Request and has no mux,
authtypes, or context dependencies. The middleware pre-extracts
principal, trace, error, and route fields before calling the factory.

* refactor(audit): move error parsing to render.ErrorFromBody and render.ErrorTypeFromStatusCode

Add render.ErrorFromBody to extract errors.JSON from a JSON-encoded
ErrorResponse body, and render.ErrorTypeFromStatusCode to reverse-map
HTTP status codes to error type strings. The middleware now uses these
instead of local duplicates.

* refactor(audit): move AuditDef onto Handler interface, consolidate files

Move AuditDef() onto the Handler interface directly. All Handler
implementations now carry it: handler returns the configured def,
healthOpenAPIHandler returns nil. Delete the separate AuditDefProvider
interface and audit.go handler file. Move excludedRoutes check before
audit emission so excluded routes skip both logging and audit.

* feat(audit): add option.go with AuditDef, Option, and WithAuditDef

* refactor(audit): decompose AuditEvent into attribute sub-structs, add tests

Decompose flat AuditEvent fields into typed sub-structs
(AuditEventAuditAttributes, PrincipalAttributes, ResourceAttributes,
ErrorAttributes, TransportAttributes) each with a constructor and
Put(pcommon.Map) method. Simplify NewAuditEventFromHTTPRequest to
accept authtypes.Claims and oteltrace IDs directly. Simplify the
middleware caller accordingly.

Add unit tests for the factory, outcome boundary, and principal type
derivation.

* refactor(audit): shorten attribute struct names, drop error message

Rename AuditEventAuditAttributes to AuditAttributes,
AuditEventPrincipalAttributes to PrincipalAttributes, and likewise
for Resource, Error, and Transport. The package prefix already
disambiguates.

Remove ErrorMessage from ErrorAttributes to avoid leaking sensitive
or PII data into audit logs. Error type and code are sufficient for
filtering; investigators can correlate via trace ID.

* fix(audit): update auditorserver test and otlphttp provider for new struct layout

Update newTestEvent in server_test.go to use nested AuditAttributes
and ResourceAttributes. Update otlphttpauditor provider to access
PrincipalOrgID via PrincipalAttributes. Fix godot lint on attribute
section comments.

* fix(audit): fix gjson path in ErrorCodeFromBody, add tests

Fix ErrorCodeFromBody gjson path from "errors.code" to "error.code"
to match the ErrorResponse JSON structure. Add unit tests for valid
error response and invalid JSON cases.

* fix(audit): add CodeUnset, use ErrorCodeFromBody in middleware

Add errors.CodeUnset for responses missing an error code. Update the
audit middleware to use render.ErrorCodeFromBody instead of the removed
render.ErrorFromBody.

* test(audit): add unit tests for responseCapture

Test the four meaningful behaviors: success responses don't capture
body, error responses capture body, large error bodies truncate at
4096 bytes, and 204 No Content suppresses writes entirely.

* fix(audit): check rw.Write return values in response_test.go

* style(audit): rename want prefix to expected in test fields

* refactor(audit): replace Sprintf with strings.Builder in newBody

Handle edge cases where principal email, ID, or resource ID may be
empty. The builder conditionally includes each segment, avoiding
empty parentheses or leading spaces in the audit body.

Add test cases covering all meaningful combinations: success/failure
with full/partial/empty principal, resource ID, and error details.

* chore: fix formatting

* chore: remove json tags

* fix: rebase with main
2026-04-01 10:10:52 +00:00
Vikrant Gupta
bad80399a6 feat(serviceaccount): integrate service account (#10681)
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
* feat(serviceaccount): integrate service account

* feat(serviceaccount): integrate service account with better types

* feat(serviceaccount): fix lint and testing changes

* feat(serviceaccount): update integration tests

* feat(serviceaccount): fix formatting

* feat(serviceaccount): fix openapi spec

* feat(serviceaccount): update txlock to immediate to avoid busy snapshot errors

* feat(serviceaccount): add restrictions for factor_api_key

* feat(serviceaccount): add restrictions for factor_api_key

* feat: enabled service account and deprecated API Keys (#10715)

* feat: enabled service account and deprecated API Keys

* feat: deprecated API Keys

* feat: service account spec updates and role management changes

* feat: updated the error component for roles management

* feat: updated test case

* feat: updated the error component and added retries

* feat: refactored code and added retry to happend 3 times total

* feat: fixed feedbacks and added test case

* feat: refactored code and removed retry

* feat: updated the test cases

---------

Co-authored-by: SagarRajput-7 <162284829+SagarRajput-7@users.noreply.github.com>
2026-04-01 07:20:59 +00:00
Naman Verma
7d3612c10a feat: textbox variable 2026-03-25 13:21:46 +05:30
Naman Verma
c82cd32f61 Merge branch 'main' into nv/4172 2026-03-25 13:11:49 +05:30
Naman Verma
a3980e084c Merge branch 'main' into nv/4172 2026-03-24 16:44:50 +05:30
Naman Verma
4319dd9cef chore: doc for how to add a panel spec 2026-03-24 16:44:34 +05:30
Naman Verma
92a5e9b9c9 chore: common threshold type 2026-03-24 16:23:11 +05:30
Naman Verma
408a914129 chore: change attr name to name 2026-03-24 16:13:09 +05:30
Naman Verma
0e304b1d40 fix: normalise enum defs 2026-03-24 16:12:09 +05:30
Naman Verma
58a9be24d3 chore: single version for all schemas 2026-03-24 14:07:24 +05:30
Naman Verma
adf439fcf1 fix: proper type for selectFields 2026-03-24 13:56:14 +05:30
Naman Verma
a1a54c4bb2 fix: promql step duration schema 2026-03-24 13:53:19 +05:30
Naman Verma
3c1961d3fc fix: datasource in perses.json 2026-03-24 13:44:58 +05:30
Naman Verma
c3efa0660b fix: only allow one of metric or expr aggregation in builder query 2026-03-24 13:40:42 +05:30
Naman Verma
183dd09082 fix: functions in formula 2026-03-24 13:34:59 +05:30
Naman Verma
a351373c49 chore: common package for variables' repeated definitions 2026-03-24 13:23:53 +05:30
Naman Verma
8e7653b90d chore: common package for queries' repeated definitions 2026-03-24 13:22:49 +05:30
Naman Verma
5c40d6b68b chore: common package for panels' repeated definitions 2026-03-24 13:19:12 +05:30
Naman Verma
31115df41c chore: actually name every panel as a panel 2026-03-24 13:05:55 +05:30
Naman Verma
869c3dccb2 fix: less verbose field names in dynamic var 2026-03-24 12:57:34 +05:30
Naman Verma
c5d7a7ef8c fix: no nesting in context links 2026-03-24 12:55:40 +05:30
Naman Verma
544b87b254 chore: remove unimplemented join query schema 2026-03-24 12:48:36 +05:30
Naman Verma
e885fb98e5 chore: no need for threshold prefix inside threshold obj 2026-03-24 12:44:50 +05:30
Naman Verma
be227eec43 chore: replace yAxisUnit by unit 2026-03-24 12:41:16 +05:30
Naman Verma
13263c1f25 Merge branch 'main' into nv/4172 2026-03-23 20:01:04 +05:30
Naman Verma
ccbf410d15 fix: no more online validation 2026-03-23 16:23:51 +05:30
Naman Verma
03b98ff824 chore: remaining fields file 2026-03-23 16:10:01 +05:30
Naman Verma
2cdba0d11c chore: examples for panel types 2026-03-23 16:06:29 +05:30
Naman Verma
84d2885530 chore: a more complex example 2026-03-23 16:03:18 +05:30
Naman Verma
b82dcc6138 docs: list panel schema without upstream ref 2026-03-23 15:31:53 +05:30
Naman Verma
a14d5847b9 docs: histogram chart panel schema without upstream ref 2026-03-23 15:21:27 +05:30
Naman Verma
d184746142 docs: table chart panel schema without upstream ref 2026-03-23 15:17:21 +05:30
Naman Verma
c335e17e1d docs: pie chart panel schema without upstream ref 2026-03-23 14:33:05 +05:30
Naman Verma
433dd0b2d0 docs: number panel schema without upstream ref 2026-03-23 14:27:46 +05:30
Naman Verma
05e97e246a docs: number panel schema without upstream ref 2026-03-23 14:27:37 +05:30
Naman Verma
bddfe30f6c docs: bar chart panel schema without upstream ref 2026-03-23 14:22:00 +05:30
Naman Verma
7a01a5250d chore: object for visualization section 2026-03-23 14:16:07 +05:30
Naman Verma
09c98c830d docs: time series panel schema without upstream ref 2026-03-23 14:11:02 +05:30
Naman Verma
0fbb90cc91 fix: promql fix 2026-03-23 14:09:48 +05:30
Naman Verma
15f0787610 chore: remove upstream import 2026-03-23 13:07:48 +05:30
Naman Verma
22ebc7732c feat: promql example 2026-03-22 19:39:57 +05:30
Naman Verma
cff18edf6e chore: comment explaining when to use composite query and when not 2026-03-22 19:22:45 +05:30
Naman Verma
cb49c0bf3b feat: custom time series schema 2026-03-22 19:18:15 +05:30
Naman Verma
1cb6f94d21 fix: proper composite query schema 2026-03-19 16:18:31 +05:30
Naman Verma
68155f374b chore: folders in schemas for arranging 2026-03-19 15:15:06 +05:30
Naman Verma
696524509f chore: folders in schemas for arranging 2026-03-19 15:14:43 +05:30
Naman Verma
705cdab38c chore: rename 2026-03-19 14:24:10 +05:30
Naman Verma
ae9b881413 chore: py script not needed 2026-03-19 01:39:17 +05:30
Naman Verma
05f4e15d07 chore: rearrange specs in package.json 2026-03-19 01:39:08 +05:30
Naman Verma
1653c6d725 chore: checkpoint for half correct setup 2026-03-19 01:34:22 +05:30
Naman Verma
070b4b7061 chore: test file with way more examples 2026-03-18 22:01:16 +05:30
Naman Verma
7f4c06edd6 chore: test file with way more examples 2026-03-18 22:01:10 +05:30
Naman Verma
6bed20b5b9 fix: remove fields from variable specs that are there in ListVariable 2026-03-18 19:19:00 +05:30
Naman Verma
033bd3c9b8 feat: validation script 2026-03-18 15:52:05 +05:30
Naman Verma
d4c9a923fd chore: no commons (for now) 2026-03-18 15:28:35 +05:30
Naman Verma
387dcb529f chore: no config folder 2026-03-18 15:04:16 +05:30
Naman Verma
7a4da7bcc5 chore: no config folder 2026-03-18 15:04:02 +05:30
Naman Verma
b152fae3fa chore: remove validate file 2026-03-18 15:03:19 +05:30
Naman Verma
2ed766726c chore: remove manually written manifest and package 2026-03-18 15:02:23 +05:30
Naman Verma
8767f6a57d chore: remove stub for time series chart 2026-03-18 15:01:58 +05:30
Naman Verma
22d8c7599b chore: rm comment 2026-03-18 13:10:19 +05:30
Naman Verma
1019264272 chore: no need for PageSize type in commons, only used once 2026-03-18 13:07:31 +05:30
Naman Verma
c950d7e784 chore: no need for Signal type in commons, only used once 2026-03-18 13:05:32 +05:30
Naman Verma
1e279e6193 Merge branch 'main' into nv/4172 2026-03-18 13:03:09 +05:30
Naman Verma
d3a278c43e docs: perses schema for dashboards 2026-03-17 14:56:07 +05:30
42 changed files with 3753 additions and 388 deletions

View File

@@ -76,12 +76,12 @@ func (provider *provider) Start(ctx context.Context) error {
}
func (provider *provider) Audit(ctx context.Context, event audittypes.AuditEvent) {
if event.PrincipalOrgID.IsZero() {
if event.PrincipalAttributes.PrincipalOrgID.IsZero() {
provider.settings.Logger().WarnContext(ctx, "audit event dropped as org_id is zero")
return
}
if _, err := provider.licensing.GetActive(ctx, event.PrincipalOrgID); err != nil {
if _, err := provider.licensing.GetActive(ctx, event.PrincipalAttributes.PrincipalOrgID); err != nil {
return
}

View File

@@ -229,7 +229,7 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
s.config.APIServer.Timeout.Default,
s.config.APIServer.Timeout.Max,
).Wrap)
r.Use(middleware.NewLogging(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes).Wrap)
r.Use(middleware.NewAudit(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes, nil).Wrap)
r.Use(middleware.NewComment().Wrap)
apiHandler.RegisterRoutes(r, am)

View File

@@ -1,8 +1,9 @@
import { ReactChild, useCallback, useEffect, useMemo, useState } from 'react';
import { useQuery } from 'react-query';
import { matchPath, Redirect, useLocation } from 'react-router-dom';
import getLocalStorageApi from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
import { useListUsers } from 'api/generated/services/users';
import getAll from 'api/v1/user/get';
import { FeatureKeys } from 'constants/features';
import { LOCALSTORAGE } from 'constants/localStorage';
import { ORG_PREFERENCES } from 'constants/orgPreferences';
@@ -11,9 +12,12 @@ import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import history from 'lib/history';
import { isEmpty } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
import { SuccessResponseV2 } from 'types/api';
import APIError from 'types/api/error';
import { LicensePlatform, LicenseState } from 'types/api/licensesV3/getActive';
import { OrgPreference } from 'types/api/preferences/preference';
import { Organization } from 'types/api/user/getOrganization';
import { UserResponse } from 'types/api/user/getUser';
import { USER_ROLES } from 'types/roles';
import { routePermission } from 'utils/permission';
@@ -59,10 +63,18 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
const [orgData, setOrgData] = useState<Organization | undefined>(undefined);
const { data: usersData, isFetching: isFetchingUsers } = useListUsers({
query: {
enabled: !isEmpty(orgData) && user.role === 'ADMIN',
const { data: usersData, isFetching: isFetchingUsers } = useQuery<
SuccessResponseV2<UserResponse[]> | undefined,
APIError
>({
queryFn: () => {
if (orgData && orgData.id !== undefined) {
return getAll();
}
return undefined;
},
queryKey: ['getOrgUser'],
enabled: !isEmpty(orgData) && user.role === 'ADMIN',
});
const checkFirstTimeUser = useCallback((): boolean => {

View File

@@ -0,0 +1,26 @@
import { ApiV2Instance as axios } from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/user/editOrg';
const editOrg = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const response = await axios.put(`/orgs/me`, {
displayName: props.displayName,
});
return {
statusCode: 204,
error: null,
message: response.data.status,
payload: response.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default editOrg;

View File

@@ -0,0 +1,28 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps } from 'types/api/user/getOrganization';
const getOrganization = async (
token?: string,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const response = await axios.get(`/org`, {
headers: {
Authorization: `bearer ${token}`,
},
});
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default getOrganization;

View File

@@ -0,0 +1,21 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { UserResponse } from 'types/api/user/getUser';
import { PayloadProps } from 'types/api/user/getUsers';
const getAll = async (): Promise<SuccessResponseV2<UserResponse[]>> => {
try {
const response = await axios.get<PayloadProps>(`/user`);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default getAll;

View File

@@ -0,0 +1,22 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props, UserResponse } from 'types/api/user/getUser';
const getUser = async (
props: Props,
): Promise<SuccessResponseV2<UserResponse>> => {
try {
const response = await axios.get<PayloadProps>(`/user/${props.userId}`);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default getUser;

View File

@@ -0,0 +1,23 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { Props } from 'types/api/user/editUser';
const update = async (props: Props): Promise<SuccessResponseV2<null>> => {
try {
const response = await axios.put(`/user/${props.userId}`, {
displayName: props.displayName,
role: props.role,
});
return {
httpStatusCode: response.status,
data: null,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default update;

View File

@@ -0,0 +1,20 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, UserResponse } from 'types/api/user/getUser';
const get = async (): Promise<SuccessResponseV2<UserResponse>> => {
try {
const response = await axios.get<PayloadProps>(`/user/me`);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default get;

View File

@@ -4,7 +4,9 @@ import { Table, Tooltip } from 'antd';
import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { MemberStatus } from 'container/MembersSettings/utils';
import { capitalize } from 'lodash-es';
import { useTimezone } from 'providers/Timezone';
import { ROLES } from 'types/roles';
import './MembersTable.styles.scss';
@@ -12,6 +14,7 @@ export interface MemberRow {
id: string;
name?: string;
email: string;
role: ROLES;
status: MemberStatus;
joinedOn: string | null;
updatedAt?: string | null;
@@ -138,6 +141,17 @@ function MembersTable({
<NameEmailCell name={record.name} email={record.email} />
),
},
{
title: 'Roles',
dataIndex: 'role',
key: 'role',
width: 180,
sorter: (a, b): number => a.role.localeCompare(b.role),
render: (role: ROLES): JSX.Element => (
<Badge color="vanilla">{capitalize(role)}</Badge>
),
},
{
title: 'Status',
dataIndex: 'status',

View File

@@ -1,11 +1,12 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useQuery } from 'react-query';
import { useHistory } from 'react-router-dom';
import { Button } from '@signozhq/button';
import { Check, ChevronDown, Plus } from '@signozhq/icons';
import { Input } from '@signozhq/input';
import type { MenuProps } from 'antd';
import { Dropdown } from 'antd';
import { useListUsers } from 'api/generated/services/users';
import getAll from 'api/v1/user/get';
import EditMemberDrawer from 'components/EditMemberDrawer/EditMemberDrawer';
import InviteMembersModal from 'components/InviteMembersModal/InviteMembersModal';
import MembersTable, { MemberRow } from 'components/MembersTable/MembersTable';
@@ -33,8 +34,9 @@ function MembersSettings(): JSX.Element {
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false);
const [selectedMember, setSelectedMember] = useState<MemberRow | null>(null);
const { data: usersData, isLoading, refetch: refetchUsers } = useListUsers({
query: { queryKey: ['getOrgUser', org?.[0]?.id] },
const { data: usersData, isLoading, refetch: refetchUsers } = useQuery({
queryFn: getAll,
queryKey: ['getOrgUser', org?.[0]?.id],
});
const allMembers = useMemo(
@@ -42,7 +44,8 @@ function MembersSettings(): JSX.Element {
(usersData?.data ?? []).map((user) => ({
id: user.id,
name: user.displayName,
email: user.email ?? '',
email: user.email,
role: user.role,
status: toMemberStatus(user.status ?? ''),
joinedOn: toISOString(user.createdAt),
updatedAt: toISOString(user?.updatedAt),
@@ -61,7 +64,9 @@ function MembersSettings(): JSX.Element {
const q = searchQuery.toLowerCase();
result = result.filter(
(m) =>
m?.name?.toLowerCase().includes(q) || m.email.toLowerCase().includes(q),
m?.name?.toLowerCase().includes(q) ||
m.email.toLowerCase().includes(q) ||
m.role.toLowerCase().includes(q),
);
}
@@ -176,7 +181,7 @@ function MembersSettings(): JSX.Element {
<div className="members-settings__search">
<Input
type="search"
placeholder="Search by name or email..."
placeholder="Search by name, email, or role..."
value={searchQuery}
onChange={(e): void => {
setSearchQuery(e.target.value);

View File

@@ -2,8 +2,8 @@ import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Input, Modal, Typography } from 'antd';
import logEvent from 'api/common/logEvent';
import { useUpdateMyUserV2 } from 'api/generated/services/users';
import changeMyPassword from 'api/v1/factor_password/changeMyPassword';
import editUser from 'api/v1/user/id/update';
import { useNotifications } from 'hooks/useNotifications';
import { Check, FileTerminal, MailIcon, UserIcon } from 'lucide-react';
import { useAppContext } from 'providers/App/App';
@@ -17,7 +17,6 @@ function UserInfo(): JSX.Element {
const { t } = useTranslation(['routes', 'settings', 'common']);
const { notifications } = useNotifications();
const { mutateAsync: updateMyUser } = useUpdateMyUserV2();
const [currentPassword, setCurrentPassword] = useState<string>('');
const [updatePassword, setUpdatePassword] = useState<string>('');
@@ -93,7 +92,10 @@ function UserInfo(): JSX.Element {
);
try {
setIsLoading(true);
await updateMyUser({ data: { displayName: changedName } });
await editUser({
displayName: changedName,
userId: user.id,
});
notifications.success({
message: t('success', {

View File

@@ -1,10 +1,8 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from '@signozhq/sonner';
import { Button, Form, Input } from 'antd';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { useUpdateMyOrganization } from 'api/generated/services/orgs';
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import editOrg from 'api/organization/editOrg';
import { useNotifications } from 'hooks/useNotifications';
import { useAppContext } from 'providers/App/App';
import { IUser } from 'providers/App/types';
import { requireErrorMessage } from 'utils/form/requireErrorMessage';
@@ -16,34 +14,42 @@ function DisplayName({ index, id: orgId }: DisplayNameProps): JSX.Element {
const { t } = useTranslation(['organizationsettings', 'common']);
const { org, updateOrg } = useAppContext();
const { displayName } = (org || [])[index];
const {
mutateAsync: updateMyOrganization,
isLoading,
} = useUpdateMyOrganization({
mutation: {
onSuccess: (_, { data }) => {
toast.success(t('success', { ns: 'common' }), {
richColors: true,
position: 'top-right',
});
updateOrg(orgId, data.displayName ?? '');
},
onError: (error) => {
const apiError = convertToApiError(
error as AxiosError<RenderErrorResponseDTO>,
);
toast.error(
apiError?.getErrorMessage() ?? t('something_went_wrong', { ns: 'common' }),
{ richColors: true, position: 'top-right' },
);
},
},
});
const [isLoading, setIsLoading] = useState<boolean>(false);
const { notifications } = useNotifications();
const onSubmit = async (values: FormValues): Promise<void> => {
const { displayName } = values;
await updateMyOrganization({ data: { id: orgId, displayName } });
try {
setIsLoading(true);
const { displayName } = values;
const { statusCode, error } = await editOrg({
displayName,
orgId,
});
if (statusCode === 204) {
notifications.success({
message: t('success', {
ns: 'common',
}),
});
updateOrg(orgId, displayName);
} else {
notifications.error({
message:
error ||
t('something_went_wrong', {
ns: 'common',
}),
});
}
setIsLoading(false);
} catch (error) {
setIsLoading(false);
notifications.error({
message: t('something_went_wrong', {
ns: 'common',
}),
});
}
};
if (!org) {

View File

@@ -0,0 +1,15 @@
import { useQuery, UseQueryResult } from 'react-query';
import getUser from 'api/v1/user/id/get';
import { SuccessResponseV2 } from 'types/api';
import { UserResponse } from 'types/api/user/getUser';
const useGetUser = (userId: string, isLoggedIn: boolean): UseGetUser =>
useQuery({
queryFn: () => getUser({ userId }),
queryKey: [userId],
enabled: !!userId && !!isLoggedIn,
});
type UseGetUser = UseQueryResult<SuccessResponseV2<UserResponse>, unknown>;
export default useGetUser;

View File

@@ -12,9 +12,8 @@ import {
import { useQuery } from 'react-query';
import getLocalStorageApi from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
import { useGetMyOrganization } from 'api/generated/services/orgs';
import { useGetMyUser } from 'api/generated/services/users';
import listOrgPreferences from 'api/v1/org/preferences/list';
import get from 'api/v1/user/me/get';
import listUserPreferences from 'api/v1/user/preferences/list';
import getUserVersion from 'api/v1/version/get';
import { LOCALSTORAGE } from 'constants/localStorage';
@@ -41,9 +40,7 @@ import {
UserPreference,
} from 'types/api/preferences/preference';
import { Organization } from 'types/api/user/getOrganization';
import { UserResponse } from 'types/api/user/getUser';
import { ROLES, USER_ROLES } from 'types/roles';
import { toISOString } from 'utils/app';
import { IAppContext, IUser } from './types';
import { getUserDefaults } from './utils';
@@ -74,19 +71,17 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
const [showChangelogModal, setShowChangelogModal] = useState<boolean>(false);
// fetcher for current user
// fetcher for user
// user will only be fetched if the user id and token is present
// if logged out and trying to hit any route none of these calls will trigger
const {
data: userData,
isFetching: isFetchingUserData,
error: userFetchDataError,
} = useGetMyUser({
query: { enabled: isLoggedIn },
});
const { data: orgData, isFetching: isFetchingOrgData } = useGetMyOrganization({
query: { enabled: isLoggedIn },
} = useQuery({
queryFn: get,
queryKey: ['/api/v1/user/me'],
enabled: isLoggedIn,
});
const {
@@ -98,8 +93,7 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
enabled: isLoggedIn,
});
const isFetchingUser =
isFetchingUserData || isFetchingOrgData || isFetchingPermissions;
const isFetchingUser = isFetchingUserData || isFetchingPermissions;
const userFetchError = userFetchDataError || errorOnPermissions;
const userRole = useMemo(() => {
@@ -124,47 +118,38 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
}, [defaultUser, userRole]);
useEffect(() => {
if (!isFetchingUserData && userData?.data) {
setLocalStorageApi(
LOCALSTORAGE.LOGGED_IN_USER_EMAIL,
userData.data.email ?? '',
);
if (!isFetchingUser && userData && userData.data) {
setLocalStorageApi(LOCALSTORAGE.LOGGED_IN_USER_EMAIL, userData.data.email);
setDefaultUser((prev) => ({
...prev,
id: userData.data.id,
displayName: userData.data.displayName ?? prev.displayName,
email: userData.data.email ?? prev.email,
orgId: userData.data.orgId ?? prev.orgId,
isRoot: userData.data.isRoot,
status: userData.data.status as UserResponse['status'],
createdAt: toISOString(userData.data.createdAt) ?? prev.createdAt,
updatedAt: toISOString(userData.data.updatedAt) ?? prev.updatedAt,
...userData.data,
}));
}
}, [userData, isFetchingUserData]);
useEffect(() => {
if (!isFetchingOrgData && orgData?.data) {
const { id: orgId, displayName: orgDisplayName } = orgData.data;
setOrg((prev) => {
if (!prev) {
return [{ createdAt: 0, id: orgId, displayName: orgDisplayName ?? '' }];
// if no org is present enter a new entry
return [
{
createdAt: 0,
id: userData.data.orgId,
displayName: userData.data.organization,
},
];
}
const orgIndex = prev.findIndex((e) => e.id === orgId);
// else mutate the existing entry
const orgIndex = prev.findIndex((e) => e.id === userData.data.orgId);
const updatedOrg: Organization[] = [
...prev.slice(0, orgIndex),
{ createdAt: 0, id: orgId, displayName: orgDisplayName ?? '' },
{
createdAt: 0,
id: userData.data.orgId,
displayName: userData.data.organization,
},
...prev.slice(orgIndex + 1, prev.length),
];
return updatedOrg;
});
setDefaultUser((prev) => ({
...prev,
organization: orgDisplayName ?? prev.organization,
}));
}
}, [orgData, isFetchingOrgData]);
}, [userData, isFetchingUser]);
// fetcher for licenses v3
const {

35
go.mod
View File

@@ -1,6 +1,6 @@
module github.com/SigNoz/signoz
go 1.25.0
go 1.25.7
require (
dario.cat/mergo v1.0.2
@@ -27,8 +27,8 @@ require (
github.com/gorilla/handlers v1.5.1
github.com/gorilla/mux v1.8.1
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
github.com/huandu/go-sqlbuilder v1.35.0
github.com/jackc/pgx/v5 v5.7.6
github.com/huandu/go-sqlbuilder v1.39.1
github.com/jackc/pgx/v5 v5.8.0
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12
github.com/knadh/koanf v1.5.0
github.com/knadh/koanf/v2 v2.3.2
@@ -38,6 +38,7 @@ require (
github.com/openfga/api/proto v0.0.0-20250909172242-b4b2a12f5c67
github.com/openfga/language/pkg/go v0.2.0-beta.2.0.20251027165255-0f8f255e5f6c
github.com/opentracing/opentracing-go v1.2.0
github.com/perses/perses v0.53.1
github.com/pkg/errors v0.9.1
github.com/prometheus/alertmanager v0.31.0
github.com/prometheus/client_golang v1.23.2
@@ -75,18 +76,18 @@ require (
go.opentelemetry.io/otel/trace v1.40.0
go.uber.org/multierr v1.11.0
go.uber.org/zap v1.27.1
golang.org/x/crypto v0.47.0
golang.org/x/crypto v0.48.0
golang.org/x/exp v0.0.0-20260112195511-716be5621a96
golang.org/x/net v0.49.0
golang.org/x/oauth2 v0.34.0
golang.org/x/net v0.50.0
golang.org/x/oauth2 v0.35.0
golang.org/x/sync v0.19.0
golang.org/x/text v0.33.0
golang.org/x/text v0.34.0
gonum.org/v1/gonum v0.17.0
google.golang.org/api v0.265.0
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v2 v2.4.0
gopkg.in/yaml.v3 v3.0.1
k8s.io/apimachinery v0.35.0
k8s.io/apimachinery v0.35.2
modernc.org/sqlite v1.40.1
)
@@ -128,9 +129,12 @@ require (
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/hashicorp/go-metrics v0.5.4 // indirect
github.com/huandu/go-clone v1.7.3 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/muhlemmer/gu v0.3.1 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/perses/common v0.30.2 // indirect
github.com/prometheus/client_golang/exp v0.0.0-20260108101519-fb0838f53562 // indirect
github.com/redis/go-redis/extra/rediscmd/v9 v9.15.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
@@ -139,6 +143,8 @@ require (
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
github.com/uptrace/opentelemetry-go-extra/otelsql v0.3.2 // indirect
github.com/zitadel/oidc/v3 v3.45.4 // indirect
github.com/zitadel/schema v1.3.2 // indirect
go.opentelemetry.io/collector/client v1.50.0 // indirect
go.opentelemetry.io/collector/config/configoptional v1.50.0 // indirect
go.opentelemetry.io/collector/config/configretry v1.50.0 // indirect
@@ -208,7 +214,7 @@ require (
github.com/golang/protobuf v1.5.4 // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/google/btree v1.1.3 // indirect
github.com/google/cel-go v0.26.1 // indirect
github.com/google/cel-go v0.27.0 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect
github.com/googleapis/gax-go/v2 v2.16.0 // indirect
@@ -226,7 +232,7 @@ require (
github.com/hashicorp/golang-lru v1.0.2 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/hashicorp/memberlist v0.5.4 // indirect
github.com/huandu/xstrings v1.4.0 // indirect
github.com/huandu/xstrings v1.5.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
@@ -298,7 +304,6 @@ require (
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/spf13/viper v1.20.1 // indirect
github.com/stoewer/go-strcase v1.3.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/swaggest/openapi-go v0.2.60
@@ -376,15 +381,15 @@ require (
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/mock v0.6.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/mod v0.32.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.41.0 // indirect
golang.org/x/tools v0.42.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409
google.golang.org/grpc v1.78.0 // indirect
gopkg.in/telebot.v3 v3.3.8 // indirect
k8s.io/client-go v0.35.0 // indirect
k8s.io/client-go v0.35.2 // indirect
k8s.io/klog/v2 v2.130.1 // indirect
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect
)

81
go.sum
View File

@@ -489,8 +489,8 @@ github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Z
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/cel-go v0.26.1 h1:iPbVVEdkhTX++hpe3lzSk7D3G3QSYqLGoHOcEio+UXQ=
github.com/google/cel-go v0.26.1/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM=
github.com/google/cel-go v0.27.0 h1:e7ih85+4qVrBuqQWTW4FKSqZYokVuc3HnhH5keboFTo=
github.com/google/cel-go v0.27.0/go.mod h1:tTJ11FWqnhw5KKpnWpvW9CJC3Y9GK4EIS0WXnBbebzw=
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
@@ -654,12 +654,15 @@ github.com/hetznercloud/hcloud-go/v2 v2.36.0 h1:HlLL/aaVXUulqe+rsjoJmrxKhPi1MflL
github.com/hetznercloud/hcloud-go/v2 v2.36.0/go.mod h1:MnN/QJEa/RYNQiiVoJjNHPntM7Z1wlYPgJ2HA40/cDE=
github.com/hjson/hjson-go/v4 v4.0.0 h1:wlm6IYYqHjOdXH1gHev4VoXCaW20HdQAGCxdOEEg2cs=
github.com/hjson/hjson-go/v4 v4.0.0/go.mod h1:KaYt3bTw3zhBjYqnXkYywcYctk0A2nxeEFTse3rH13E=
github.com/huandu/go-assert v1.1.5/go.mod h1:yOLvuqZwmcHIC5rIzrBhT7D3Q9c3GFnd0JrPVhn/06U=
github.com/huandu/go-assert v1.1.6 h1:oaAfYxq9KNDi9qswn/6aE0EydfxSa+tWZC1KabNitYs=
github.com/huandu/go-assert v1.1.6/go.mod h1:JuIfbmYG9ykwvuxoJ3V8TB5QP+3+ajIA54Y44TmkMxs=
github.com/huandu/go-sqlbuilder v1.35.0 h1:ESvxFHN8vxCTudY1Vq63zYpU5yJBESn19sf6k4v2T5Q=
github.com/huandu/go-sqlbuilder v1.35.0/go.mod h1:mS0GAtrtW+XL6nM2/gXHRJax2RwSW1TraavWDFAc1JA=
github.com/huandu/xstrings v1.4.0 h1:D17IlohoQq4UcpqD7fDk80P7l+lwAmlFaBHgOipl2FU=
github.com/huandu/xstrings v1.4.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/huandu/go-clone v1.7.3 h1:rtQODA+ABThEn6J5LBTppJfKmZy/FwfpMUWa8d01TTQ=
github.com/huandu/go-clone v1.7.3/go.mod h1:ReGivhG6op3GYr+UY3lS6mxjKp7MIGTknuU5TbTVaXE=
github.com/huandu/go-sqlbuilder v1.39.1 h1:uUaj41yLNTQBe7ojNF6Im1RPbHCN4zCjMRySTEC2ooI=
github.com/huandu/go-sqlbuilder v1.39.1/go.mod h1:zdONH67liL+/TvoUMwnZP/sUYGSSvHh9psLe/HpXn8E=
github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc=
github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
@@ -672,8 +675,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4=
@@ -818,6 +821,8 @@ github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjY
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
github.com/muhlemmer/gu v0.3.1 h1:7EAqmFrW7n3hETvuAdmFmn4hS8W+z3LgKtrnow+YzNM=
github.com/muhlemmer/gu v0.3.1/go.mod h1:YHtHR+gxM+bKEIIs7Hmi9sPT3ZDUvTN/i88wQpZkrdM=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
@@ -827,9 +832,11 @@ github.com/natefinch/wrap v0.2.0 h1:IXzc/pw5KqxJv55gV0lSOcKHYuEZPGbQrOOXr/bamRk=
github.com/natefinch/wrap v0.2.0/go.mod h1:6gMHlAl12DwYEfKP3TkuykYUfLSEAvHw67itm4/KAS8=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nexucis/lamenv v0.5.2 h1:tK/u3XGhCq9qIoVNcXsK9LZb8fKopm0A5weqSRvHd7M=
github.com/nexucis/lamenv v0.5.2/go.mod h1:HusJm6ltmmT7FMG8A750mOLuME6SHCsr2iFYxp5fFi0=
github.com/npillmayer/nestext v0.1.3/go.mod h1:h2lrijH8jpicr25dFY+oAJLyzlya6jhnuG+zWp9L0Uk=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY=
github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc=
github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA=
github.com/oklog/run v1.2.0 h1:O8x3yXwah4A73hJdlrwo/2X6J62gE5qTMusH0dvz60E=
github.com/oklog/run v1.2.0/go.mod h1:mgDbKRSwPhJfesJ4PntqFUbKQRZ50NgmZTSPlFA0YFk=
@@ -891,6 +898,10 @@ github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCko
github.com/pelletier/go-toml/v2 v2.0.5/go.mod h1:OMHamSCAODeSsVrwwvcJOaoN0LIUIaFVNZzmWyNfXas=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/perses/common v0.30.2 h1:RAiVxUpX76lTCb4X7pfcXSvYdXQmZwKi4oDKAEO//u0=
github.com/perses/common v0.30.2/go.mod h1:DFtur1QPah2/ChXbKKhw7djYdwNgz27s5fPKpiK0Xao=
github.com/perses/perses v0.53.1 h1:9VY/6p9QWrZwPSV7qiwTMSOsgcB37Lb1AXKT0ORXc6I=
github.com/perses/perses v0.53.1/go.mod h1:ro8fsgBkHYOdrL/MV+fdP9mflKzYCy/+gcbxiaReI/A=
github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
github.com/pierrec/lz4/v4 v4.1.23 h1:oJE7T90aYBGtFNrI8+KbETnPymobAhzRrR8Mu8n1yfU=
github.com/pierrec/lz4/v4 v4.1.23/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
@@ -1049,8 +1060,6 @@ github.com/srikanthccv/ClickHouse-go-mock v0.13.0 h1:/b7DQphGkh29ocNtLh4DGmQxQYA
github.com/srikanthccv/ClickHouse-go-mock v0.13.0/go.mod h1:LiiyBUdXNwB/1DE9rgK/8q9qjVYsTzg6WXQ/3mU3TeY=
github.com/stackitcloud/stackit-sdk-go/core v0.21.1 h1:Y/PcAgM7DPYMNqum0MLv4n1mF9ieuevzcCIZYQfm3Ts=
github.com/stackitcloud/stackit-sdk-go/core v0.21.1/go.mod h1:osMglDby4csGZ5sIfhNyYq1bS1TxIdPY88+skE/kkmI=
github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs=
github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.3.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
@@ -1150,6 +1159,10 @@ github.com/zeebo/assert v1.3.1 h1:vukIABvugfNMZMQO1ABsyQDJDTVQbn+LWSMy1ol1h6A=
github.com/zeebo/assert v1.3.1/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
github.com/zitadel/oidc/v3 v3.45.4 h1:GKyWaPRVQ8sCu9XgJ3NgNGtG52FzwVJpzXjIUG2+YrI=
github.com/zitadel/oidc/v3 v3.45.4/go.mod h1:XALmFXS9/kSom9B6uWin1yJ2WTI/E4Ti5aXJdewAVEs=
github.com/zitadel/schema v1.3.2 h1:gfJvt7dOMfTmxzhscZ9KkapKo3Nei3B6cAxjav+lyjI=
github.com/zitadel/schema v1.3.2/go.mod h1:IZmdfF9Wu62Zu6tJJTH3UsArevs3Y4smfJIj3L8fzxw=
go.etcd.io/etcd/api/v3 v3.5.4/go.mod h1:5GB2vv4A4AOn3yk7MftYGHkUfGtDHnEraIjym4dYz5A=
go.etcd.io/etcd/client/pkg/v3 v3.5.4/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g=
go.etcd.io/etcd/client/v2 v2.305.4/go.mod h1:Ud+VUwIi9/uQHOMA+4ekToJ12lTxlv0zB/+DHwTGEbU=
@@ -1385,8 +1398,8 @@ golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm
golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -1424,8 +1437,8 @@ golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -1474,8 +1487,8 @@ golang.org/x/net v0.0.0-20220325170049-de3da57026de/go.mod h1:CfG3xpIq0wQ8r1q4Su
golang.org/x/net v0.0.0-20220412020605-290c469a71a5/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220520000938-2e3eb7b945c2/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -1496,8 +1509,8 @@ golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ
golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -1598,12 +1611,12 @@ golang.org/x/sys v0.0.0-20220502124256-b6088ccd6cba/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -1614,8 +1627,8 @@ golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -1678,8 +1691,8 @@ golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/tools/godoc v0.1.0-deprecated h1:o+aZ1BOj6Hsx/GBdJO/s815sqftjSnrZZwyYTHODvtk=
golang.org/x/tools/godoc v0.1.0-deprecated/go.mod h1:qM63CriJ961IHWmnWa9CjZnBndniPt4a3CK0PVB9bIg=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -1915,12 +1928,12 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
k8s.io/api v0.35.0 h1:iBAU5LTyBI9vw3L5glmat1njFK34srdLmktWwLTprlY=
k8s.io/api v0.35.0/go.mod h1:AQ0SNTzm4ZAczM03QH42c7l3bih1TbAXYo0DkF8ktnA=
k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8=
k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns=
k8s.io/client-go v0.35.0 h1:IAW0ifFbfQQwQmga0UdoH0yvdqrbwMdq9vIFEhRpxBE=
k8s.io/client-go v0.35.0/go.mod h1:q2E5AAyqcbeLGPdoRB+Nxe3KYTfPce1Dnu1myQdqz9o=
k8s.io/api v0.35.2 h1:tW7mWc2RpxW7HS4CoRXhtYHSzme1PN1UjGHJ1bdrtdw=
k8s.io/api v0.35.2/go.mod h1:7AJfqGoAZcwSFhOjcGM7WV05QxMMgUaChNfLTXDRE60=
k8s.io/apimachinery v0.35.2 h1:NqsM/mmZA7sHW02JZ9RTtk3wInRgbVxL8MPfzSANAK8=
k8s.io/apimachinery v0.35.2/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns=
k8s.io/client-go v0.35.2 h1:YUfPefdGJA4aljDdayAXkc98DnPkIetMl4PrKX97W9o=
k8s.io/client-go v0.35.2/go.mod h1:4QqEwh4oQpeK8AaefZ0jwTFJw/9kIjdQi0jpKeYvz7g=
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE=

View File

@@ -50,6 +50,11 @@ func (handler *healthOpenAPIHandler) ServeOpenAPI(opCtx openapi.OperationContext
)
}
func (handler *healthOpenAPIHandler) AuditDef() *pkghandler.AuditDef {
// Health endpoints are not audited since they don't represent user actions and are called frequently by monitoring systems, which would create noise in the audit logs.
return nil
}
func (provider *provider) addRegistryRoutes(router *mux.Router) error {
if err := router.Handle("/api/v2/healthz", newHealthOpenAPIHandler(
provider.authZ.OpenAccess(provider.factoryHandler.Healthz),

View File

@@ -21,11 +21,15 @@ func newTestSettings() factory.ScopedProviderSettings {
func newTestEvent(resource string, action audittypes.Action) audittypes.AuditEvent {
return audittypes.AuditEvent{
Timestamp: time.Now(),
EventName: audittypes.NewEventName(resource, action),
ResourceName: resource,
Action: action,
Outcome: audittypes.OutcomeSuccess,
Timestamp: time.Now(),
EventName: audittypes.NewEventName(resource, action),
AuditAttributes: audittypes.AuditAttributes{
Action: action,
Outcome: audittypes.OutcomeSuccess,
},
ResourceAttributes: audittypes.ResourceAttributes{
ResourceName: resource,
},
}
}

View File

@@ -20,6 +20,12 @@ var (
CodeLicenseUnavailable = Code{"license_unavailable"}
)
var (
// Used when reverse engineering an error from a response that doesn't have a code.
// This should never be used in the codebase, and if it is, it's a bug that should be fixed by using proper error handling and including error codes in responses.
CodeUnset = Code{"unset"}
)
var (
codeRegex = regexp.MustCompile(`^[a-z_]+$`)
)

View File

@@ -15,14 +15,16 @@ type ServeOpenAPIFunc func(openapi.OperationContext)
type Handler interface {
http.Handler
ServeOpenAPI(openapi.OperationContext)
AuditDef() *AuditDef
}
type handler struct {
handlerFunc http.HandlerFunc
openAPIDef OpenAPIDef
auditDef *AuditDef
}
func New(handlerFunc http.HandlerFunc, openAPIDef OpenAPIDef) Handler {
func New(handlerFunc http.HandlerFunc, openAPIDef OpenAPIDef, opts ...Option) Handler {
// Remove duplicate error status codes
openAPIDef.ErrorStatusCodes = slices.DeleteFunc(openAPIDef.ErrorStatusCodes, func(statusCode int) bool {
return statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden || statusCode == http.StatusInternalServerError
@@ -36,10 +38,16 @@ func New(handlerFunc http.HandlerFunc, openAPIDef OpenAPIDef) Handler {
openAPIDef.ErrorStatusCodes = append(openAPIDef.ErrorStatusCodes, http.StatusUnauthorized, http.StatusForbidden)
}
return &handler{
handler := &handler{
handlerFunc: handlerFunc,
openAPIDef: openAPIDef,
}
for _, opt := range opts {
opt(handler)
}
return handler
}
func (handler *handler) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
@@ -120,5 +128,8 @@ func (handler *handler) ServeOpenAPI(opCtx openapi.OperationContext) {
openapi.WithHTTPStatus(statusCode),
)
}
}
func (handler *handler) AuditDef() *AuditDef {
return handler.auditDef
}

View File

@@ -0,0 +1,24 @@
package handler
import (
"github.com/SigNoz/signoz/pkg/types/audittypes"
)
// Option configures optional behaviour on a handler created by New.
type Option func(*handler)
type AuditDef struct {
ResourceName string // AuthZ Typeable.Name() value, e.g. "dashboard", "user".
Action audittypes.Action // create, update, delete, login, etc.
Category audittypes.ActionCategory // access_control, configuration_change, etc.
ResourceIDParam string // Gorilla mux path param name for the resource ID.
}
// WithAudit attaches an AuditDef to the handler. The actual audit event
// emission is handled by the middleware layer, which reads the AuditDef
// from the matched route's handler.
func WithAuditDef(def AuditDef) Option {
return func(h *handler) {
h.auditDef = &def
}
}

View File

@@ -0,0 +1,169 @@
package middleware
import (
"log/slog"
"net"
"net/http"
"time"
"github.com/gorilla/mux"
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
"go.opentelemetry.io/otel/trace"
"github.com/SigNoz/signoz/pkg/auditor"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/types/audittypes"
"github.com/SigNoz/signoz/pkg/types/authtypes"
)
const (
logMessage = "::RECEIVED-REQUEST::"
)
type Audit struct {
logger *slog.Logger
excludedRoutes map[string]struct{}
auditor auditor.Auditor
}
func NewAudit(logger *slog.Logger, excludedRoutes []string, auditor auditor.Auditor) *Audit {
excludedRoutesMap := make(map[string]struct{})
for _, route := range excludedRoutes {
excludedRoutesMap[route] = struct{}{}
}
return &Audit{
logger: logger.With(slog.String("pkg", pkgname)),
excludedRoutes: excludedRoutesMap,
auditor: auditor,
}
}
func (middleware *Audit) Wrap(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
start := time.Now()
host, port, _ := net.SplitHostPort(req.Host)
path, err := mux.CurrentRoute(req).GetPathTemplate()
if err != nil {
path = req.URL.Path
}
fields := []any{
string(semconv.ClientAddressKey), req.RemoteAddr,
string(semconv.UserAgentOriginalKey), req.UserAgent(),
string(semconv.ServerAddressKey), host,
string(semconv.ServerPortKey), port,
string(semconv.HTTPRequestSizeKey), req.ContentLength,
string(semconv.HTTPRouteKey), path,
}
responseBuffer := &byteBuffer{}
writer := newResponseCapture(rw, responseBuffer)
next.ServeHTTP(writer, req)
statusCode, writeErr := writer.StatusCode(), writer.WriteError()
// Logging or Audit: skip if the matched route is in the excluded list. This allows us to exclude noisy routes (e.g. health checks) from both logging and audit.
if _, ok := middleware.excludedRoutes[path]; ok {
return
}
middleware.emitAuditEvent(req, writer, path)
fields = append(fields,
string(semconv.HTTPResponseStatusCodeKey), statusCode,
string(semconv.HTTPServerRequestDurationName), time.Since(start),
)
if writeErr != nil {
fields = append(fields, errors.Attr(writeErr))
middleware.logger.ErrorContext(req.Context(), logMessage, fields...)
} else {
if responseBuffer.Len() != 0 {
fields = append(fields, "response.body", responseBuffer.String())
}
middleware.logger.InfoContext(req.Context(), logMessage, fields...)
}
})
}
func (middleware *Audit) emitAuditEvent(req *http.Request, writer responseCapture, routeTemplate string) {
if middleware.auditor == nil {
return
}
def := auditDefFromRequest(req)
if def == nil {
return
}
// extract claims
claims, _ := authtypes.ClaimsFromContext(req.Context())
// extract status code
statusCode := writer.StatusCode()
// extract traces.
span := trace.SpanFromContext(req.Context())
// extract error details.
var errorType, errorCode string
if statusCode >= 400 {
errorType = render.ErrorTypeFromStatusCode(statusCode)
errorCode = render.ErrorCodeFromBody(writer.BodyBytes())
}
event := audittypes.NewAuditEventFromHTTPRequest(
req,
routeTemplate,
statusCode,
span.SpanContext().TraceID(),
span.SpanContext().SpanID(),
def.Action,
def.Category,
claims,
resourceIDFromRequest(req, def.ResourceIDParam),
def.ResourceName,
errorType,
errorCode,
)
middleware.auditor.Audit(req.Context(), event)
}
func auditDefFromRequest(req *http.Request) *handler.AuditDef {
route := mux.CurrentRoute(req)
if route == nil {
return nil
}
actualHandler := route.GetHandler()
if actualHandler == nil {
return nil
}
// The type assertion is necessary because route.GetHandler() returns
// http.Handler, and not every http.Handler on the mux is a handler.Handler
// (e.g. middleware wrappers, raw http.HandlerFunc registrations).
provider, ok := actualHandler.(handler.Handler)
if !ok {
return nil
}
return provider.AuditDef()
}
func resourceIDFromRequest(req *http.Request, param string) string {
if param == "" {
return ""
}
vars := mux.Vars(req)
if vars == nil {
return ""
}
return vars[param]
}

View File

@@ -1,81 +0,0 @@
package middleware
import (
"bytes"
"log/slog"
"net"
"net/http"
"time"
"github.com/gorilla/mux"
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
"github.com/SigNoz/signoz/pkg/errors"
)
const (
logMessage string = "::RECEIVED-REQUEST::"
)
type Logging struct {
logger *slog.Logger
excludedRoutes map[string]struct{}
}
func NewLogging(logger *slog.Logger, excludedRoutes []string) *Logging {
excludedRoutesMap := make(map[string]struct{})
for _, route := range excludedRoutes {
excludedRoutesMap[route] = struct{}{}
}
return &Logging{
logger: logger.With(slog.String("pkg", pkgname)),
excludedRoutes: excludedRoutesMap,
}
}
func (middleware *Logging) Wrap(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
start := time.Now()
host, port, _ := net.SplitHostPort(req.Host)
path, err := mux.CurrentRoute(req).GetPathTemplate()
if err != nil {
path = req.URL.Path
}
fields := []any{
string(semconv.ClientAddressKey), req.RemoteAddr,
string(semconv.UserAgentOriginalKey), req.UserAgent(),
string(semconv.ServerAddressKey), host,
string(semconv.ServerPortKey), port,
string(semconv.HTTPRequestSizeKey), req.ContentLength,
string(semconv.HTTPRouteKey), path,
}
badResponseBuffer := new(bytes.Buffer)
writer := newBadResponseLoggingWriter(rw, badResponseBuffer)
next.ServeHTTP(writer, req)
// if the path is in the excludedRoutes map, don't log
if _, ok := middleware.excludedRoutes[path]; ok {
return
}
statusCode, err := writer.StatusCode(), writer.WriteError()
fields = append(fields,
string(semconv.HTTPResponseStatusCodeKey), statusCode,
string(semconv.HTTPServerRequestDurationName), time.Since(start),
)
if err != nil {
fields = append(fields, errors.Attr(err))
middleware.logger.ErrorContext(req.Context(), logMessage, fields...)
} else {
// when the status code is 400 or >=500, and the response body is not empty.
if badResponseBuffer.Len() != 0 {
fields = append(fields, "response.body", badResponseBuffer.String())
}
middleware.logger.InfoContext(req.Context(), logMessage, fields...)
}
})
}

View File

@@ -2,7 +2,6 @@ package middleware
import (
"bufio"
"io"
"net"
"net/http"
@@ -10,118 +9,156 @@ import (
)
const (
maxResponseBodyInLogs = 4096 // At most 4k bytes from response bodies in our logs.
maxResponseBodyCapture int = 4096 // At most 4k bytes from response bodies.
)
type badResponseLoggingWriter interface {
// Wraps an http.ResponseWriter to capture the status code,
// write errors, and (for error responses) a bounded slice of the body.
type responseCapture interface {
http.ResponseWriter
// Get the status code.
// StatusCode returns the HTTP status code written to the response.
StatusCode() int
// Get the error while writing.
// WriteError returns the error (if any) from the downstream Write call.
WriteError() error
// BodyBytes returns the captured response body bytes. Only populated
// for error responses (status >= 400).
BodyBytes() []byte
}
func newBadResponseLoggingWriter(rw http.ResponseWriter, buffer io.Writer) badResponseLoggingWriter {
b := nonFlushingBadResponseLoggingWriter{
func newResponseCapture(rw http.ResponseWriter, buffer *byteBuffer) responseCapture {
b := nonFlushingResponseCapture{
rw: rw,
buffer: buffer,
logBody: false,
bodyBytesLeft: maxResponseBodyInLogs,
captureBody: false,
bodyBytesLeft: maxResponseBodyCapture,
statusCode: http.StatusOK,
}
if f, ok := rw.(http.Flusher); ok {
return &flushingBadResponseLoggingWriter{b, f}
return &flushingResponseCapture{nonFlushingResponseCapture: b, f: f}
}
return &b
}
type nonFlushingBadResponseLoggingWriter struct {
rw http.ResponseWriter
buffer io.Writer
logBody bool
bodyBytesLeft int
statusCode int
writeError error // The error returned when downstream Write() fails.
// byteBuffer is a minimal write-only buffer used to capture response bodies.
type byteBuffer struct {
buf []byte
}
// Extends nonFlushingBadResponseLoggingWriter that implements http.Flusher.
type flushingBadResponseLoggingWriter struct {
nonFlushingBadResponseLoggingWriter
func (b *byteBuffer) Write(p []byte) (int, error) {
b.buf = append(b.buf, p...)
return len(p), nil
}
func (b *byteBuffer) WriteString(s string) (int, error) {
b.buf = append(b.buf, s...)
return len(s), nil
}
func (b *byteBuffer) Bytes() []byte {
return b.buf
}
func (b *byteBuffer) Len() int {
return len(b.buf)
}
func (b *byteBuffer) String() string {
return string(b.buf)
}
type nonFlushingResponseCapture struct {
rw http.ResponseWriter
buffer *byteBuffer
captureBody bool
bodyBytesLeft int
statusCode int
writeError error
}
type flushingResponseCapture struct {
nonFlushingResponseCapture
f http.Flusher
}
// Unwrap method is used by http.ResponseController to get access to original http.ResponseWriter.
func (writer *nonFlushingBadResponseLoggingWriter) Unwrap() http.ResponseWriter {
// Unwrap is used by http.ResponseController to get access to original http.ResponseWriter.
func (writer *nonFlushingResponseCapture) Unwrap() http.ResponseWriter {
return writer.rw
}
// Header returns the header map that will be sent by WriteHeader.
// Implements ResponseWriter.
func (writer *nonFlushingBadResponseLoggingWriter) Header() http.Header {
func (writer *nonFlushingResponseCapture) Header() http.Header {
return writer.rw.Header()
}
// WriteHeader writes the HTTP response header.
func (writer *nonFlushingBadResponseLoggingWriter) WriteHeader(statusCode int) {
func (writer *nonFlushingResponseCapture) WriteHeader(statusCode int) {
writer.statusCode = statusCode
if statusCode >= 500 || statusCode == 400 {
writer.logBody = true
if statusCode >= 400 {
writer.captureBody = true
}
writer.rw.WriteHeader(statusCode)
}
// Writes HTTP response data.
func (writer *nonFlushingBadResponseLoggingWriter) Write(data []byte) (int, error) {
// Write writes HTTP response data.
func (writer *nonFlushingResponseCapture) Write(data []byte) (int, error) {
if writer.statusCode == 0 {
// WriteHeader has (probably) not been called, so we need to call it with StatusOK to fulfill the interface contract.
// https://godoc.org/net/http#ResponseWriter
writer.WriteHeader(http.StatusOK)
}
// 204 No Content is a success response that indicates that the request has been successfully processed and that the response body is intentionally empty.
if writer.statusCode == 204 {
return 0, nil
}
n, err := writer.rw.Write(data)
if writer.logBody {
if writer.captureBody {
writer.captureResponseBody(data)
}
if err != nil {
writer.writeError = err
}
return n, err
}
// Hijack hijacks the first response writer that is a Hijacker.
func (writer *nonFlushingBadResponseLoggingWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
func (writer *nonFlushingResponseCapture) Hijack() (net.Conn, *bufio.ReadWriter, error) {
hj, ok := writer.rw.(http.Hijacker)
if ok {
return hj.Hijack()
}
return nil, nil, errors.NewInternalf(errors.CodeInternal, "cannot cast underlying response writer to Hijacker")
}
func (writer *nonFlushingBadResponseLoggingWriter) StatusCode() int {
func (writer *nonFlushingResponseCapture) StatusCode() int {
return writer.statusCode
}
func (writer *nonFlushingBadResponseLoggingWriter) WriteError() error {
func (writer *nonFlushingResponseCapture) WriteError() error {
return writer.writeError
}
func (writer *flushingBadResponseLoggingWriter) Flush() {
func (writer *nonFlushingResponseCapture) BodyBytes() []byte {
return writer.buffer.Bytes()
}
func (writer *flushingResponseCapture) Flush() {
writer.f.Flush()
}
func (writer *nonFlushingBadResponseLoggingWriter) captureResponseBody(data []byte) {
func (writer *nonFlushingResponseCapture) captureResponseBody(data []byte) {
if len(data) > writer.bodyBytesLeft {
_, _ = writer.buffer.Write(data[:writer.bodyBytesLeft])
_, _ = io.WriteString(writer.buffer, "...")
_, _ = writer.buffer.WriteString("...")
writer.bodyBytesLeft = 0
writer.logBody = false
writer.captureBody = false
} else {
_, _ = writer.buffer.Write(data)
writer.bodyBytesLeft -= len(data)

View File

@@ -0,0 +1,88 @@
package middleware
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestResponseCapture(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
handler http.HandlerFunc
expectedStatus int
expectedBodyBytes string
expectedClientBody string
}{
{
name: "Success_DoesNotCaptureBody",
handler: func(rw http.ResponseWriter, req *http.Request) {
rw.WriteHeader(http.StatusOK)
_, _ = rw.Write([]byte(`{"status":"success","data":{"id":"123"}}`))
},
expectedStatus: http.StatusOK,
expectedBodyBytes: "",
expectedClientBody: `{"status":"success","data":{"id":"123"}}`,
},
{
name: "Error_CapturesBody",
handler: func(rw http.ResponseWriter, req *http.Request) {
rw.WriteHeader(http.StatusForbidden)
_, _ = rw.Write([]byte(`{"status":"error","error":{"code":"authz_forbidden","message":"forbidden"}}`))
},
expectedStatus: http.StatusForbidden,
expectedBodyBytes: `{"status":"error","error":{"code":"authz_forbidden","message":"forbidden"}}`,
expectedClientBody: `{"status":"error","error":{"code":"authz_forbidden","message":"forbidden"}}`,
},
{
name: "Error_TruncatesAtMaxCapture",
handler: func(rw http.ResponseWriter, req *http.Request) {
rw.WriteHeader(http.StatusInternalServerError)
_, _ = rw.Write([]byte(strings.Repeat("x", maxResponseBodyCapture+100)))
},
expectedStatus: http.StatusInternalServerError,
expectedBodyBytes: strings.Repeat("x", maxResponseBodyCapture) + "...",
expectedClientBody: strings.Repeat("x", maxResponseBodyCapture+100),
},
{
name: "NoContent_SuppressesWrite",
handler: func(rw http.ResponseWriter, req *http.Request) {
rw.WriteHeader(http.StatusNoContent)
_, _ = rw.Write([]byte("should be suppressed"))
},
expectedStatus: http.StatusNoContent,
expectedBodyBytes: "",
expectedClientBody: "",
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()
var captured responseCapture
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
buf := &byteBuffer{}
captured = newResponseCapture(rw, buf)
testCase.handler(captured, req)
}))
defer server.Close()
resp, err := http.Get(server.URL)
assert.NoError(t, err)
defer resp.Body.Close()
clientBody, _ := io.ReadAll(resp.Body)
assert.Equal(t, testCase.expectedStatus, captured.StatusCode())
assert.Equal(t, testCase.expectedBodyBytes, string(captured.BodyBytes()))
assert.Equal(t, testCase.expectedClientBody, string(clientBody))
})
}
}

View File

@@ -5,6 +5,7 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
jsoniter "github.com/json-iterator/go"
"github.com/tidwall/gjson"
)
const (
@@ -42,6 +43,45 @@ func Success(rw http.ResponseWriter, httpCode int, data interface{}) {
_, _ = rw.Write(body)
}
func ErrorCodeFromBody(body []byte) string {
code := gjson.GetBytes(body, "error.code").String()
// This should never return empty since we only call this function on responses that were generated by us.
// If it does return empty, the codebase has failed to use render package for error responses somewhere, and we should fix that instead of trying to handle it here.
if code == "" {
return errors.CodeUnset.String()
}
return code
}
func ErrorTypeFromStatusCode(statusCode int) string {
// We are losing the exact type information here, but we can at least capture the error code and message for better observability.
// To get the exact type, we would need some changes in the render package to include the error type in the response, which we can consider in the future if there is a need for it.
switch statusCode {
case http.StatusBadRequest:
return errors.TypeInvalidInput.String()
case http.StatusNotFound:
return errors.TypeNotFound.String()
case http.StatusConflict:
return errors.TypeAlreadyExists.String()
case http.StatusUnauthorized:
return errors.TypeUnauthenticated.String()
case http.StatusNotImplemented:
return errors.TypeUnsupported.String()
case http.StatusForbidden:
return errors.TypeForbidden.String()
case statusClientClosedConnection:
return errors.TypeCanceled.String()
case http.StatusGatewayTimeout:
return errors.TypeTimeout.String()
case http.StatusUnavailableForLegalReasons:
return errors.TypeLicenseUnavailable.String()
default:
return errors.TypeInternal.String()
}
}
func Error(rw http.ResponseWriter, cause error) {
// Derive the http code from the error type
t, _, _, _, _, _ := errors.Unwrapb(cause)

View File

@@ -58,6 +58,31 @@ func TestSuccess(t *testing.T) {
assert.Equal(t, expected, actual)
}
func TestErrorCodeFromBody(t *testing.T) {
testCases := []struct {
name string
body []byte
wantCode string
}{
{
name: "ValidErrorResponse",
body: []byte(`{"status":"error","error":{"code":"authz_forbidden","message":"only admins can access this resource"}}`),
wantCode: "authz_forbidden",
},
{
name: "InvalidJSON",
body: []byte(`not json`),
wantCode: "unset",
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.wantCode, ErrorCodeFromBody(testCase.body))
})
}
}
func TestError(t *testing.T) {
listener, err := net.Listen("tcp", "localhost:0")
require.NoError(t, err)

View File

@@ -18,6 +18,7 @@ import (
"github.com/uptrace/bun"
"github.com/SigNoz/signoz/pkg/prometheus"
"github.com/SigNoz/signoz/pkg/query-service/utils/timestamp"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types"
@@ -1257,7 +1258,7 @@ func (r *ClickHouseReader) GetFlamegraphSpansForTrace(ctx context.Context, orgID
}
}
selectedSpans = tracedetail.GetSelectedSpansForFlamegraph(traceRoots, spanIdToSpanNodeMap)
selectedSpans = tracedetail.GetAllSpansForFlamegraph(traceRoots, spanIdToSpanNodeMap)
traceCache := model.GetFlamegraphSpansForTraceCache{
StartTime: startTime,
EndTime: endTime,
@@ -1274,12 +1275,25 @@ func (r *ClickHouseReader) GetFlamegraphSpansForTrace(ctx context.Context, orgID
}
processingPostCache := time.Now()
selectedSpansForRequest := tracedetail.GetSelectedSpansForFlamegraphForRequest(req.SelectedSpanID, selectedSpans, startTime, endTime)
r.logger.Info("getFlamegraphSpansForTrace: processing post cache", "duration", time.Since(processingPostCache), "traceID", traceID)
selectedSpansForRequest := selectedSpans
clientLimit := min(req.Limit, tracedetail.MaxLimitWithoutSampling)
totalSpanCount := tracedetail.GetTotalSpanCount(selectedSpans)
if totalSpanCount > uint64(clientLimit) {
// using trace start and end time if boundary ts are set to zero (or not set)
boundaryStart := max(timestamp.MilliToNano(req.BoundaryStartTS), startTime)
boundaryEnd := timestamp.MilliToNano(req.BoundaryEndTS)
if boundaryEnd == 0 {
boundaryEnd = endTime
}
selectedSpansForRequest = tracedetail.GetSelectedSpansForFlamegraphForRequest(req.SelectedSpanID, selectedSpans, boundaryStart, boundaryEnd)
}
r.logger.Debug("getFlamegraphSpansForTrace: processing post cache", "duration", time.Since(processingPostCache), "traceID", traceID, "totalSpans", totalSpanCount, "limit", clientLimit)
trace.Spans = selectedSpansForRequest
trace.StartTimestampMillis = startTime / 1000000
trace.EndTimestampMillis = endTime / 1000000
trace.HasMore = totalSpanCount > uint64(clientLimit)
return trace, nil
}

View File

@@ -208,7 +208,7 @@ func (s *Server) createPublicServer(api *APIHandler, web web.Web) (*http.Server,
s.config.APIServer.Timeout.Default,
s.config.APIServer.Timeout.Max,
).Wrap)
r.Use(middleware.NewLogging(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes).Wrap)
r.Use(middleware.NewAudit(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes, nil).Wrap)
r.Use(middleware.NewComment().Wrap)
am := middleware.NewAuthZ(s.signoz.Instrumentation.Logger(), s.signoz.Modules.OrgGetter, s.signoz.Authz)

View File

@@ -7,9 +7,12 @@ import (
)
var (
SPAN_LIMIT_PER_REQUEST_FOR_FLAMEGRAPH float64 = 50
SPAN_LIMIT_PER_LEVEL int = 100
TIMESTAMP_SAMPLING_BUCKET_COUNT int = 50
flamegraphSpanLevelLimit float64 = 50
flamegraphSpanLimitPerLevel int = 100
flamegraphSamplingBucketCount int = 50
flamegraphTopLatencySpanCount int = 5
MaxLimitWithoutSampling uint = 120_000
)
func ContainsFlamegraphSpan(slice []*model.FlamegraphSpan, item *model.FlamegraphSpan) bool {
@@ -52,7 +55,8 @@ func FindIndexForSelectedSpan(spans [][]*model.FlamegraphSpan, selectedSpanId st
return selectedSpanLevel
}
func GetSelectedSpansForFlamegraph(traceRoots []*model.FlamegraphSpan, spanIdToSpanNodeMap map[string]*model.FlamegraphSpan) [][]*model.FlamegraphSpan {
// GetAllSpansForFlamegraph groups all spans as per their level
func GetAllSpansForFlamegraph(traceRoots []*model.FlamegraphSpan, spanIdToSpanNodeMap map[string]*model.FlamegraphSpan) [][]*model.FlamegraphSpan {
var traceIdLevelledFlamegraph = map[string]map[int64][]*model.FlamegraphSpan{}
selectedSpans := [][]*model.FlamegraphSpan{}
@@ -100,7 +104,7 @@ func getLatencyAndTimestampBucketedSpans(spans []*model.FlamegraphSpan, selected
})
// pick the top 5 latency spans
for idx := range 5 {
for idx := range flamegraphTopLatencySpanCount {
sampledSpans = append(sampledSpans, spans[idx])
}
@@ -117,17 +121,17 @@ func getLatencyAndTimestampBucketedSpans(spans []*model.FlamegraphSpan, selected
}
}
bucketSize := (endTime - startTime) / uint64(TIMESTAMP_SAMPLING_BUCKET_COUNT)
bucketSize := (endTime - startTime) / uint64(flamegraphSamplingBucketCount)
if bucketSize == 0 {
bucketSize = 1
}
bucketedSpans := make([][]*model.FlamegraphSpan, 50)
bucketedSpans := make([][]*model.FlamegraphSpan, flamegraphSamplingBucketCount)
for _, span := range spans {
if span.TimeUnixNano >= startTime && span.TimeUnixNano <= endTime {
bucketIndex := int((span.TimeUnixNano - startTime) / bucketSize)
if bucketIndex >= 0 && bucketIndex < 50 {
if bucketIndex >= 0 && bucketIndex < flamegraphSamplingBucketCount {
bucketedSpans[bucketIndex] = append(bucketedSpans[bucketIndex], span)
}
}
@@ -156,8 +160,8 @@ func GetSelectedSpansForFlamegraphForRequest(selectedSpanID string, selectedSpan
selectedIndex = FindIndexForSelectedSpan(selectedSpans, selectedSpanID)
}
lowerLimit := selectedIndex - int(SPAN_LIMIT_PER_REQUEST_FOR_FLAMEGRAPH*0.4)
upperLimit := selectedIndex + int(SPAN_LIMIT_PER_REQUEST_FOR_FLAMEGRAPH*0.6)
lowerLimit := selectedIndex - int(flamegraphSpanLevelLimit*0.4)
upperLimit := selectedIndex + int(flamegraphSpanLevelLimit*0.6)
if lowerLimit < 0 {
upperLimit = upperLimit - lowerLimit
@@ -174,7 +178,7 @@ func GetSelectedSpansForFlamegraphForRequest(selectedSpanID string, selectedSpan
}
for i := lowerLimit; i < upperLimit; i++ {
if len(selectedSpans[i]) > SPAN_LIMIT_PER_LEVEL {
if len(selectedSpans[i]) > flamegraphSpanLimitPerLevel {
_spans := getLatencyAndTimestampBucketedSpans(selectedSpans[i], selectedSpanID, i == selectedIndex, startTime, endTime)
selectedSpansForRequest = append(selectedSpansForRequest, _spans)
} else {
@@ -184,3 +188,12 @@ func GetSelectedSpansForFlamegraphForRequest(selectedSpanID string, selectedSpan
return selectedSpansForRequest
}
func GetTotalSpanCount(spans [][]*model.FlamegraphSpan) uint64 {
levelCount := len(spans)
spanCount := uint64(0)
for i := range levelCount {
spanCount += uint64(len(spans[i]))
}
return spanCount
}

View File

@@ -337,7 +337,10 @@ type GetWaterfallSpansForTraceWithMetadataParams struct {
}
type GetFlamegraphSpansForTraceParams struct {
SelectedSpanID string `json:"selectedSpanId"`
SelectedSpanID string `json:"selectedSpanId"`
Limit uint `json:"limit"`
BoundaryStartTS uint64 `json:"boundaryStartTsMilli"`
BoundaryEndTS uint64 `json:"boundarEndTsMilli"`
}
type SpanFilterParams struct {

View File

@@ -337,6 +337,7 @@ type GetFlamegraphSpansForTraceResponse struct {
EndTimestampMillis uint64 `json:"endTimestampMillis"`
DurationNano uint64 `json:"durationNano"`
Spans [][]*FlamegraphSpan `json:"spans"`
HasMore bool `json:"hasMore"`
}
type OtelSpanRef struct {

View File

@@ -11,3 +11,7 @@ func FromTime(t time.Time) int64 {
func Time(ts int64) time.Time {
return time.Unix(ts/1000, (ts%1000)*int64(time.Millisecond))
}
func MilliToNano(milliTS uint64) uint64 {
return milliTS * 1000_000
}

View File

@@ -0,0 +1,206 @@
package audittypes
import (
"net/http"
"strings"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"go.opentelemetry.io/collector/pdata/pcommon"
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
)
// Audit attributes — Action (What).
type AuditAttributes struct {
Action Action // guaranteed to be present
ActionCategory ActionCategory // guaranteed to be present
Outcome Outcome // guaranteed to be present
IdentNProvider authtypes.IdentNProvider
}
func NewAuditAttributesFromHTTP(statusCode int, action Action, category ActionCategory, claims authtypes.Claims) AuditAttributes {
outcome := OutcomeFailure
if statusCode >= 200 && statusCode < 400 {
outcome = OutcomeSuccess
}
return AuditAttributes{
Action: action,
ActionCategory: category,
Outcome: outcome,
IdentNProvider: claims.IdentNProvider,
}
}
func (attributes AuditAttributes) Put(dest pcommon.Map) {
dest.PutStr("signoz.audit.action", attributes.Action.StringValue())
dest.PutStr("signoz.audit.action_category", attributes.ActionCategory.StringValue())
dest.PutStr("signoz.audit.outcome", attributes.Outcome.StringValue())
putStrIfNotEmpty(dest, "signoz.audit.identn_provider", attributes.IdentNProvider.StringValue())
}
// Audit attributes — Principal (Who).
type PrincipalAttributes struct {
PrincipalType authtypes.Principal
PrincipalID valuer.UUID
PrincipalEmail valuer.Email
PrincipalOrgID valuer.UUID
}
func NewPrincipalAttributesFromClaims(claims authtypes.Claims) PrincipalAttributes {
principalID, _ := valuer.NewUUID(claims.UserID)
principalEmail, _ := valuer.NewEmail(claims.Email)
principalOrgID, _ := valuer.NewUUID(claims.OrgID)
return PrincipalAttributes{
PrincipalType: claims.Principal,
PrincipalID: principalID,
PrincipalEmail: principalEmail,
PrincipalOrgID: principalOrgID,
}
}
func (attributes PrincipalAttributes) Put(dest pcommon.Map) {
dest.PutStr("signoz.audit.principal.id", attributes.PrincipalID.StringValue())
dest.PutStr("signoz.audit.principal.email", attributes.PrincipalEmail.String())
dest.PutStr("signoz.audit.principal.type", attributes.PrincipalType.StringValue())
dest.PutStr("signoz.audit.principal.org_id", attributes.PrincipalOrgID.StringValue())
}
// Audit attributes — Resource (On What).
type ResourceAttributes struct {
ResourceID string
ResourceName string // guaranteed to be present
}
func NewResourceAttributes(resourceID, resourceName string) ResourceAttributes {
return ResourceAttributes{
ResourceID: resourceID,
ResourceName: resourceName,
}
}
func (attributes ResourceAttributes) Put(dest pcommon.Map) {
putStrIfNotEmpty(dest, "signoz.audit.resource.name", attributes.ResourceName)
putStrIfNotEmpty(dest, "signoz.audit.resource.id", attributes.ResourceID)
}
// Audit attributes — Error (When outcome is failure)
// Error messages are intentionally excluded to avoid leaking sensitive or
// PII data into audit logs. The error type and code are sufficient for
// filtering and alerting; investigators can correlate via trace ID.
type ErrorAttributes struct {
ErrorType string
ErrorCode string
}
func NewErrorAttributes(errorType, errorCode string) ErrorAttributes {
return ErrorAttributes{
ErrorType: errorType,
ErrorCode: errorCode,
}
}
func (attributes ErrorAttributes) Put(dest pcommon.Map) {
putStrIfNotEmpty(dest, "signoz.audit.error.type", attributes.ErrorType)
putStrIfNotEmpty(dest, "signoz.audit.error.code", attributes.ErrorCode)
}
// Audit attributes — Transport Context (Where/How).
type TransportAttributes struct {
HTTPMethod string
HTTPRoute string
HTTPStatusCode int
URLPath string
ClientAddress string
UserAgent string
}
func NewTransportAttributesFromHTTP(req *http.Request, route string, statusCode int) TransportAttributes {
return TransportAttributes{
HTTPMethod: req.Method,
HTTPRoute: route,
HTTPStatusCode: statusCode,
URLPath: req.URL.Path,
ClientAddress: req.RemoteAddr,
UserAgent: req.UserAgent(),
}
}
func (attributes TransportAttributes) Put(dest pcommon.Map) {
putStrIfNotEmpty(dest, string(semconv.HTTPRequestMethodKey), attributes.HTTPMethod)
putStrIfNotEmpty(dest, string(semconv.HTTPRouteKey), attributes.HTTPRoute)
if attributes.HTTPStatusCode != 0 {
dest.PutInt(string(semconv.HTTPResponseStatusCodeKey), int64(attributes.HTTPStatusCode))
}
putStrIfNotEmpty(dest, string(semconv.URLPathKey), attributes.URLPath)
putStrIfNotEmpty(dest, string(semconv.ClientAddressKey), attributes.ClientAddress)
putStrIfNotEmpty(dest, string(semconv.UserAgentOriginalKey), attributes.UserAgent)
}
func putStrIfNotEmpty(attrs pcommon.Map, key, value string) {
if value != "" {
attrs.PutStr(key, value)
}
}
func newBody(auditAttributes AuditAttributes, principalAttributes PrincipalAttributes, resourceAttributes ResourceAttributes, errorAttributes ErrorAttributes) string {
var b strings.Builder
// Principal: "email (id)" or "id" or "email" or omitted.
hasEmail := principalAttributes.PrincipalEmail.String() != ""
hasID := !principalAttributes.PrincipalID.IsZero()
if hasEmail {
b.WriteString(principalAttributes.PrincipalEmail.String())
if hasID {
b.WriteString(" (")
b.WriteString(principalAttributes.PrincipalID.StringValue())
b.WriteString(")")
}
} else if hasID {
b.WriteString(principalAttributes.PrincipalID.StringValue())
}
// Action: " created" or " failed to create".
if b.Len() > 0 {
b.WriteString(" ")
}
if auditAttributes.Outcome == OutcomeSuccess {
b.WriteString(auditAttributes.Action.PastTense())
} else {
b.WriteString("failed to ")
b.WriteString(auditAttributes.Action.StringValue())
}
// Resource: " name (id)" or " name".
b.WriteString(" ")
b.WriteString(resourceAttributes.ResourceName)
if resourceAttributes.ResourceID != "" {
b.WriteString(" (")
b.WriteString(resourceAttributes.ResourceID)
b.WriteString(")")
}
// Error suffix (failure only): ": type (code)" or ": type" or ": (code)" or omitted.
if auditAttributes.Outcome == OutcomeFailure {
errorType := errorAttributes.ErrorType
errorCode := errorAttributes.ErrorCode
if errorType != "" || errorCode != "" {
b.WriteString(": ")
if errorType != "" && errorCode != "" {
b.WriteString(errorType)
b.WriteString(" (")
b.WriteString(errorCode)
b.WriteString(")")
} else if errorType != "" {
b.WriteString(errorType)
} else {
b.WriteString("(")
b.WriteString(errorCode)
b.WriteString(")")
}
}
}
return b.String()
}

View File

@@ -0,0 +1,203 @@
package audittypes
import (
"testing"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
)
func TestNewAuditAttributesFromHTTP_OutcomeBoundary(t *testing.T) {
claims := authtypes.Claims{IdentNProvider: authtypes.IdentNProviderTokenizer}
testCases := []struct {
name string
statusCode int
expectedOutcome Outcome
}{
{
name: "200_Success",
statusCode: 200,
expectedOutcome: OutcomeSuccess,
},
{
name: "399_Success",
statusCode: 399,
expectedOutcome: OutcomeSuccess,
},
{
name: "400_Failure",
statusCode: 400,
expectedOutcome: OutcomeFailure,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
attrs := NewAuditAttributesFromHTTP(testCase.statusCode, ActionUpdate, ActionCategoryConfigurationChange, claims)
assert.Equal(t, testCase.expectedOutcome, attrs.Outcome)
})
}
}
func TestNewBody(t *testing.T) {
testCases := []struct {
name string
auditAttributes AuditAttributes
principalAttributes PrincipalAttributes
resourceAttributes ResourceAttributes
errorAttributes ErrorAttributes
expectedBody string
}{
{
name: "Success_EmptyResourceID",
auditAttributes: AuditAttributes{
Action: ActionDelete,
ActionCategory: ActionCategoryConfigurationChange,
Outcome: OutcomeSuccess,
},
principalAttributes: PrincipalAttributes{
PrincipalID: valuer.MustNewUUID("019a1234-abcd-7000-8000-567800000001"),
PrincipalEmail: valuer.MustNewEmail("test@acme.com"),
},
resourceAttributes: ResourceAttributes{
ResourceID: "",
ResourceName: "dashboard",
},
errorAttributes: ErrorAttributes{},
expectedBody: "test@acme.com (019a1234-abcd-7000-8000-567800000001) deleted dashboard",
},
{
name: "Success_EmptyPrincipalEmail",
auditAttributes: AuditAttributes{
Action: ActionDelete,
ActionCategory: ActionCategoryConfigurationChange,
Outcome: OutcomeSuccess,
},
principalAttributes: PrincipalAttributes{
PrincipalID: valuer.MustNewUUID("019a1234-abcd-7000-8000-567800000001"),
PrincipalEmail: valuer.Email{},
},
resourceAttributes: ResourceAttributes{
ResourceID: "abd",
ResourceName: "dashboard",
},
errorAttributes: ErrorAttributes{},
expectedBody: "019a1234-abcd-7000-8000-567800000001 deleted dashboard (abd)",
},
{
name: "Success_EmptyPrincipalIDandEmail",
auditAttributes: AuditAttributes{
Action: ActionDelete,
ActionCategory: ActionCategoryConfigurationChange,
Outcome: OutcomeSuccess,
},
principalAttributes: PrincipalAttributes{
PrincipalID: valuer.UUID{},
PrincipalEmail: valuer.Email{},
},
resourceAttributes: ResourceAttributes{
ResourceID: "abd",
ResourceName: "dashboard",
},
errorAttributes: ErrorAttributes{},
expectedBody: "deleted dashboard (abd)",
},
{
name: "Success_AllPresent",
auditAttributes: AuditAttributes{
Action: ActionCreate,
ActionCategory: ActionCategoryConfigurationChange,
Outcome: OutcomeSuccess,
},
principalAttributes: PrincipalAttributes{
PrincipalID: valuer.MustNewUUID("019a1234-abcd-7000-8000-567800000001"),
PrincipalEmail: valuer.MustNewEmail("alice@acme.com"),
},
resourceAttributes: ResourceAttributes{
ResourceID: "019b-5678",
ResourceName: "dashboard",
},
errorAttributes: ErrorAttributes{},
expectedBody: "alice@acme.com (019a1234-abcd-7000-8000-567800000001) created dashboard (019b-5678)",
},
{
name: "Success_EmptyEverythingOptional",
auditAttributes: AuditAttributes{
Action: ActionUpdate,
ActionCategory: ActionCategoryConfigurationChange,
Outcome: OutcomeSuccess,
},
principalAttributes: PrincipalAttributes{},
resourceAttributes: ResourceAttributes{
ResourceName: "alert-rule",
},
errorAttributes: ErrorAttributes{},
expectedBody: "updated alert-rule",
},
{
name: "Failure_AllPresent",
auditAttributes: AuditAttributes{
Action: ActionUpdate,
ActionCategory: ActionCategoryConfigurationChange,
Outcome: OutcomeFailure,
},
principalAttributes: PrincipalAttributes{
PrincipalID: valuer.MustNewUUID("019aaaaa-bbbb-7000-8000-cccc00000002"),
PrincipalEmail: valuer.MustNewEmail("viewer@acme.com"),
},
resourceAttributes: ResourceAttributes{
ResourceID: "019b-5678",
ResourceName: "dashboard",
},
errorAttributes: ErrorAttributes{
ErrorType: "forbidden",
ErrorCode: "authz_forbidden",
},
expectedBody: "viewer@acme.com (019aaaaa-bbbb-7000-8000-cccc00000002) failed to update dashboard (019b-5678): forbidden (authz_forbidden)",
},
{
name: "Failure_ErrorTypeOnly",
auditAttributes: AuditAttributes{
Action: ActionDelete,
Outcome: OutcomeFailure,
},
principalAttributes: PrincipalAttributes{
PrincipalID: valuer.MustNewUUID("019a1234-abcd-7000-8000-567800000001"),
PrincipalEmail: valuer.MustNewEmail("test@acme.com"),
},
resourceAttributes: ResourceAttributes{
ResourceName: "user",
},
errorAttributes: ErrorAttributes{
ErrorType: "not-found",
},
expectedBody: "test@acme.com (019a1234-abcd-7000-8000-567800000001) failed to delete user: not-found",
},
{
name: "Failure_NoErrorDetails",
auditAttributes: AuditAttributes{
Action: ActionCreate,
Outcome: OutcomeFailure,
},
principalAttributes: PrincipalAttributes{
PrincipalID: valuer.MustNewUUID("019a1234-abcd-7000-8000-567800000001"),
PrincipalEmail: valuer.MustNewEmail("test@acme.com"),
},
resourceAttributes: ResourceAttributes{
ResourceID: "019b-5678",
ResourceName: "dashboard",
},
errorAttributes: ErrorAttributes{},
expectedBody: "test@acme.com (019a1234-abcd-7000-8000-567800000001) failed to create dashboard (019b-5678)",
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
body := newBody(testCase.auditAttributes, testCase.principalAttributes, testCase.resourceAttributes, testCase.errorAttributes)
assert.Equal(t, testCase.expectedBody, body)
})
}
}

View File

@@ -1,54 +1,80 @@
package audittypes
import (
"encoding/hex"
"fmt"
"net/http"
"time"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"go.opentelemetry.io/collector/pdata/pcommon"
"go.opentelemetry.io/collector/pdata/plog"
semconv "go.opentelemetry.io/otel/semconv/v1.10.0"
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
oteltrace "go.opentelemetry.io/otel/trace"
)
// AuditEvent represents a single audit log event.
// Fields are ordered following the OTel LogRecord structure.
type AuditEvent struct {
// OTel LogRecord intrinsic fields
Timestamp time.Time `json:"timestamp"`
TraceID string `json:"traceId,omitempty"`
SpanID string `json:"spanId,omitempty"`
Body string `json:"body"`
EventName EventName `json:"eventName"`
// OTel LogRecord Intrinsic
Timestamp time.Time
// Audit attributes — Principal (Who)
PrincipalID valuer.UUID `json:"principalId"`
PrincipalEmail valuer.Email `json:"principalEmail"`
PrincipalType PrincipalType `json:"principalType"`
PrincipalOrgID valuer.UUID `json:"principalOrgId"`
IdentNProvider string `json:"identnProvider,omitempty"`
// OTel LogRecord Intrinsic
TraceID oteltrace.TraceID
// Audit attributes — Action (What)
Action Action `json:"action"`
ActionCategory ActionCategory `json:"actionCategory"`
Outcome Outcome `json:"outcome"`
// OTel LogRecord Intrinsic
SpanID oteltrace.SpanID
// Audit attributes — Resource (On What)
ResourceName string `json:"resourceName"`
ResourceID string `json:"resourceId,omitempty"`
// OTel LogRecord Intrinsic
Body string
// Audit attributes — Error (When outcome is failure)
ErrorType string `json:"errorType,omitempty"`
ErrorCode string `json:"errorCode,omitempty"`
ErrorMessage string `json:"errorMessage,omitempty"`
// OTel LogRecord Intrinsic
EventName EventName
// Transport Context (Where/How)
HTTPMethod string `json:"httpMethod,omitempty"`
HTTPRoute string `json:"httpRoute,omitempty"`
HTTPStatusCode int `json:"httpStatusCode,omitempty"`
URLPath string `json:"urlPath,omitempty"`
ClientAddress string `json:"clientAddress,omitempty"`
UserAgent string `json:"userAgent,omitempty"`
// Custom Audit Attributes - Action
AuditAttributes AuditAttributes
// Custom Audit Attributes - Principal
PrincipalAttributes PrincipalAttributes
// Custom Audit Attributes - Resource
ResourceAttributes ResourceAttributes
// Custom Audit Attributes - Error
ErrorAttributes ErrorAttributes
// Custom Audit Attributes - Transport Context
TransportAttributes TransportAttributes
}
func NewAuditEventFromHTTPRequest(
req *http.Request,
route string,
statusCode int,
traceID oteltrace.TraceID,
spanID oteltrace.SpanID,
action Action,
actionCategory ActionCategory,
claims authtypes.Claims,
resourceID string,
resourceName string,
errorType string,
errorCode string,
) AuditEvent {
auditAttributes := NewAuditAttributesFromHTTP(statusCode, action, actionCategory, claims)
principalAttributes := NewPrincipalAttributesFromClaims(claims)
resourceAttributes := NewResourceAttributes(resourceID, resourceName)
errorAttributes := NewErrorAttributes(errorType, errorCode)
transportAttributes := NewTransportAttributesFromHTTP(req, route, statusCode)
return AuditEvent{
Timestamp: time.Now(),
TraceID: traceID,
SpanID: spanID,
Body: newBody(auditAttributes, principalAttributes, resourceAttributes, errorAttributes),
EventName: NewEventName(resourceAttributes.ResourceName, auditAttributes.Action),
AuditAttributes: auditAttributes,
PrincipalAttributes: principalAttributes,
ResourceAttributes: resourceAttributes,
ErrorAttributes: errorAttributes,
TransportAttributes: transportAttributes,
}
}
func NewPLogsFromAuditEvents(events []AuditEvent, name string, version string, scope string) plog.Logs {
@@ -68,88 +94,41 @@ func NewPLogsFromAuditEvents(events []AuditEvent, name string, version string, s
}
func (event AuditEvent) ToLogRecord(dest plog.LogRecord) {
// Set timestamps
dest.SetTimestamp(pcommon.NewTimestampFromTime(event.Timestamp))
dest.SetObservedTimestamp(pcommon.NewTimestampFromTime(event.Timestamp))
dest.Body().SetStr(event.setBody())
dest.SetEventName(event.EventName.String())
dest.SetSeverityNumber(event.Outcome.Severity())
dest.SetSeverityText(event.Outcome.SeverityText())
if tid, ok := parseTraceID(event.TraceID); ok {
dest.SetTraceID(tid)
// Set body and event name
dest.Body().SetStr(event.Body)
dest.SetEventName(event.EventName.String())
// Set severity based on outcome
dest.SetSeverityNumber(event.AuditAttributes.Outcome.Severity())
dest.SetSeverityText(event.AuditAttributes.Outcome.SeverityText())
// Set trace and span IDs if present
if event.TraceID.IsValid() {
dest.SetTraceID(pcommon.TraceID(event.TraceID))
}
if sid, ok := parseSpanID(event.SpanID); ok {
dest.SetSpanID(sid)
if event.SpanID.IsValid() {
dest.SetSpanID(pcommon.SpanID(event.SpanID))
}
attrs := dest.Attributes()
// Principal attributes
attrs.PutStr("signoz.audit.principal.id", event.PrincipalID.StringValue())
attrs.PutStr("signoz.audit.principal.email", event.PrincipalEmail.String())
attrs.PutStr("signoz.audit.principal.type", event.PrincipalType.StringValue())
attrs.PutStr("signoz.audit.principal.org_id", event.PrincipalOrgID.StringValue())
putStrIfNotEmpty(attrs, "signoz.audit.identn_provider", event.IdentNProvider)
// Audit attributes
event.AuditAttributes.Put(attrs)
// Action attributes
attrs.PutStr("signoz.audit.action", event.Action.StringValue())
attrs.PutStr("signoz.audit.action_category", event.ActionCategory.StringValue())
attrs.PutStr("signoz.audit.outcome", event.Outcome.StringValue())
// Principal attributes
event.PrincipalAttributes.Put(attrs)
// Resource attributes
attrs.PutStr("signoz.audit.resource.name", event.ResourceName)
putStrIfNotEmpty(attrs, "signoz.audit.resource.id", event.ResourceID)
event.ResourceAttributes.Put(attrs)
// Error attributes (on failure)
putStrIfNotEmpty(attrs, "signoz.audit.error.type", event.ErrorType)
putStrIfNotEmpty(attrs, "signoz.audit.error.code", event.ErrorCode)
putStrIfNotEmpty(attrs, "signoz.audit.error.message", event.ErrorMessage)
// Error attributes
event.ErrorAttributes.Put(attrs)
// Transport context attributes
putStrIfNotEmpty(attrs, "http.request.method", event.HTTPMethod)
putStrIfNotEmpty(attrs, "http.route", event.HTTPRoute)
if event.HTTPStatusCode != 0 {
attrs.PutInt("http.response.status_code", int64(event.HTTPStatusCode))
}
putStrIfNotEmpty(attrs, "url.path", event.URLPath)
putStrIfNotEmpty(attrs, "client.address", event.ClientAddress)
putStrIfNotEmpty(attrs, "user_agent.original", event.UserAgent)
}
func (event AuditEvent) setBody() string {
if event.Outcome == OutcomeSuccess {
return fmt.Sprintf("%s (%s) %s %s %s", event.PrincipalEmail, event.PrincipalID, event.Action.PastTense(), event.ResourceName, event.ResourceID)
}
return fmt.Sprintf("%s (%s) failed to %s %s %s: %s (%s)", event.PrincipalEmail, event.PrincipalID, event.Action.StringValue(), event.ResourceName, event.ResourceID, event.ErrorType, event.ErrorCode)
}
func putStrIfNotEmpty(attrs pcommon.Map, key, value string) {
if value != "" {
attrs.PutStr(key, value)
}
}
func parseTraceID(s string) (pcommon.TraceID, bool) {
b, err := hex.DecodeString(s)
if err != nil || len(b) != 16 {
return pcommon.TraceID{}, false
}
var tid pcommon.TraceID
copy(tid[:], b)
return tid, true
}
func parseSpanID(s string) (pcommon.SpanID, bool) {
b, err := hex.DecodeString(s)
if err != nil || len(b) != 8 {
return pcommon.SpanID{}, false
}
var sid pcommon.SpanID
copy(sid[:], b)
return sid, true
event.TransportAttributes.Put(attrs)
}

View File

@@ -0,0 +1,99 @@
package audittypes
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/stretchr/testify/assert"
oteltrace "go.opentelemetry.io/otel/trace"
)
func TestNewAuditEventFromHTTPRequest(t *testing.T) {
traceID := oteltrace.TraceID{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}
spanID := oteltrace.SpanID{1, 2, 3, 4, 5, 6, 7, 8}
testCases := []struct {
name string
method string
path string
route string
statusCode int
action Action
category ActionCategory
claims authtypes.Claims
resourceID string
resourceName string
errorType string
errorCode string
expectedOutcome Outcome
expectedBody string
}{
{
name: "Success_DashboardCreated",
method: http.MethodPost,
path: "/api/v1/dashboards",
route: "/api/v1/dashboards",
statusCode: http.StatusOK,
action: ActionCreate,
category: ActionCategoryConfigurationChange,
claims: authtypes.Claims{UserID: "019a1234-abcd-7000-8000-567800000001", Email: "alice@acme.com", OrgID: "019a-0000-0000-0001", IdentNProvider: authtypes.IdentNProviderTokenizer},
resourceID: "019b-5678-efgh-9012",
resourceName: "dashboard",
expectedOutcome: OutcomeSuccess,
expectedBody: "alice@acme.com (019a1234-abcd-7000-8000-567800000001) created dashboard (019b-5678-efgh-9012)",
},
{
name: "Failure_ForbiddenDashboardUpdate",
method: http.MethodPut,
path: "/api/v1/dashboards/019b-5678-efgh-9012",
route: "/api/v1/dashboards/{id}",
statusCode: http.StatusForbidden,
action: ActionUpdate,
category: ActionCategoryConfigurationChange,
claims: authtypes.Claims{UserID: "019aaaaa-bbbb-7000-8000-cccc00000002", Email: "viewer@acme.com", OrgID: "019a-0000-0000-0001", IdentNProvider: authtypes.IdentNProviderTokenizer},
resourceID: "019b-5678-efgh-9012",
resourceName: "dashboard",
errorType: "forbidden",
errorCode: "authz_forbidden",
expectedOutcome: OutcomeFailure,
expectedBody: "viewer@acme.com (019aaaaa-bbbb-7000-8000-cccc00000002) failed to update dashboard (019b-5678-efgh-9012): forbidden (authz_forbidden)",
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
req := httptest.NewRequest(testCase.method, testCase.path, nil)
event := NewAuditEventFromHTTPRequest(
req,
testCase.route,
testCase.statusCode,
traceID,
spanID,
testCase.action,
testCase.category,
testCase.claims,
testCase.resourceID,
testCase.resourceName,
testCase.errorType,
testCase.errorCode,
)
assert.Equal(t, testCase.expectedOutcome, event.AuditAttributes.Outcome)
assert.Equal(t, testCase.expectedBody, event.Body)
assert.Equal(t, testCase.resourceName, event.ResourceAttributes.ResourceName)
assert.Equal(t, testCase.resourceID, event.ResourceAttributes.ResourceID)
assert.Equal(t, testCase.action, event.AuditAttributes.Action)
assert.Equal(t, testCase.category, event.AuditAttributes.ActionCategory)
assert.Equal(t, testCase.route, event.TransportAttributes.HTTPRoute)
assert.Equal(t, testCase.statusCode, event.TransportAttributes.HTTPStatusCode)
assert.Equal(t, testCase.method, event.TransportAttributes.HTTPMethod)
assert.Equal(t, traceID, event.TraceID)
assert.Equal(t, spanID, event.SpanID)
assert.Equal(t, testCase.errorType, event.ErrorAttributes.ErrorType)
assert.Equal(t, testCase.errorCode, event.ErrorAttributes.ErrorCode)
})
}
}

View File

@@ -0,0 +1,278 @@
package dashboardtypes
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/valuer"
v1 "github.com/perses/perses/pkg/model/api/v1"
"github.com/perses/perses/pkg/model/api/v1/common"
"github.com/uptrace/bun"
)
type StorableDashboardDataV2 = v1.Dashboard
type StorableDashboardV2 struct {
bun.BaseModel `bun:"table:dashboard,alias:dashboard"`
types.Identifiable
// TimeAuditable is not embedded here — CreatedAt/UpdatedAt live in
// Data.Metadata (Perses's ProjectMetadata) to avoid duplication.
types.UserAuditable
Data StorableDashboardDataV2 `bun:"data,type:text,notnull"`
Locked bool `bun:"locked,notnull,default:false"`
OrgID valuer.UUID `bun:"org_id,notnull"`
}
type DashboardV2 struct {
// TimeAuditable is not embedded here — CreatedAt/UpdatedAt live in
// Data.Metadata (Perses's ProjectMetadata) to avoid duplication.
types.UserAuditable
ID string `json:"id"`
Data StorableDashboardDataV2 `json:"data"`
Locked bool `json:"locked"`
OrgID valuer.UUID `json:"org_id"`
}
type (
GettableDashboardV2 = DashboardV2
UpdatableDashboardV2 = StorableDashboardDataV2
PostableDashboardV2 = StorableDashboardDataV2
ListableDashboardV2 []*GettableDashboardV2
)
func NewStorableDashboardV2FromDashboard(dashboard *DashboardV2) (*StorableDashboardV2, error) {
dashboardID, err := valuer.NewUUID(dashboard.ID)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid")
}
return &StorableDashboardV2{
Identifiable: types.Identifiable{
ID: dashboardID,
},
UserAuditable: types.UserAuditable{
CreatedBy: dashboard.CreatedBy,
UpdatedBy: dashboard.UpdatedBy,
},
OrgID: dashboard.OrgID,
Data: dashboard.Data,
Locked: dashboard.Locked,
}, nil
}
func NewDashboardV2(orgID valuer.UUID, createdBy string, data StorableDashboardDataV2) (*DashboardV2, error) {
currentTime := time.Now()
data.Metadata.CreatedAt = currentTime
data.Metadata.UpdatedAt = currentTime
return &DashboardV2{
ID: valuer.GenerateUUID().StringValue(),
UserAuditable: types.UserAuditable{
CreatedBy: createdBy,
UpdatedBy: createdBy,
},
OrgID: orgID,
Data: data,
Locked: false,
}, nil
}
func NewDashboardV2FromStorableDashboard(storableDashboard *StorableDashboardV2) *DashboardV2 {
return &DashboardV2{
ID: storableDashboard.ID.StringValue(),
UserAuditable: types.UserAuditable{
CreatedBy: storableDashboard.CreatedBy,
UpdatedBy: storableDashboard.UpdatedBy,
},
OrgID: storableDashboard.OrgID,
Data: storableDashboard.Data,
Locked: storableDashboard.Locked,
}
}
func NewDashboardsV2FromStorableDashboards(storableDashboards []*StorableDashboardV2) []*DashboardV2 {
dashboards := make([]*DashboardV2, len(storableDashboards))
for idx, storableDashboard := range storableDashboards {
dashboards[idx] = NewDashboardV2FromStorableDashboard(storableDashboard)
}
return dashboards
}
func NewGettableDashboardsV2FromDashboards(dashboards []*DashboardV2) ([]*GettableDashboardV2, error) {
gettableDashboards := make([]*GettableDashboardV2, len(dashboards))
for idx, d := range dashboards {
gettableDashboard, err := NewGettableDashboardV2FromDashboard(d)
if err != nil {
return nil, err
}
gettableDashboards[idx] = gettableDashboard
}
return gettableDashboards, nil
}
func NewGettableDashboardV2FromDashboard(dashboard *DashboardV2) (*GettableDashboardV2, error) {
return &GettableDashboardV2{
ID: dashboard.ID,
UserAuditable: dashboard.UserAuditable,
OrgID: dashboard.OrgID,
Data: dashboard.Data,
Locked: dashboard.Locked,
}, nil
}
func (dashboard *DashboardV2) UpdateV2(ctx context.Context, updatableDashboard UpdatableDashboardV2, updatedBy string) error {
if dashboard.Locked {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "cannot update a locked dashboard, please unlock the dashboard to update")
}
dashboard.UpdatedBy = updatedBy
updatableDashboard.Metadata.UpdatedAt = time.Now()
dashboard.Data = updatableDashboard
return nil
}
func (dashboard *DashboardV2) LockUnlockV2(lock bool, role types.Role, updatedBy string) error {
if dashboard.CreatedBy != updatedBy && role != types.RoleAdmin {
return errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "you are not authorized to lock/unlock this dashboard")
}
dashboard.Locked = lock
dashboard.UpdatedBy = updatedBy
dashboard.Data.Metadata.UpdatedAt = time.Now()
return nil
}
// ValidateDashboardV2JSON validates a dashboard v2 JSON by unmarshalling into typed structs
// and then validating plugin kinds and specs.
func ValidateDashboardV2JSON(data []byte) error {
var d StorableDashboardDataV2
if err := json.Unmarshal(data, &d); err != nil {
return err
}
return validateDashboardV2(d)
}
// Plugin kind → spec type factory. Each value is a pointer to the zero value of the
// expected spec struct. validatePluginSpec marshals plugin.Spec back to JSON and
// unmarshals into the typed struct to catch field-level errors.
var (
panelPluginSpecs = map[string]func() any{
"SigNozTimeSeriesPanel": func() any { return new(TimeSeriesPanelSpec) },
"SigNozBarChartPanel": func() any { return new(BarChartPanelSpec) },
"SigNozNumberPanel": func() any { return new(NumberPanelSpec) },
"SigNozPieChartPanel": func() any { return new(PieChartPanelSpec) },
"SigNozTablePanel": func() any { return new(TablePanelSpec) },
"SigNozHistogramPanel": func() any { return new(HistogramPanelSpec) },
"SigNozListPanel": func() any { return new(ListPanelSpec) },
}
queryPluginSpecs = map[string]func() any{
"SigNozBuilderQuery": func() any { return new(BuilderQuerySpec) },
"SigNozCompositeQuery": func() any { return new(CompositeQuerySpec) },
"SigNozFormula": func() any { return new(FormulaSpec) },
"SigNozPromQLQuery": func() any { return new(PromQLQuerySpec) },
"SigNozClickHouseSQL": func() any { return new(ClickHouseSQLQuerySpec) },
"SigNozTraceOperator": func() any { return new(TraceOperatorSpec) },
}
variablePluginSpecs = map[string]func() any{
"SigNozDynamicVariable": func() any { return new(DynamicVariableSpec) },
"SigNozQueryVariable": func() any { return new(QueryVariableSpec) },
"SigNozCustomVariable": func() any { return new(CustomVariableSpec) },
"SigNozTextboxVariable": func() any { return new(TextboxVariableSpec) },
}
datasourcePluginSpecs = map[string]func() any{
"SigNozDatasource": nil, // empty spec, nothing to validate
}
)
func validateDashboardV2(d StorableDashboardDataV2) error {
// Validate datasource plugins.
for name, ds := range d.Spec.Datasources {
if err := validatePlugin(ds.Plugin, datasourcePluginSpecs, fmt.Sprintf("spec.datasources.%s.plugin", name)); err != nil {
return err
}
}
// Validate variable plugins.
for i, v := range d.Spec.Variables {
plugin, err := extractPluginFromVariable(v)
if err != nil {
return fmt.Errorf("spec.variables[%d]: %w", i, err)
}
if plugin != nil {
if err := validatePlugin(*plugin, variablePluginSpecs, fmt.Sprintf("spec.variables[%d].spec.plugin", i)); err != nil {
return err
}
}
}
// Validate panel and query plugins.
for key, panel := range d.Spec.Panels {
if panel == nil {
continue
}
path := fmt.Sprintf("spec.panels.%s", key)
if err := validatePlugin(panel.Spec.Plugin, panelPluginSpecs, path+".spec.plugin"); err != nil {
return err
}
for qi, query := range panel.Spec.Queries {
if err := validatePlugin(query.Spec.Plugin, queryPluginSpecs, fmt.Sprintf("%s.spec.queries[%d].spec.plugin", path, qi)); err != nil {
return err
}
}
}
return nil
}
func validatePlugin(plugin common.Plugin, specs map[string]func() any, path string) error {
if plugin.Kind == "" {
return fmt.Errorf("%s: plugin kind is required", path)
}
factory, ok := specs[plugin.Kind]
if !ok {
return fmt.Errorf("%s: unknown plugin kind %q", path, plugin.Kind)
}
if plugin.Spec == nil {
return nil
}
// If factory is nil, no spec validation needed for this kind.
if factory == nil {
return nil
}
// Re-marshal the spec and unmarshal into the typed struct.
specJSON, err := json.Marshal(plugin.Spec)
if err != nil {
return fmt.Errorf("%s.spec: %w", path, err)
}
target := factory()
if err := json.Unmarshal(specJSON, target); err != nil {
return fmt.Errorf("%s.spec: %w", path, err)
}
return nil
}
func extractPluginFromVariable(v any) (*common.Plugin, error) {
data, err := json.Marshal(v)
if err != nil {
return nil, err
}
var raw struct {
Spec struct {
Plugin common.Plugin `json:"plugin"`
} `json:"spec"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return nil, err
}
if raw.Spec.Plugin.Kind == "" {
return nil, nil
}
return &raw.Spec.Plugin, nil
}

View File

@@ -0,0 +1,842 @@
package dashboardtypes
import (
"encoding/json"
"os"
"strings"
"testing"
)
func TestValidateDashboardV2JSON_ValidExample(t *testing.T) {
data, err := os.ReadFile("testdata/perses.json")
if err != nil {
t.Fatalf("reading example file: %v", err)
}
if err := ValidateDashboardV2JSON(data); err != nil {
t.Fatalf("expected valid dashboard, got error: %v", err)
}
}
func TestValidateDashboardV2JSON_InvalidJSON(t *testing.T) {
if err := ValidateDashboardV2JSON([]byte(`not json`)); err == nil {
t.Fatal("expected error for invalid JSON")
}
}
func TestValidateDashboardV2JSON_EmptyObject(t *testing.T) {
// Perses's UnmarshalJSON validates kind must be "Dashboard", so empty object fails.
if err := ValidateDashboardV2JSON([]byte(`{}`)); err == nil {
t.Fatal("expected error for empty object missing kind")
}
}
func TestValidateDashboardV2JSON_MinimalValid(t *testing.T) {
data := []byte(`{
"kind": "Dashboard",
"metadata": {"name": "test", "project": "signoz"},
"spec": {
"duration": "1h",
"panels": {},
"layouts": []
}
}`)
if err := ValidateDashboardV2JSON(data); err != nil {
t.Fatalf("expected valid, got: %v", err)
}
}
func TestValidateDashboardV2JSON_WithPanel(t *testing.T) {
data := []byte(`{
"kind": "Dashboard",
"metadata": {"name": "test", "project": "signoz"},
"spec": {
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"display": {"name": "My Panel"},
"plugin": {
"kind": "SigNozTimeSeriesPanel",
"spec": {"visualization": {"fillSpans": true}}
},
"queries": [{
"kind": "TimeSeriesQuery",
"spec": {
"plugin": {
"kind": "SigNozBuilderQuery",
"spec": {
"name": "A",
"signal": "metrics"
}
}
}
}]
}
}
},
"layouts": []
}
}`)
if err := ValidateDashboardV2JSON(data); err != nil {
t.Fatalf("expected valid, got: %v", err)
}
}
func TestValidateDashboardV2JSON_WithVariables(t *testing.T) {
data := []byte(`{
"kind": "Dashboard",
"metadata": {"name": "test", "project": "signoz"},
"spec": {
"variables": [
{
"kind": "ListVariable",
"spec": {
"name": "service",
"allowAllValue": true,
"allowMultiple": false,
"plugin": {
"kind": "SigNozDynamicVariable",
"spec": {"name": "service.name", "source": "Metrics"}
}
}
},
{
"kind": "TextVariable",
"spec": {
"name": "mytext",
"value": "default",
"plugin": {
"kind": "SigNozTextboxVariable",
"spec": {}
}
}
}
],
"layouts": []
}
}`)
if err := ValidateDashboardV2JSON(data); err != nil {
t.Fatalf("expected valid, got: %v", err)
}
}
func TestValidateDashboardV2JSON_WrongFieldType(t *testing.T) {
data := []byte(`{"kind": 123}`)
if err := ValidateDashboardV2JSON(data); err == nil {
t.Fatal("expected error for wrong type on kind field")
}
}
func TestValidateDashboardV2JSON_WithLayout(t *testing.T) {
data := []byte(`{
"kind": "Dashboard",
"metadata": {"name": "test", "project": "signoz"},
"spec": {
"panels": {
"abc": {
"kind": "Panel",
"spec": {
"display": {"name": "p"},
"plugin": {"kind": "SigNozNumberPanel", "spec": {}}
}
}
},
"layouts": [{
"kind": "Grid",
"spec": {
"items": [{
"x": 0, "y": 0, "width": 12, "height": 6,
"content": {"$ref": "#/spec/panels/abc"}
}]
}
}]
}
}`)
if err := ValidateDashboardV2JSON(data); err != nil {
t.Fatalf("expected valid, got: %v", err)
}
}
// ══════════════════════════════════════════════
// Tests proving CUE-equivalent validation gaps are covered
// ══════════════════════════════════════════════
func TestValidateDashboardV2JSON_UnknownPanelPluginKind(t *testing.T) {
data := []byte(`{
"kind": "Dashboard",
"metadata": {"name": "test", "project": "signoz"},
"spec": {
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "NonExistentPanel", "spec": {}}
}
}
},
"layouts": []
}
}`)
err := ValidateDashboardV2JSON(data)
if err == nil {
t.Fatal("expected error for unknown panel plugin kind")
}
if !strings.Contains(err.Error(), "NonExistentPanel") {
t.Fatalf("error should mention the bad kind, got: %v", err)
}
}
func TestValidateDashboardV2JSON_UnknownQueryPluginKind(t *testing.T) {
data := []byte(`{
"kind": "Dashboard",
"metadata": {"name": "test", "project": "signoz"},
"spec": {
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "SigNozTimeSeriesPanel", "spec": {}},
"queries": [{
"kind": "TimeSeriesQuery",
"spec": {
"plugin": {"kind": "FakeQueryPlugin", "spec": {}}
}
}]
}
}
},
"layouts": []
}
}`)
err := ValidateDashboardV2JSON(data)
if err == nil {
t.Fatal("expected error for unknown query plugin kind")
}
if !strings.Contains(err.Error(), "FakeQueryPlugin") {
t.Fatalf("error should mention the bad kind, got: %v", err)
}
}
func TestValidateDashboardV2JSON_UnknownVariablePluginKind(t *testing.T) {
data := []byte(`{
"kind": "Dashboard",
"metadata": {"name": "test", "project": "signoz"},
"spec": {
"variables": [{
"kind": "ListVariable",
"spec": {
"name": "v1",
"allowAllValue": false,
"allowMultiple": false,
"plugin": {"kind": "FakeVariable", "spec": {}}
}
}],
"layouts": []
}
}`)
err := ValidateDashboardV2JSON(data)
if err == nil {
t.Fatal("expected error for unknown variable plugin kind")
}
if !strings.Contains(err.Error(), "FakeVariable") {
t.Fatalf("error should mention the bad kind, got: %v", err)
}
}
func TestValidateDashboardV2JSON_UnknownDatasourcePluginKind(t *testing.T) {
data := []byte(`{
"kind": "Dashboard",
"metadata": {"name": "test", "project": "signoz"},
"spec": {
"datasources": {
"ds1": {
"default": true,
"plugin": {"kind": "FakeDatasource", "spec": {}}
}
},
"layouts": []
}
}`)
err := ValidateDashboardV2JSON(data)
if err == nil {
t.Fatal("expected error for unknown datasource plugin kind")
}
if !strings.Contains(err.Error(), "FakeDatasource") {
t.Fatalf("error should mention the bad kind, got: %v", err)
}
}
func TestValidateDashboardV2JSON_WrongKind(t *testing.T) {
// Perses validates kind must be "Dashboard"
data := []byte(`{
"kind": "NotADashboard",
"metadata": {"name": "test", "project": "signoz"},
"spec": {"layouts": []}
}`)
err := ValidateDashboardV2JSON(data)
if err == nil {
t.Fatal("expected error for wrong kind")
}
}
func TestValidateDashboardV2JSON_EmptyPanelPluginKind(t *testing.T) {
data := []byte(`{
"kind": "Dashboard",
"metadata": {"name": "test", "project": "signoz"},
"spec": {
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "", "spec": {}}
}
}
},
"layouts": []
}
}`)
err := ValidateDashboardV2JSON(data)
if err == nil {
t.Fatal("expected error for empty panel plugin kind")
}
}
func TestValidateDashboardV2JSON_AllPanelPluginKinds(t *testing.T) {
kinds := []string{
"SigNozTimeSeriesPanel", "SigNozBarChartPanel", "SigNozNumberPanel",
"SigNozPieChartPanel", "SigNozTablePanel", "SigNozHistogramPanel", "SigNozListPanel",
}
for _, kind := range kinds {
data := []byte(`{
"kind": "Dashboard",
"metadata": {"name": "test", "project": "signoz"},
"spec": {
"panels": {"p1": {"kind": "Panel", "spec": {"plugin": {"kind": "` + kind + `", "spec": {}}}}},
"layouts": []
}
}`)
if err := ValidateDashboardV2JSON(data); err != nil {
t.Fatalf("expected %s to be valid, got: %v", kind, err)
}
}
}
func TestValidateDashboardV2JSON_AllQueryPluginKinds(t *testing.T) {
// Each kind needs a minimal valid spec.
cases := map[string]string{
"SigNozBuilderQuery": `{"name": "A", "signal": "metrics"}`,
"SigNozCompositeQuery": `{"queries": []}`,
"SigNozFormula": `{"name": "F1", "expression": "A + B"}`,
"SigNozPromQLQuery": `{"name": "A", "query": "up"}`,
"SigNozClickHouseSQL": `{"name": "A", "query": "SELECT 1"}`,
"SigNozTraceOperator": `{"name": "T1", "expression": "A => B"}`,
}
for kind, spec := range cases {
data := []byte(`{
"kind": "Dashboard",
"metadata": {"name": "test", "project": "signoz"},
"spec": {
"panels": {"p1": {"kind": "Panel", "spec": {
"plugin": {"kind": "SigNozTimeSeriesPanel", "spec": {}},
"queries": [{"kind": "TimeSeriesQuery", "spec": {"plugin": {"kind": "` + kind + `", "spec": ` + spec + `}}}]
}}},
"layouts": []
}
}`)
if err := ValidateDashboardV2JSON(data); err != nil {
t.Fatalf("expected %s to be valid, got: %v", kind, err)
}
}
}
func TestValidateDashboardV2JSON_AllVariablePluginKinds(t *testing.T) {
kinds := []string{
"SigNozDynamicVariable", "SigNozQueryVariable",
"SigNozCustomVariable", "SigNozTextboxVariable",
}
for _, kind := range kinds {
data := []byte(`{
"kind": "Dashboard",
"metadata": {"name": "test", "project": "signoz"},
"spec": {
"variables": [{"kind": "ListVariable", "spec": {
"name": "v", "allowAllValue": false, "allowMultiple": false,
"plugin": {"kind": "` + kind + `", "spec": {}}
}}],
"layouts": []
}
}`)
if err := ValidateDashboardV2JSON(data); err != nil {
t.Fatalf("expected %s to be valid, got: %v", kind, err)
}
}
}
// ══════════════════════════════════════════════
// Full dashboard JSON tests for structural + plugin spec validation.
// ══════════════════════════════════════════════
func TestValidateDashboardV2JSON_InvalidVariableKind(t *testing.T) {
data := []byte(`{
"kind": "Dashboard",
"metadata": {"name": "test", "project": "signoz"},
"spec": {
"variables": [{
"kind": "UnknownVariableKind",
"spec": {"name": "v"}
}],
"layouts": []
}
}`)
err := ValidateDashboardV2JSON(data)
if err == nil {
t.Fatal("expected error for unknown variable kind")
}
}
func TestValidateDashboardV2JSON_WrongFieldTypePanelSpec(t *testing.T) {
// fillSpans should be bool, not string — spec validation catches this now.
data := []byte(`{
"kind": "Dashboard",
"metadata": {"name": "test", "project": "signoz"},
"spec": {
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"plugin": {
"kind": "SigNozTimeSeriesPanel",
"spec": {"visualization": {"fillSpans": "notabool"}}
}
}
}
},
"layouts": []
}
}`)
err := ValidateDashboardV2JSON(data)
if err == nil {
t.Fatal("expected error for wrong type on fillSpans")
}
if !strings.Contains(err.Error(), "fillSpans") {
t.Fatalf("error should mention fillSpans, got: %v", err)
}
}
func TestValidateDashboardV2JSON_MultiplePanelsOneInvalid(t *testing.T) {
data := []byte(`{
"kind": "Dashboard",
"metadata": {"name": "test", "project": "signoz"},
"spec": {
"panels": {
"good": {
"kind": "Panel",
"spec": {"plugin": {"kind": "SigNozNumberPanel", "spec": {}}}
},
"bad": {
"kind": "Panel",
"spec": {"plugin": {"kind": "FakePanel", "spec": {}}}
}
},
"layouts": []
}
}`)
err := ValidateDashboardV2JSON(data)
if err == nil {
t.Fatal("expected error for invalid panel plugin kind")
}
if !strings.Contains(err.Error(), "FakePanel") {
t.Fatalf("error should mention FakePanel, got: %v", err)
}
}
func TestValidateDashboardV2JSON_DatasourceAndVariableAndPanel(t *testing.T) {
data := []byte(`{
"kind": "Dashboard",
"metadata": {"name": "full-test", "project": "signoz"},
"spec": {
"datasources": {
"ds": {
"default": true,
"plugin": {"kind": "SigNozDatasource", "spec": {}}
}
},
"variables": [
{
"kind": "ListVariable",
"spec": {
"name": "svc",
"allowAllValue": true,
"allowMultiple": false,
"plugin": {"kind": "SigNozDynamicVariable", "spec": {"name": "service.name", "source": "Metrics"}}
}
}
],
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"display": {"name": "My Panel"},
"plugin": {"kind": "SigNozTimeSeriesPanel", "spec": {}},
"queries": [{
"kind": "TimeSeriesQuery",
"spec": {
"plugin": {"kind": "SigNozBuilderQuery", "spec": {"name": "A", "signal": "metrics"}}
}
}]
}
}
},
"layouts": [{
"kind": "Grid",
"spec": {
"items": [{"x": 0, "y": 0, "width": 12, "height": 6, "content": {"$ref": "#/spec/panels/p1"}}]
}
}]
}
}`)
if err := ValidateDashboardV2JSON(data); err != nil {
t.Fatalf("expected valid full dashboard, got: %v", err)
}
}
func TestValidateDashboardV2JSON_MissingPanelPlugin(t *testing.T) {
data := []byte(`{
"kind": "Dashboard",
"metadata": {"name": "test", "project": "signoz"},
"spec": {
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "", "spec": {}}
}
}
},
"layouts": []
}
}`)
err := ValidateDashboardV2JSON(data)
if err == nil {
t.Fatal("expected error for empty plugin kind")
}
}
func TestValidateDashboardV2JSON_InvalidDatasourceValidPanels(t *testing.T) {
data := []byte(`{
"kind": "Dashboard",
"metadata": {"name": "test", "project": "signoz"},
"spec": {
"datasources": {
"ds": {"default": true, "plugin": {"kind": "PrometheusDatasource", "spec": {}}}
},
"panels": {
"p1": {"kind": "Panel", "spec": {"plugin": {"kind": "SigNozNumberPanel", "spec": {}}}}
},
"layouts": []
}
}`)
err := ValidateDashboardV2JSON(data)
if err == nil {
t.Fatal("expected error for invalid datasource plugin kind")
}
if !strings.Contains(err.Error(), "PrometheusDatasource") {
t.Fatalf("error should mention PrometheusDatasource, got: %v", err)
}
}
// ══════════════════════════════════════════════
// Invalid plugin spec tests — verify field-level validation per plugin kind
// ══════════════════════════════════════════════
func TestValidateDashboardV2JSON_InvalidPanelSpec_BadTimePreference(t *testing.T) {
data := []byte(`{
"kind": "Dashboard",
"metadata": {"name": "test", "project": "signoz"},
"spec": {
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"plugin": {
"kind": "SigNozTimeSeriesPanel",
"spec": {"visualization": {"timePreference": "last2Hr"}}
}
}
}
},
"layouts": []
}
}`)
err := ValidateDashboardV2JSON(data)
if err == nil {
t.Fatal("expected error for invalid timePreference")
}
if !strings.Contains(err.Error(), "timePreference") {
t.Fatalf("error should mention timePreference, got: %v", err)
}
}
func TestValidateDashboardV2JSON_InvalidPanelSpec_BadLegendPosition(t *testing.T) {
data := []byte(`{
"kind": "Dashboard",
"metadata": {"name": "test", "project": "signoz"},
"spec": {
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"plugin": {
"kind": "SigNozBarChartPanel",
"spec": {"legend": {"position": "top"}}
}
}
}
},
"layouts": []
}
}`)
err := ValidateDashboardV2JSON(data)
if err == nil {
t.Fatal("expected error for invalid legend position")
}
if !strings.Contains(err.Error(), "legend position") {
t.Fatalf("error should mention legend position, got: %v", err)
}
}
func TestValidateDashboardV2JSON_InvalidPanelSpec_BadThresholdFormat(t *testing.T) {
data := []byte(`{
"kind": "Dashboard",
"metadata": {"name": "test", "project": "signoz"},
"spec": {
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"plugin": {
"kind": "SigNozTimeSeriesPanel",
"spec": {"thresholds": [{"value": 100, "color": "Red", "format": "Color"}]}
}
}
}
},
"layouts": []
}
}`)
err := ValidateDashboardV2JSON(data)
if err == nil {
t.Fatal("expected error for invalid threshold format")
}
if !strings.Contains(err.Error(), "threshold format") {
t.Fatalf("error should mention threshold format, got: %v", err)
}
}
func TestValidateDashboardV2JSON_InvalidPanelSpec_BadComparisonOperator(t *testing.T) {
data := []byte(`{
"kind": "Dashboard",
"metadata": {"name": "test", "project": "signoz"},
"spec": {
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"plugin": {
"kind": "SigNozNumberPanel",
"spec": {"thresholds": [{"value": 100, "operator": "!=", "color": "Red", "format": "Text"}]}
}
}
}
},
"layouts": []
}
}`)
err := ValidateDashboardV2JSON(data)
if err == nil {
t.Fatal("expected error for invalid comparison operator")
}
if !strings.Contains(err.Error(), "comparison operator") {
t.Fatalf("error should mention comparison operator, got: %v", err)
}
}
func TestValidateDashboardV2JSON_InvalidPanelSpec_BadPrecision(t *testing.T) {
data := []byte(`{
"kind": "Dashboard",
"metadata": {"name": "test", "project": "signoz"},
"spec": {
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"plugin": {
"kind": "SigNozTimeSeriesPanel",
"spec": {"formatting": {"decimalPrecision": 9}}
}
}
}
},
"layouts": []
}
}`)
err := ValidateDashboardV2JSON(data)
if err == nil {
t.Fatal("expected error for invalid precision option")
}
if !strings.Contains(err.Error(), "precision") {
t.Fatalf("error should mention precision, got: %v", err)
}
}
func TestValidateDashboardV2JSON_InvalidQuerySpec_WrongFieldType(t *testing.T) {
// query should be string, not number
data := []byte(`{
"kind": "Dashboard",
"metadata": {"name": "test", "project": "signoz"},
"spec": {
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "SigNozTimeSeriesPanel", "spec": {}},
"queries": [{
"kind": "TimeSeriesQuery",
"spec": {
"plugin": {
"kind": "SigNozPromQLQuery",
"spec": {"name": "A", "query": 123}
}
}
}]
}
}
},
"layouts": []
}
}`)
err := ValidateDashboardV2JSON(data)
if err == nil {
t.Fatal("expected error for wrong type on PromQL query field")
}
}
func TestValidateDashboardV2JSON_ValidQuerySpec_ClickHouseSQL(t *testing.T) {
data := []byte(`{
"kind": "Dashboard",
"metadata": {"name": "test", "project": "signoz"},
"spec": {
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "SigNozTimeSeriesPanel", "spec": {}},
"queries": [{
"kind": "TimeSeriesQuery",
"spec": {
"plugin": {
"kind": "SigNozClickHouseSQL",
"spec": {"name": "A", "query": "SELECT 1"}
}
}
}]
}
}
},
"layouts": []
}
}`)
if err := ValidateDashboardV2JSON(data); err != nil {
t.Fatalf("expected valid ClickHouseSQL query, got: %v", err)
}
}
func TestValidateDashboardV2JSON_InvalidVariableSpec_MissingName(t *testing.T) {
data := []byte(`{
"kind": "Dashboard",
"metadata": {"name": "test", "project": "signoz"},
"spec": {
"variables": [{
"kind": "ListVariable",
"spec": {
"name": "v",
"allowAllValue": false,
"allowMultiple": false,
"plugin": {
"kind": "SigNozDynamicVariable",
"spec": {"source": "Metrics"}
}
}
}],
"layouts": []
}
}`)
// DynamicVariableSpec requires name — but since Go's json.Unmarshal zero-values
// missing fields, "name": "" is accepted. This is a known limitation.
err := ValidateDashboardV2JSON(data)
if err != nil {
t.Fatalf("expected no error (missing name zero-values to empty string), got: %v", err)
}
}
func TestValidateDashboardV2JSON_InvalidVariableSpec_WrongFieldType(t *testing.T) {
data := []byte(`{
"kind": "Dashboard",
"metadata": {"name": "test", "project": "signoz"},
"spec": {
"variables": [{
"kind": "ListVariable",
"spec": {
"name": "v",
"allowAllValue": false,
"allowMultiple": false,
"plugin": {
"kind": "SigNozDynamicVariable",
"spec": {"name": 123, "source": "Metrics"}
}
}
}],
"layouts": []
}
}`)
err := ValidateDashboardV2JSON(data)
if err == nil {
t.Fatal("expected error for wrong type on variable plugin name field")
}
}
func TestValidateDashboardV2JSON_PrecisionDefaultsTo2(t *testing.T) {
// When decimalPrecision is absent, it should default to 2 when read back.
data := []byte(`{
"kind": "Dashboard",
"metadata": {"name": "test", "project": "signoz"},
"spec": {
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"plugin": {
"kind": "SigNozTimeSeriesPanel",
"spec": {"formatting": {"unit": "bytes"}}
}
}
}
},
"layouts": []
}
}`)
var d StorableDashboardDataV2
if err := json.Unmarshal(data, &d); err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
specJSON, _ := json.Marshal(d.Spec.Panels["p1"].Spec.Plugin.Spec)
var spec TimeSeriesPanelSpec
if err := json.Unmarshal(specJSON, &spec); err != nil {
t.Fatalf("unmarshal spec failed: %v", err)
}
if spec.Formatting.DecimalPrecision.Value() != 2 {
t.Fatalf("expected default precision 2, got %v", spec.Formatting.DecimalPrecision.Value())
}
}

View File

@@ -0,0 +1,324 @@
package dashboardtypes
import (
"encoding/json"
"fmt"
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
// ══════════════════════════════════════════════
// SigNoz variable plugin specs
// ══════════════════════════════════════════════
type DynamicVariableSpec struct {
Name string `json:"name"`
Source string `json:"source"`
}
type QueryVariableSpec struct {
QueryValue string `json:"queryValue"`
}
type CustomVariableSpec struct {
CustomValue string `json:"customValue"`
}
type TextboxVariableSpec struct{}
// ══════════════════════════════════════════════
// SigNoz query plugin specs — aliased from querybuildertypesv5
// ══════════════════════════════════════════════
type (
MetricBuilderQuerySpec = qb.QueryBuilderQuery[qb.MetricAggregation]
LogBuilderQuerySpec = qb.QueryBuilderQuery[qb.LogAggregation]
TraceBuilderQuerySpec = qb.QueryBuilderQuery[qb.TraceAggregation]
CompositeQuerySpec = qb.CompositeQuery
QueryEnvelope = qb.QueryEnvelope
FormulaSpec = qb.QueryBuilderFormula
PromQLQuerySpec = qb.PromQuery
ClickHouseSQLQuerySpec = qb.ClickHouseQuery
TraceOperatorSpec = qb.QueryBuilderTraceOperator
)
// BuilderQuerySpec dispatches to MetricBuilderQuerySpec, LogBuilderQuerySpec,
// or TraceBuilderQuerySpec based on the signal field.
type BuilderQuerySpec struct {
Spec any
}
func (b *BuilderQuerySpec) UnmarshalJSON(data []byte) error {
var peek struct {
Signal string `json:"signal"`
}
if err := json.Unmarshal(data, &peek); err != nil {
return err
}
switch peek.Signal {
case "metrics":
var spec MetricBuilderQuerySpec
if err := json.Unmarshal(data, &spec); err != nil {
return err
}
b.Spec = spec
case "logs":
var spec LogBuilderQuerySpec
if err := json.Unmarshal(data, &spec); err != nil {
return err
}
b.Spec = spec
case "traces":
var spec TraceBuilderQuerySpec
if err := json.Unmarshal(data, &spec); err != nil {
return err
}
b.Spec = spec
default:
return fmt.Errorf("invalid signal %q: must be metrics, logs, or traces", peek.Signal)
}
return nil
}
// ══════════════════════════════════════════════
// SigNoz panel plugin specs
// ══════════════════════════════════════════════
type TimeSeriesPanelSpec struct {
Visualization TimeSeriesVisualization `json:"visualization"`
Formatting PanelFormatting `json:"formatting"`
Axes Axes `json:"axes"`
Legend Legend `json:"legend"`
ContextLinks []ContextLinkProps `json:"contextLinks"`
Thresholds []ThresholdWithLabel `json:"thresholds"`
}
type TimeSeriesVisualization struct {
TimePreference TimePreference `json:"timePreference"`
FillSpans bool `json:"fillSpans"`
}
type BarChartPanelSpec struct {
Visualization BarChartVisualization `json:"visualization"`
Formatting PanelFormatting `json:"formatting"`
Axes Axes `json:"axes"`
Legend Legend `json:"legend"`
ContextLinks []ContextLinkProps `json:"contextLinks"`
Thresholds []ThresholdWithLabel `json:"thresholds"`
}
type BarChartVisualization struct {
TimePreference TimePreference `json:"timePreference"`
FillSpans bool `json:"fillSpans"`
StackedBarChart bool `json:"stackedBarChart"`
}
type NumberPanelSpec struct {
Visualization BasicVisualization `json:"visualization"`
Formatting PanelFormatting `json:"formatting"`
ContextLinks []ContextLinkProps `json:"contextLinks"`
Thresholds []ComparisonThreshold `json:"thresholds"`
}
type PieChartPanelSpec struct {
Visualization BasicVisualization `json:"visualization"`
Formatting PanelFormatting `json:"formatting"`
Legend Legend `json:"legend"`
ContextLinks []ContextLinkProps `json:"contextLinks"`
}
type TablePanelSpec struct {
Visualization BasicVisualization `json:"visualization"`
Formatting TableFormatting `json:"formatting"`
ContextLinks []ContextLinkProps `json:"contextLinks"`
Thresholds []TableThreshold `json:"thresholds"`
}
type TableFormatting struct {
ColumnUnits map[string]string `json:"columnUnits"`
DecimalPrecision PrecisionOption `json:"decimalPrecision"`
}
type TableThreshold struct {
ComparisonThreshold
TableOptions string `json:"tableOptions"`
}
type HistogramPanelSpec struct {
HistogramBuckets HistogramBuckets `json:"histogramBuckets"`
Legend Legend `json:"legend"`
ContextLinks []ContextLinkProps `json:"contextLinks"`
}
type HistogramBuckets struct {
BucketCount *float64 `json:"bucketCount"`
BucketWidth *float64 `json:"bucketWidth"`
MergeAllActiveQueries bool `json:"mergeAllActiveQueries"`
}
type ListPanelSpec struct {
SelectedLogFields []LogField `json:"selectedLogFields"`
SelectedTracesFields []telemetrytypes.TelemetryFieldKey `json:"selectedTracesFields"`
}
type LogField struct {
Name string `json:"name"`
Type string `json:"type"`
DataType string `json:"dataType"`
}
// ══════════════════════════════════════════════
// Panel common types
// ══════════════════════════════════════════════
type ContextLinkProps struct {
URL string `json:"url"`
Label string `json:"label"`
}
type Axes struct {
SoftMin *float64 `json:"softMin"`
SoftMax *float64 `json:"softMax"`
IsLogScale bool `json:"isLogScale"`
}
type BasicVisualization struct {
TimePreference TimePreference `json:"timePreference"`
}
type PanelFormatting struct {
Unit string `json:"unit"`
DecimalPrecision PrecisionOption `json:"decimalPrecision"`
}
type Legend struct {
Position LegendPosition `json:"position"`
CustomColors map[string]string `json:"customColors"`
}
type ThresholdWithLabel struct {
Value float64 `json:"value"`
Unit string `json:"unit"`
Color string `json:"color"`
Format ThresholdFormat `json:"format"`
Label string `json:"label"`
}
type ComparisonThreshold struct {
Value float64 `json:"value"`
Operator ComparisonOperator `json:"operator"`
Unit string `json:"unit"`
Color string `json:"color"`
Format ThresholdFormat `json:"format"`
}
// ══════════════════════════════════════════════
// Constrained scalar types (enum validation via custom UnmarshalJSON)
// ══════════════════════════════════════════════
// TimePreference: "globalTime" | "last5Min" | "last15Min" | "last30Min" | "last1Hr" | "last6Hr" | "last1Day" | "last3Days" | "last1Week" | "last1Month"
type TimePreference string
func (t *TimePreference) UnmarshalJSON(data []byte) error {
var v string
if err := json.Unmarshal(data, &v); err != nil {
return err
}
switch v {
case "globalTime", "last5Min", "last15Min", "last30Min", "last1Hr", "last6Hr", "last1Day", "last3Days", "last1Week", "last1Month":
*t = TimePreference(v)
return nil
default:
return fmt.Errorf("invalid timePreference %q", v)
}
}
// LegendPosition: "bottom" | "right"
type LegendPosition string
func (l *LegendPosition) UnmarshalJSON(data []byte) error {
var v string
if err := json.Unmarshal(data, &v); err != nil {
return err
}
switch v {
case "bottom", "right":
*l = LegendPosition(v)
return nil
default:
return fmt.Errorf("invalid legend position %q: must be bottom or right", v)
}
}
// ThresholdFormat: "Text" | "Background"
type ThresholdFormat string
func (f *ThresholdFormat) UnmarshalJSON(data []byte) error {
var v string
if err := json.Unmarshal(data, &v); err != nil {
return err
}
switch v {
case "Text", "Background":
*f = ThresholdFormat(v)
return nil
default:
return fmt.Errorf("invalid threshold format %q: must be Text or Background", v)
}
}
// ComparisonOperator: ">" | "<" | ">=" | "<=" | "="
type ComparisonOperator string
func (o *ComparisonOperator) UnmarshalJSON(data []byte) error {
var v string
if err := json.Unmarshal(data, &v); err != nil {
return err
}
switch v {
case ">", "<", ">=", "<=", "=":
*o = ComparisonOperator(v)
return nil
default:
return fmt.Errorf("invalid comparison operator %q", v)
}
}
// PrecisionOption: 0 | 1 | 2 | 3 | 4 | "full". Default is 2.
type PrecisionOption struct {
value any
}
func (p PrecisionOption) Value() any {
if p.value == nil {
return 2
}
return p.value
}
func (p *PrecisionOption) UnmarshalJSON(data []byte) error {
var s string
if err := json.Unmarshal(data, &s); err == nil {
if s != "full" {
return fmt.Errorf("invalid precision option %q: string value must be \"full\"", s)
}
p.value = s
return nil
}
var n int
if err := json.Unmarshal(data, &n); err == nil {
switch n {
case 0, 1, 2, 3, 4:
p.value = n
return nil
default:
return fmt.Errorf("invalid precision option %d: must be 0, 1, 2, 3, or 4", n)
}
}
return fmt.Errorf("invalid precision option: must be an int (0-4) or \"full\"")
}
func (p PrecisionOption) MarshalJSON() ([]byte, error) {
return json.Marshal(p.Value())
}

View File

@@ -0,0 +1,874 @@
{
"kind": "Dashboard",
"metadata": {
"name": "the-everything-dashboard",
"project": "signoz"
},
"spec": {
"display": {
"name": "The everything dashboard",
"description": "Trying to cover as many concepts here as possible"
},
"duration": "1h",
"datasources": {
"SigNozDatasource": {
"default": true,
"plugin": {
"kind": "SigNozDatasource",
"spec": {}
}
}
},
"variables": [
{
"kind": "ListVariable",
"spec": {
"name": "serviceName",
"display": {
"name": "serviceName"
},
"allowAllValue": true,
"allowMultiple": false,
"sort": "none",
"plugin": {
"kind": "SigNozDynamicVariable",
"spec": {
"name": "service.name",
"source": "Metrics"
}
}
}
},
{
"kind": "ListVariable",
"spec": {
"name": "statusCodesFromQuery",
"display": {
"name": "statusCodesFromQuery"
},
"allowAllValue": true,
"allowMultiple": true,
"sort": "alphabetical-asc",
"plugin": {
"kind": "SigNozQueryVariable",
"spec": {
"queryValue": "SELECT JSONExtractString(labels, 'http.status_code') AS status_code FROM signoz_metrics.distributed_time_series_v4_1day WHERE status_code != '' GROUP BY status_code"
}
}
}
},
{
"kind": "ListVariable",
"spec": {
"name": "limit",
"display": {
"name": "limit"
},
"allowAllValue": false,
"allowMultiple": false,
"sort": "none",
"plugin": {
"kind": "SigNozCustomVariable",
"spec": {
"customValue": "1,10,20,40,80,160,200"
}
}
}
},
{
"kind": "TextVariable",
"spec": {
"name": "textboxvar",
"display": {
"name": "textboxvar"
},
"value": "defaultvaluegoeshere",
"plugin": {
"kind": "SigNozTextboxVariable",
"spec": {}
}
}
}
],
"panels": {
"24e2697b": {
"kind": "Panel",
"spec": {
"display": {
"name": "total resp size",
"description": ""
},
"plugin": {
"kind": "SigNozTimeSeriesPanel",
"spec": {
"visualization": {
"fillSpans": true
},
"formatting": {
"unit": "By",
"decimalPrecision": 3
},
"axes": {
"softMax": 800,
"isLogScale": true
},
"legend": {
"position": "right",
"customColors": {
"{service.name=\"sampleapp-gateway\"}": "#9ea5f7"
}
},
"contextLinks": [
{
"label": "View service details",
"url": "http://localhost:8080/{{_service.name}}?dfddf=%7B%7Blimit%7D%7D"
}
],
"thresholds": [
{
"value": 1024,
"unit": "By",
"color": "Red",
"format": "Text",
"label": "upper limit"
},
{
"value": 100,
"unit": "By",
"color": "Orange",
"format": "Text",
"label": "kinda bad"
}
]
}
},
"queries": [
{
"kind": "TimeSeriesQuery",
"spec": {
"plugin": {
"kind": "SigNozBuilderQuery",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [
{
"metricName": "http.server.response.body.size.sum",
"reduceTo": "sum",
"spaceAggregation": "sum",
"timeAggregation": "rate"
}
],
"filter": {
"expression": "http.response.status_code IN $statusCodesFromQuery"
},
"groupBy": [
{
"name": "service.name",
"fieldDataType": "string",
"fieldContext": "tag"
}
]
}
}
}
}
]
}
},
"ff2f72f1": {
"kind": "Panel",
"spec": {
"display": {
"name": "fraction of calls",
"description": ""
},
"plugin": {
"kind": "SigNozTimeSeriesPanel",
"spec": {
"visualization": {
"fillSpans": true
},
"formatting": {
"decimalPrecision": 1
},
"thresholds": [
{
"value": 1,
"color": "Blue",
"format": "Background",
"label": "max possible"
}
]
}
},
"queries": [
{
"kind": "TimeSeriesQuery",
"spec": {
"plugin": {
"kind": "SigNozCompositeQuery",
"spec": {
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"disabled": true,
"aggregations": [
{
"metricName": "signoz_calls_total",
"reduceTo": "sum",
"spaceAggregation": "sum",
"timeAggregation": "rate"
}
],
"filter": {
"expression": "service.name IN $serviceName AND http.status_code IN $statusCodesFromQuery"
}
}
},
{
"type": "builder_query",
"spec": {
"name": "B",
"signal": "metrics",
"disabled": true,
"aggregations": [
{
"metricName": "signoz_calls_total",
"reduceTo": "sum",
"spaceAggregation": "sum",
"timeAggregation": "rate"
}
],
"filter": {
"expression": "service.name in $serviceName"
}
}
},
{
"type": "builder_formula",
"spec": {
"name": "F1",
"expression": "A / B"
}
}
]
}
}
}
}
]
}
},
"011605e7": {
"kind": "Panel",
"spec": {
"display": {
"name": "total resp size"
},
"plugin": {
"kind": "SigNozBarChartPanel",
"spec": {
"visualization": {
"stackedBarChart": false
},
"formatting": {
"unit": "By"
}
}
},
"queries": [
{
"kind": "TimeSeriesQuery",
"spec": {
"plugin": {
"kind": "SigNozBuilderQuery",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [
{
"metricName": "http.server.response.body.size.sum",
"reduceTo": "sum",
"spaceAggregation": "sum",
"timeAggregation": "rate"
}
],
"filter": {
"expression": "http.response.status_code IN $statusCodesFromQuery"
},
"groupBy": [
{
"name": "service.name",
"fieldDataType": "string",
"fieldContext": "tag"
}
]
}
}
}
}
]
}
},
"e23516fc": {
"kind": "Panel",
"spec": {
"display": {
"name": "num traces for service"
},
"plugin": {
"kind": "SigNozNumberPanel",
"spec": {
"formatting": {
"unit": "none",
"decimalPrecision": 1
},
"thresholds": [
{
"value": 1200000,
"operator": ">",
"unit": "none",
"color": "Red",
"format": "Text"
},
{
"value": 1200000,
"operator": "<=",
"unit": "none",
"color": "Green",
"format": "Text"
}
]
}
},
"queries": [
{
"kind": "TimeSeriesQuery",
"spec": {
"plugin": {
"kind": "SigNozBuilderQuery",
"spec": {
"name": "A",
"signal": "traces",
"aggregations": [
{
"expression": "count() "
}
],
"filter": {
"expression": "service.name = $serviceName "
}
}
}
}
}
]
}
},
"130c8d6b": {
"kind": "Panel",
"spec": {
"display": {
"name": "num logs for service"
},
"plugin": {
"kind": "SigNozNumberPanel",
"spec": {
"formatting": {
"unit": "none",
"decimalPrecision": 1
}
}
},
"queries": [
{
"kind": "TimeSeriesQuery",
"spec": {
"plugin": {
"kind": "SigNozBuilderQuery",
"spec": {
"name": "A",
"signal": "logs",
"aggregations": [
{
"expression": "count() "
}
],
"filter": {
"expression": "service.name = $serviceName "
}
}
}
}
}
]
}
},
"246f7c6d": {
"kind": "Panel",
"spec": {
"display": {
"name": "num traces for service per resp code"
},
"plugin": {
"kind": "SigNozPieChartPanel",
"spec": {
"formatting": {
"decimalPrecision": 1
},
"legend": {
"customColors": {
"\"201\"": "#2bc051",
"\"400\"": "#cc462e",
"\"500\"": "#ff0000"
}
}
}
},
"queries": [
{
"kind": "TimeSeriesQuery",
"spec": {
"plugin": {
"kind": "SigNozBuilderQuery",
"spec": {
"name": "A",
"signal": "traces",
"aggregations": [
{
"expression": "count() "
}
],
"filter": {
"expression": "service.name = $serviceName isEntryPoint = 'true'"
},
"groupBy": [
{
"name": "http.response.status_code",
"fieldDataType": "float64",
"fieldContext": "tag"
}
],
"legend": "\"{{http.response.status_code}}\""
}
}
}
}
]
}
},
"21f7d4d0": {
"kind": "Panel",
"spec": {
"display": {
"name": "average latency per service"
},
"plugin": {
"kind": "SigNozTablePanel",
"spec": {
"formatting": {
"columnUnits": {
"A": "s"
}
},
"thresholds": [
{
"value": 1,
"operator": ">",
"unit": "min",
"color": "Red",
"format": "Text",
"tableOptions": "A"
}
]
}
},
"queries": [
{
"kind": "TimeSeriesQuery",
"spec": {
"plugin": {
"kind": "SigNozClickHouseSQL",
"spec": {
"name": "A",
"query": "WITH\n __spatial_aggregation_cte AS\n (\n SELECT\n toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(60)) AS ts,\n `service.name`,\n le,\n sum(value) / 60 AS value\n FROM signoz_metrics.distributed_samples_v4 AS points\n INNER JOIN\n (\n SELECT\n fingerprint,\n JSONExtractString(labels, 'service.name') AS `service.name`,\n JSONExtractString(labels, 'le') AS le\n FROM signoz_metrics.time_series_v4\n WHERE (metric_name IN ('signoz_latency.bucket')) AND (LOWER(temporality) LIKE LOWER('delta')) AND (__normalized = 0)\n GROUP BY\n fingerprint,\n `service.name`,\n le\n ) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint\n WHERE metric_name IN ('signoz_latency.bucket')\n GROUP BY\n ts,\n `service.name`,\n le\n ),\n __histogramCTE AS\n (\n SELECT\n ts,\n `service.name`,\n histogramQuantile(arrayMap(x -> toFloat64(x), groupArray(le)), groupArray(value), 0.9) AS value\n FROM __spatial_aggregation_cte\n GROUP BY\n `service.name`,\n ts\n ORDER BY\n `service.name` ASC,\n ts ASC\n )\nSELECT\n `service.name` AS service,\n avg(value) AS A\nFROM __histogramCTE\nGROUP BY `service.name`"
}
}
}
}
]
}
},
"ad5fd556": {
"kind": "Panel",
"spec": {
"display": {
"name": "logs from service"
},
"plugin": {
"kind": "SigNozListPanel",
"spec": {
"selectedLogFields": [
{
"name": "timestamp",
"type": "log",
"dataType": ""
},
{
"name": "body",
"type": "log",
"dataType": ""
},
{
"name": "error",
"type": "",
"dataType": "string"
}
]
}
},
"queries": [
{
"kind": "LogQuery",
"spec": {
"plugin": {
"kind": "SigNozBuilderQuery",
"spec": {
"name": "A",
"signal": "logs",
"aggregations": [
{
"expression": "count() "
}
],
"filter": {
"expression": "service.name = $serviceName"
},
"groupBy": [],
"order": [
{
"key": {
"name": "timestamp"
},
"direction": "desc"
},
{
"key": {
"name": "id"
},
"direction": "desc"
}
]
}
}
}
}
]
}
},
"f07b59ee": {
"kind": "Panel",
"spec": {
"display": {
"name": "response size buckets"
},
"plugin": {
"kind": "SigNozHistogramPanel",
"spec": {
"histogramBuckets": {
"bucketCount": 60,
"mergeAllActiveQueries": true
}
}
},
"queries": [
{
"kind": "TimeSeriesQuery",
"spec": {
"plugin": {
"kind": "SigNozBuilderQuery",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [
{
"metricName": "http.server.response.body.size.bucket",
"reduceTo": "avg",
"spaceAggregation": "p90",
"timeAggregation": "rate"
}
]
}
}
}
}
]
}
},
"e1a41831": {
"kind": "Panel",
"spec": {
"display": {
"name": "trace operator",
"description": ""
},
"plugin": {
"kind": "SigNozTimeSeriesPanel",
"spec": {
"legend": {
"position": "right"
}
}
},
"queries": [
{
"kind": "TimeSeriesQuery",
"spec": {
"plugin": {
"kind": "SigNozCompositeQuery",
"spec": {
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "traces",
"aggregations": [
{
"expression": "count() "
}
],
"filter": {
"expression": "service.name = 'sampleapp-gateway' "
},
"legend": "Gateway"
}
},
{
"type": "builder_query",
"spec": {
"name": "B",
"signal": "traces",
"aggregations": [
{
"expression": "count() "
}
],
"filter": {
"expression": "http.response.status_code = 200"
},
"legend": "$serviceName"
}
},
{
"type": "builder_trace_operator",
"spec": {
"name": "T1",
"aggregations": [
{
"expression": "count()",
"alias": "request_count"
}
]
}
}
]
}
}
}
}
]
}
},
"f0d70491": {
"kind": "Panel",
"spec": {
"display": {
"name": "no results in this promql",
"description": ""
},
"plugin": {
"kind": "SigNozTimeSeriesPanel",
"spec": {}
},
"queries": [
{
"kind": "TimeSeriesQuery",
"spec": {
"plugin": {
"kind": "SigNozCompositeQuery",
"spec": {
"queries": [
{
"type": "promql",
"spec": {
"name": "A",
"query": "sum(rate(flask_exporter_info[5m]))"
}
},
{
"type": "promql",
"spec": {
"name": "B",
"query": "sum(increase(flask_exporter_info[5m]))"
}
}
]
}
}
}
}
]
}
},
"0e6eb4ca": {
"kind": "Panel",
"spec": {
"display": {
"name": "no results in this promql",
"description": ""
},
"plugin": {
"kind": "SigNozTimeSeriesPanel",
"spec": {}
},
"queries": [
{
"kind": "TimeSeriesQuery",
"spec": {
"plugin": {
"kind": "SigNozPromQLQuery",
"spec": {
"name": "A",
"query": "sum(rate(flask_exporter_info[5m]))"
}
}
}
}
]
}
}
},
"layouts": [
{
"kind": "Grid",
"spec": {
"items": [
{
"x": 0,
"y": 0,
"width": 6,
"height": 6,
"content": {
"$ref": "#/spec/panels/24e2697b"
}
},
{
"x": 6,
"y": 0,
"width": 6,
"height": 6,
"content": {
"$ref": "#/spec/panels/ff2f72f1"
}
},
{
"x": 0,
"y": 6,
"width": 6,
"height": 6,
"content": {
"$ref": "#/spec/panels/011605e7"
}
},
{
"x": 6,
"y": 6,
"width": 6,
"height": 3,
"content": {
"$ref": "#/spec/panels/e23516fc"
}
},
{
"x": 6,
"y": 9,
"width": 6,
"height": 3,
"content": {
"$ref": "#/spec/panels/130c8d6b"
}
},
{
"x": 0,
"y": 12,
"width": 6,
"height": 6,
"content": {
"$ref": "#/spec/panels/246f7c6d"
}
},
{
"x": 6,
"y": 12,
"width": 6,
"height": 6,
"content": {
"$ref": "#/spec/panels/21f7d4d0"
}
},
{
"x": 0,
"y": 18,
"width": 6,
"height": 6,
"content": {
"$ref": "#/spec/panels/ad5fd556"
}
},
{
"x": 6,
"y": 18,
"width": 6,
"height": 6,
"content": {
"$ref": "#/spec/panels/f07b59ee"
}
},
{
"x": 0,
"y": 24,
"width": 12,
"height": 6,
"content": {
"$ref": "#/spec/panels/e1a41831"
}
},
{
"x": 0,
"y": 30,
"width": 6,
"height": 6,
"content": {
"$ref": "#/spec/panels/f0d70491"
}
},
{
"x": 6,
"y": 30,
"width": 6,
"height": 6,
"content": {
"$ref": "#/spec/panels/0e6eb4ca"
}
}
]
}
}
]
}
}