Compare commits

...

32 Commits

Author SHA1 Message Date
Vinícius Lourenço
fc7510434a feat(sso-configuration): change roles selector to allow custom roles 2026-06-29 12:52:25 -03:00
Tushar Vats
7c3ac5b221 chore: json tags for error struct (#11886)
* chore: json tags for error struct

* fix: udpate unit tests
2026-06-29 12:17:00 +00:00
Naman Verma
c1d40d7359 fix: schema fixes based on UI and MCP integration review (#11718)
* fix: change schema properties based on UI integration review

* fix: check that panels referred in layouts exist

* chore: extract out validate panels method

* test: add test for missing spec prefix in layout

* fix: reject dashbaords that have vars with the same name

* fix: add additional error info on patch application error

* fix: add validations to list variable that text variable has

* fix: replicate text variable spec in signoz to make name required

* chore: replicate variable.sort into signoz

* chore: remove unsupported enum values (causing errors right now)

* chore: fix variable sort type errors

* fix: add back enum values

* fix: reject single-element list default when allowMultiple is false in list variables

* fix: remove unused import

* fix: make display required

* chore: make queries non-nullable

* fix: properly define default value and datasource plugin spec's api specs

* fix: promote variable defaultValue to a named oneOf component

The list variable defaultValue was an inline string | []string oneOf,
which downstream codegen can't canonicalize: tfplugingen-openapi rejects
the inline scalar-or-array multi-type, and oapi-codegen has no named type
to attach the union's Marshal/UnmarshalJSON to.

Shape the vendored variable.DefaultValue as the named VariableDefaultValue
oneOf via a reflector InterceptSchema hook and let defaultValue $ref it,
instead of overriding the property inline. Regenerate the OpenAPI spec and
frontend client accordingly.

* refactor: move VariableDefaultValue oneOf into dashboardtypes

Define VariableDefaultValue in dashboardtypes as a subclass of the perses
variable.DefaultValue and attach the string | []string oneOf via its own
PrepareJSONSchema, instead of shaping the perses type from an openapi.go
InterceptSchema hook. This keeps the union's schema next to its type.

The named component is now DashboardtypesVariableDefaultValue; regenerate
the OpenAPI spec and frontend client accordingly.

---------

Co-authored-by: Ashwin Bhatkal <ashwin96@gmail.com>
Co-authored-by: grandwizard28 <vibhupandey28@gmail.com>
2026-06-29 12:02:33 +00:00
Nityananda Gohain
c5c1913f97 fix(querier): pad clamped time range for trace_id-filtered logs (#11800)
* fix(querier): pad clamped time range for trace_id-filtered logs

* chore: use a struct instead

* chore: more cleanup
2026-06-29 11:19:39 +00:00
Naman Verma
5ab6636863 feat: add api to fetch v2 dashboards for a metric name (#11784)
* feat: add api to fetch v2 dashboards for a metric name

* chore: switch to query param

* chore: generate API specs

* chore: use proper struct in return type of GetByMetricNamesV2

* chore: add method for escaping like patterns in sqlstore formatter

* fix: use only one db call in GetByMetricNamesV2

* chore: dont use type alias for list of references
2026-06-29 10:24:23 +00:00
Aditya Singh
3680cc7779 refactor(trace-details): remove dead Trace Details V2 code (#11805)
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(trace-details): move no-data component from v2 code to v3 before v2 cleanup

* feat(trace-details): move span logs out from v2 to v3 before cleanup

* feat(trace-details): move events  out from v2 to v3 before cleanup

* feat(trace-details): remove usage of getTraceV2 from V3 code

* fix(trace-details): fix serviceName path in trace funnel

* feat(trace-details): remove Trace Details V2 page and its module import

* feat(trace-details): remove unused trace details v2 code

* feat(trace-details): fix failing test
2026-06-29 08:19:14 +00:00
Aditya Singh
7cfedc46c4 refactor(trace-details): migrate SpanLogs/Events/NoData + funnel to V3 (pre-V2-cleanup) (#11855)
* feat(trace-details): move no-data component from v2 code to v3 before v2 cleanup

* feat(trace-details): move span logs out from v2 to v3 before cleanup

* feat(trace-details): move events  out from v2 to v3 before cleanup

* feat(trace-details): remove usage of getTraceV2 from V3 code

* fix(trace-details): fix serviceName path in trace funnel

* feat(trace-details): move no-data component from v2 code to v3 before v2 cleanup

* feat(trace-details): move span logs out from v2 to v3 before cleanup

* feat(trace-details): move events  out from v2 to v3 before cleanup

* feat(trace-details): remove usage of getTraceV2 from V3 code

* fix(trace-details): fix serviceName path in trace funnel

* feat(trace-details): fix css style conventions

* feat(trace-details): events code refactor

* feat(trace-details): fix tests
2026-06-29 07:54:58 +00:00
Gaurav Tewari
69ba25d82e feat(llm-pricing): foundation — route, permission & page shell (1/5) (#11750)
* feat(llm-pricing): add model pricing foundation (route, permission, page shell)

* refactor: migrate to css moduel

* refactor: shell

* fix: add key to route

* feat: add flags

* refactor: update routes

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-06-29 05:56:37 +00:00
Yunus M
e42159f257 feat: enhance context picker with empty state handling and prefill functionality (#11869)
* feat: enhance context picker with empty state handling and prefill functionality

* feat: implement empty state CTA styling and testing for context picker
2026-06-29 05:51:06 +00:00
Ashwin Bhatkal
1e887dc9c9 chore(e2eci): source Playwright browsers from official image (#11789)
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
* chore(e2eci): source Playwright browsers from official image

Replace the runtime `pnpm playwright install` (which fetches browser
binaries from the Playwright CDN and intermittently times out) with a
copy out of the official `mcr.microsoft.com/playwright:v1.57.0-noble`
image. The job stays on ubuntu-latest so the testcontainers-based
backend bring-up is untouched; only OS deps are still installed via apt
(`install-deps`), which does not hit the flaky CDN.

Bump @playwright/test from the 1.57.0 alpha to stable 1.57.0 so the
client version matches the image tag (browser builds line up).

Closes #11772

* fix(e2e): open panel ⋮ menu via click, not hover (#11881)
2026-06-28 12:15:55 +00:00
Vinicius Lourenço
7646aabb2b fix(member-settings): sidenav button not redirecting to correct invite members modal (#11871)
Some checks failed
build-staging / prepare (push) Has been cancelled
Release Drafter / update_release_draft (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
2026-06-26 17:18:19 +00:00
Vinicius Lourenço
18851afb6d refactor(onboarding): drop old page (#11860) 2026-06-26 17:16:24 +00:00
Srikanth Chekuri
d5221a6ff3 feat(metrics): add per-metric label-reduction rules (#11849)
* chore(metric-reduction): scaffold metric volume control API (types, routes, stubs)

* chore: generate spec

* chore: fix required and generate frontend types

* chore(metric-reduction): add module implementation (#11857)
2026-06-26 17:16:04 +00:00
Vikrant Gupta
19712c3579 feat(authdomain): support custom roles in SSO group mapping (#11858)
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(authdomain): support custom roles in SSO group mapping

Role mappings now reference SigNoz roles by name (custom or managed)
instead of the legacy ADMIN/EDITOR/VIEWER enum. Legacy values sent by a
client are normalized to their managed names (signoz-admin/editor/viewer),
and a migration normalizes existing stored mappings.

- normalize legacy role names on unmarshal; resolve all matched roles on
  SSO callback (union of matched groups, else default, else viewer)
- validate referenced roles exist on auth domain create/update
- block deleting a role referenced by any auth domain mapping, naming the
  referencing domains (OnBeforeRoleDelete now also passes the role name)
- migration 096 to rewrite legacy names in existing auth_domain mappings

* refactor(sqlmigration): decode SSO role mapping into a typed struct

Decode the roleMapping object into a small frozen struct instead of
poking at json.RawMessage per field. The top-level config stays a raw
map so the other config fields are preserved untouched.

* fix(authdomain): validate SSO role attribute claim against existing roles

When the role mapping uses the IDP role attribute, the claimed role is
assigned only if it exists in the org (resolved case-insensitively, with
legacy ADMIN/EDITOR/VIEWER mapped to their managed names); otherwise the
mapping falls through to group mappings and the default role. This lets
custom roles be assigned via the attribute and avoids failing login on an
unknown claim.

Also normalize legacy role names case-insensitively and fold the
single-use managed-role lookup into NormalizeRoleName.
2026-06-26 14:26:43 +00:00
ElyesLaaribi
e31683be11 fix(metrics-explorer): resolve pagination overlap in table (#11812)
* fix: resolve pagination overlap in metrics explorer table

* fix: add padding-bottom to metrics table container per review feedback
2026-06-26 13:59:04 +00:00
Tushar Vats
1700ad06e6 chore: refactor error response + normalize ClickHouse telemetrystore errors (#11740)
* chore: mark required and nullable in json tag, renamed methods, and added more functionality

* fix: unit test

* fix: cast clickhouse exceptions

* fix: go mod tidy

* fix: telemetrystore now returns explicit base errors

* fix: typo

* fix: added all changes

* fix: added nil check

* fix: update test files

* fix: addressed comments

* fix: change errors and suggestions to be non-nullable
2026-06-26 13:44:38 +00:00
Vikrant Gupta
ee3b45b80d feat(statsreporter): track role counts (#11866)
Register the authz provider as a stats collector and emit per-org
role counts (role.count, role.custom.count, role.managed.count) using
the same dynamic key pattern as authdomain.
2026-06-26 13:24:59 +00:00
Vinicius Lourenço
4771e30c03 fix(keyboard-shortcut): monaco-editor should not trigger shortcut (#11848) 2026-06-26 12:22:03 +00:00
Pandey
e933fa74c7 feat(querybuilder): type untyped value fields in v5 openapi schema (#11850)
* feat(querybuilder): type untyped value fields in v5 openapi schema

The query-builder v5 types FunctionArg, VariableItem and Label carry a
`Value any` field that the reflector rendered as an untyped `{}` in the
OpenAPI spec, and QueryEnvelope left an untyped `spec: {}` on its oneOf
base. Add PrepareJSONSchema methods that document the real wire contract:

- FunctionArg.value  -> oneOf [number, string]
- VariableItem.value -> oneOf [string, number, boolean, array<scalar>]
- Label.value        -> oneOf [string, number, boolean]
- QueryEnvelope      -> drop the duplicate base properties so only the
                        typed oneOf-of-$ref variants remain

The Go fields stay `any`; this only shapes the generated schema, so the
runtime decode paths and the existing wire format are unchanged.

* chore(api): regenerate openapi spec and frontend client

Regenerates docs/api/openapi.yml (`generate openapi`) and the orval
frontend client (`generate:api`) for the v5 schema typing. The four
value/spec fields are now typed instead of untyped `{}`, and the
QueryEnvelope DTO collapses from a union of `& { spec?: unknown }`
intersections into a clean discriminated union.

* feat(querybuilder): expose builder_join variant and nullable variable value

- Uncomment the QueryEnvelope builder_join variant so the discriminated
  union includes joins (the runtime UnmarshalJSON already decodes them),
  and type QueryBuilderJoin.aggregations (a []any holding trace/log/metric
  aggregations) as a oneOf of the concrete aggregation schemas instead of
  an untyped {}.
- Mark VariableItem.value nullable: the frontend sends null for a dynamic
  variable whose "ALL" option is selected.

Go field types are unchanged; this only shapes the generated schema.

* chore(api): regenerate spec and frontend client for join + nullable value

Regenerates docs/api/openapi.yml and the orval frontend client. The
QueryEnvelope union gains the builder_join variant (with typed
aggregations), and VariableItem.value is now string | number | boolean |
array | null.

* refactor(querybuilder): extract join aggregations into a named JoinAggregation

The previous inline `items.oneOf` on QueryBuilderJoin.aggregations isn't
mappable by code generators — tfplugingen-openapi rejects a oneOf buried
inline in array items ("schema composition is currently not supported"),
and skaff's detectOneOfRewrites only rewrites top-level component schemas.

Introduce a named JoinAggregation element type exposing the trace/log/metric
shapes via JSONSchemaOneOf, so the schema becomes a named oneOf-of-$ref
component (which generators flatten into one object with three optional
sub-objects) instead of an inline union. The runtime value stays opaque and
the wire shape is unchanged via transparent Marshal/UnmarshalJSON.

* chore(api): regenerate spec and frontend client for JoinAggregation

QueryBuilderJoin.aggregations items now reference the named
Querybuildertypesv5JoinAggregation oneOf component.

* feat(querybuilder): add an OpenAPI discriminator to the QueryEnvelope union

Collapse the three signal-specific builder_query schema variants into a single
queryEnvelopeBuilder whose aggregations are a trace/log/metric union
(BuilderAggregation), so `type` maps 1:1 to one variant. Tag QueryEnvelope with
x-signoz-discriminator (propertyName: type); attachDiscriminators promotes it to
a real OpenAPI 3 discriminator so oapi-codegen emits ValueByDiscriminator and
generated clients can dispatch the union mechanically.

Runtime decoding is unchanged — UnmarshalJSON still dispatches builder queries
by signal. The generated frontend client and its dashboard-v2 consumers need a
follow-up to adopt the discriminated union.

* chore(api): regenerate openapi spec for the QueryEnvelope discriminator

Frontend client regeneration is deferred to the follow-up that adapts the
dashboard-v2 query consumers to the discriminated union.

* chore(api): regenerate frontend client for the QueryEnvelope discriminator

Matches the discriminator added in 120de27c8 and the openapi spec in
4d00c0f09: QueryEnvelopeDTO is now a discriminated union keyed on `type`,
with the builder variant collapsed and BuilderAggregation typed.

WIP: the dashboard-v2 queryV5 consumers (buildQueryRangeRequest,
persesQueryAdapters, prepareScalarTables) don't yet compile against the
discriminated union, so a whole-project `tsgo --noEmit` fails until the
follow-up commit adapts them. Committed with --no-verify intentionally.

* feat(querybuilder): make the QueryEnvelope discriminator generator-friendly

Two fixes so oapi-codegen/skaff can dispatch the QueryEnvelope union:
- Pin each variant's `type` to a plain-string enum (it was a $ref to the typed
  QueryType enum, which made oapi-codegen's `v.Type = "builder_query"`
  assignment fail to compile).
- Replace the builder variant's aggregation union with a signal-discriminated
  builderQuerySpec — a oneOf of QueryBuilderQuery[Trace|Log|Metric] keyed on the
  existing (already plain-string-pinned) `signal` field. The three aggregation
  structs stay separate, mirroring dashboardtypes.BuilderQuerySpec.

builder_join's aggregations stay a discriminator-less oneOf (JoinAggregation)
for now — deferred, since a join has no signal to discriminate on.

* chore(api): regenerate openapi spec for the QueryEnvelope discriminator fix

QueryEnvelope now has a plain-string `type` discriminator; builder_query nests a
signal-discriminated BuilderQuerySpec. Frontend client regeneration is deferred
to the single FE pass.

* chore(api): regenerate frontend client for the QueryEnvelope discriminator fix

Matches the plain-string two-level discriminator (cf5b2556e / openapi
1da75be19): QueryEnvelopeDTO discriminates on `type`; builder_query nests a
signal-discriminated BuilderQuerySpecDTO over the three QueryBuilderQuery[T].

WIP: the dashboard-v2 queryV5 consumers (buildQueryRangeRequest,
persesQueryAdapters, prepareScalarTables) still don't compile against the
discriminated union, so a whole-project `tsgo --noEmit` fails until the single
FE pass adapts them. Committed with --no-verify intentionally.

* fix(querybuilder): make QueryEnvelope discriminator `type` required so apitypes compile

The plain-string pinning was the wrong lever — the blocker is the pointer, not
the enum's underlying type. An optional discriminator field renders as `*T`, and
oapi-codegen's From<Variant> assigns the discriminator string literal directly,
which only compiles on a non-pointer field. Mark `type` required:"true" on each
variant (renders non-pointer, like dashboardtypes_layout's required `kind`), and
drop the pinQueryType plain-string override. The QueryType enum is unchanged.

* chore(api): regenerate openapi spec for the required `type` discriminator

QueryEnvelope variants now mark `type` required so the generated apitypes
compile. Frontend client regeneration follows.

* chore(api): regenerate frontend client for the required `type` discriminator

Matches the required-`type` fix (65afd890f / openapi b8d528666): QueryEnvelope
variants mark `type` required so the apitypes compile.

WIP: the dashboard-v2 queryV5 consumers still don't compile against the
discriminated union; a whole-project `tsgo --noEmit` fails until the single FE
pass adapts them. Committed with --no-verify intentionally.

* chore(querybuilder): defer builder_join until it has a proper aggregation discriminator

The join aggregation oneOf (trace/log/metric) has no discriminator — trace and
log aggregations are byte-identical, and a join carries no `signal` to dispatch
on (unlike a builder query) — so code generators can't map it. Comment out
JoinAggregation (with a TODO for when full join support lands), revert
QueryBuilderJoin.Aggregations to []any, and drop the builder_join variant from
QueryEnvelope's discriminated union (oneOf + `type` mapping). Runtime decoding of
builder_join is unchanged.

* chore(api): regenerate openapi spec — builder_join deferred out of the union

* chore(api): regenerate frontend client — builder_join deferred

Matches ee6228946 / openapi ac1255f7b: the builder_join variant (and its
JoinAggregation/QueryBuilderJoin DTOs) are dropped from QueryEnvelopeDTO while
joins are deferred.

WIP: the dashboard-v2 queryV5 consumers still don't compile against the
discriminated union; a whole-project `tsgo --noEmit` fails until the single FE
pass adapts them. Committed with --no-verify intentionally.

* fix(dashboards-v2): adapt queryV5 consumers to the discriminated QueryEnvelope union

The generated QueryEnvelopeDTO is now a discriminated union — orval splits `type`
into per-variant enums — so comparing/constructing against the shared
QueryTypeDTO no longer type-checks. Route the type/spec logic through the
hand-rolled QueryEnvelope model (plain-string `type`, typed `spec`) and cast to
the generated DTO at the wire boundary (reusing the existing toMapperEnvelopes
bridge). No behavior change; the queryV5 tests pass.

* refactor(dashboards-v2): compare envelope discriminator via generated enums

Replace the string-literal `type` checks with the generated per-variant
discriminator enums (Querybuildertypesv5QueryEnvelope{Builder,PromQL,ClickHouseSQL}DTOType).
They compare directly against `envelope.type` (no cast) and narrow the union,
so no magic strings and no hand-rolled QueryEnvelope routing for the type check.

* refactor(dashboards-v2): cast only the envelope spec in toQueryEnvelopes

plugin.spec is the un-narrowed plugin-spec union, so the construction needs to
pick out the specific spec — but casting the whole array `as unknown as
QueryEnvelopeDTO[]` also discarded the type-check on the `type` discriminator.
Cast just `spec` to the variant spec (single `as`, mirroring the CompositeQuery
case), keeping `type` and the array type-checked against QueryEnvelopeDTO.

* refactor(dashboards-v2): drop the as-unknown-as casts from queryV5 consumers

Discriminator narrowing makes envelope.spec typed, so the double casts I added
when adapting these files reduce to single `as` or none:
- extractClickhouseQueryNames: cast-free (ClickHouseQueryDTO has `name`).
- withBarStepInterval: read spec.stepInterval directly; the rebuilt spec keeps a
  single `as BuilderQuerySpecDTO` (spreading the optional union drops required
  `signal`).
- withPagination: single `as` on the rebuilt spec, same reason.
- extractAggregationsPerQuery: single `as` only on the heterogeneous aggregations.
- hasRunnableQueries: single `as QuerySpecView` (its boolean .filter doesn't
  narrow, and the view exposes signal-as-string + metricName).

No `as unknown as` remain in the files this PR touched.

* refactor(qbv5): inline discriminator helpers, trim schema comments

Inline the x-signoz-discriminator construction directly into the
builderQuerySpec and QueryEnvelope PrepareJSONSchema methods, dropping the
signozDiscriminatorKey const and the schemaRef/markDiscriminator helpers.
Trim verbose PrepareJSONSchema doc comments to 1-2 lines each.

Pure refactor: generated openapi.yml is unchanged.

* fix(qbv5): make variable value schema non-nullable

The ALL selection of a dynamic variable sends the marker string __all__,
not null, so the generated value schema should not allow null. Regenerate
the OpenAPI spec and frontend clients to drop the nullable type.
2026-06-26 10:09:00 +00:00
Abhi kumar
853397a79e feat(dashboards-v2): create new panels from the editor (#11777)
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(dashboards-v2): list panel kind (logs/traces) with row detail

* feat(dashboards-v2): list columns editor with datasource column switch

* feat(dashboards-v2): move list columns editor below the query builder

Replace the config-pane Columns section with a dnd-kit reorderable editor
rendered beneath the query builder (V1 parity); sanitize selectFields to the
field-key DTO so saved columns drop non-contract keys (isIndexed).

* style(dashboards-v2): format list panel header with oxfmt

* refactor(dashboards-v2): drop the unsupported-panel fallback

Every panel kind now has a renderer, so the "not yet supported in V2"
body is dead. Remove UnsupportedPanelBody and render the panel body
behind a plain `panelDefinition &&` guard. Also clean up the panel-kind
typing in Panel.tsx: `spec.plugin.kind` is non-optional and already a
PanelKind, so the `as unknown as` cast and `?.` chains go away.

* refactor(dashboards-v2): tighten the getPanelDefinition cast

Drop the dead `if (!kind)` guard (a partial registry already returns
undefined for absent keys) and reduce the `as unknown as` double-cast to
a single `as` — comparability clears it, so the unknown laundering was
unnecessary.

* refactor(dashboards-v2): narrow renderer props to the panel kind

PanelRendererProps<K> now carries a `panel` narrowed to kind K (via a
PluginOfKind helper that picks the variant from the generated plugin
union), so each renderer reads `panel.spec.plugin.spec` as its exact spec
DTO. Removes the per-renderer `as <Kind>SpecDTO` cast (and the now-dead
`?? {}` fallbacks) across all seven renderers; the single unavoidable
widening stays in getPanelDefinition.

* feat(dashboards-v2): create new panels from the editor

Add a draft-first create flow: picking a panel type opens the editor at
`/panel/new?panelKind=…&layoutIndex=…` on an in-memory default panel, and
nothing is persisted until save — cancelling leaves the dashboard
untouched (V1 parity). On save a new panel is minted (uuid) and added to
the target section via `createPanelOps` (resolves the section, or creates
one when the dashboard has none).

All "Add panel" triggers (section header, empty section, empty dashboard,
toolbar) route through a single useCreatePanel hook + the V2 type picker;
the leftover V1 global modal and useAddPanelToSection are removed.

New panels seed sensible defaults from the kind's supported signals: the
query datasource (e.g. List → logs, not the unsupported metrics default)
and, for List, the signal's default columns — so the Columns control
isn't empty on first open. New panels always re-serialize their query for
the kind on save, so the persisted query is valid even if untouched.

echo "--- working tree after commits (should be only dev hack) ---"; git status --short

* fix(dashboards-v2): place a new panel in the last row's free space

createPanelOps placed every new panel at x:0 on a fresh row, so a half-full
last row left its right half empty. Find the first free slot in the section's
last row (right of its panels) and only wrap to a new row when it can't fit.

* fix(uplot): center a single-item chart legend

isSingleRow read the container width from a ref inside useMemo, so it computed
once at mount (ref still null → width 0 → false) and only recovered if the
component happened to re-render. The uPlot legend re-renders on plot sync so it
recovered by luck; the Pie legend doesn't, leaving a single item clipped in a
narrow grid track. Measure the container with useResizeObserver instead.

* feat(dashboards-v2): unified panel empty, no-data and no-query states

Add a shared PanelMessage component (icon + title + description + optional
action) backing every non-chart panel state. PanelBody now shows a 'Nothing to
visualize yet' state for panels with no runnable query and a polished error
state with Retry. NoData becomes a 'No data in this time range' affordance with
an optional Retry, wired through a new optional refetch on the renderer props.

* fix(dashboards-v2): derive the preview "Plotted with" tag from the panel query

The editor preview hardcoded EQueryType.QUERY_BUILDER, so PromQL/ClickHouse
panels were mislabelled and list panels still showed the tag. Add a V2 PlotTag
(mirroring V1: hidden for list panels and before a query exists) fed by a new
getPanelQueryType util that reads the panel's V5 envelopes.

* feat(dashboards-v2): panel header title with description tooltip

Replace PanelHeader's ReactNode title with explicit name + description props,
rendering the description as an info-icon tooltip in the header instead of a
wrapper around the title. Panel passes them directly (no headerTitle memo).

* refactor(dashboards-v2): let consumers own the refetch loader signal

usePanelQuery returns the raw react-query isLoading; the dashboard panel and the
editor preview now compute isLoading || isFetching themselves, so each surface
decides whether a background refetch shows the full loader.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(dashboards-v2): default a threshold's label to an empty string on save

* feat(dashboards-v2): show the panel header in the editor preview

Reuse PanelHeader in PreviewPane so the editor preview shows the panel
title, description tooltip, refetch spinner, and error/warning indicators,
with the body rendered flush beneath it like the dashboard grid panel.

Add a hideActions flag to PanelHeader to suppress the actions menu in the
editor: View/Edit/Clone/Delete don't apply there, and omitting panelActions
alone isn't enough since View/Edit/Download survive their own gates. Wire the
table/list header search through the preview as the grid does, and thread the
raw isFetching for the header's refetch spinner.

* feat(dashboards-v2): seed new panels with per-kind config defaults

A new panel was seeded with an empty plugin spec, so the config pane's
dropdowns and segmented controls (time scope, legend position, line style /
interpolation, fill mode) opened with nothing selected.

Derive a default plugin spec from each kind's declared sections via a new pure
buildDefaultPluginSpec helper and seed it in createDefaultPanel. Each default
equals the matching renderer fallback, so only the config-pane display changes,
not the rendered output. Controls whose empty state already reads as the chart
default (unit/decimals "auto", switches, numeric "Auto" inputs) are left unset.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(dashboards-v2): preserve list columns across datasource round-trips

The List panel stores a single selectFields list, so switching the query
signal replaced the columns with the new datasource's defaults — going
logs -> traces -> logs discarded a customized logs selection.

Remember each signal's columns and restore them when switching back;
defaults still seed a signal the first time it's seen.

* docs(dashboards-v2): trim verbose comments to minimal-why style

Condense multi-paragraph block comments and drop comments that restate
the code across the new-panel editor surface, keeping only the non-obvious
"why" and concise JSDoc on functions and type fields. Comment-only — no
code, types, or test assertions changed.

* fix(dashboards-v2): show resource attributes like service.name in trace list panels

The List renderer only flattened nested resource/attribute maps for logs, so a
selected resource field such as service.name (nested under resources_string)
rendered N/A for traces even though the same data shows in V1. Flatten traces too.

* feat(dashboards-v2): open new List panels on a runnable logs query (V1 parity)

A new List panel opened blank: its seed query had an empty orderBy and lived only
in the builder, so the preview didn't run on open. Seed spec.queries at creation
with the V1 list logs query (orderBy timestamp desc) so the panel opens on logs,
pre-sorted, and the preview runs immediately; wire up useSeedNewListColumns so its
default columns (timestamp, body) are populated too. Switching to traces is handled
by the builder's list-view path.

* fix(dashboards-v2): drop all config-pane sections from the List panel

List columns are edited below the query builder, and Context Links isn't wanted in
the config pane, so the List panel now declares no config-pane sections.

* refactor(dashboards-v2): address PR review feedback

- getPanelDefinition: make PanelRegistry total over PanelKind so it never
  returns undefined (a missing kind is now a compile error)
- toQueryEnvelopes/hasRunnableQueries: accept `... | null` and drop the
  `?? []` at call sites
- consolidate panel loading to a single `isFetching` signal (Panel,
  PreviewPane, PanelEditor)
- rename defaultDataSource -> signal; use TelemetrytypesSignalDTO enum for
  the List default columns
- useCreatePanel now owns the panel-type picker state, deduping the three
  add-panel triggers; rename pluginKind -> panelKind
- LabelThresholdRow: extract onSave into a callback; ThresholdsSection test
  uses userEvent
- PreviewPane: drop the optional chaining on the required display field
- trim over-verbose comments

* refactor(dashboards-v2): type panel signals as TelemetrytypesSignalDTO

Replace the query-builder `DataSource` enum with `TelemetrytypesSignalDTO`
across the panel signal plumbing — the panel definitions' `supportedSignals`,
the List columns editor/suggestions/defaults, and the editor query-sync and
column-seeding hooks — and make the signal non-optional where a panel always
resolves one. Drops the now-dead `DataSource` imports.

* chore: pr review changes

* chore: pr review changes

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 18:48:20 +00:00
Vinicius Lourenço
bd526df11d feat(roles): rework the create/update pages (#11815)
* fix(styles): little fix for callout

I will upstream this fix eventually

* fix(settings): ensure tab content can use height: 100%

We will need this fix for the create/edit role page later during json mode

* refactor(authz): define the verbs that supports selectorId

* refactor(roles): drop old components

We will rewrite them

* refactor(roles): use css modules on role settings

* refactor(roles): rewrite delete role modal to use dialog component

* feat(roles): add initial type files

* feat(error-in-place): add testId to allow assert on future tests

* refactor(permission-denied-full-page): change the wording a little bit to match the API

This change was requested by Vikrant

* feat(service-account-drawer): add copy button for ID

Suggested by https://github.com/SigNoz/platform-pod/issues/2529#issuecomment-4776975323

* fix(service-account-drawer): not validating the permissions correctly for read/update

Found at https://github.com/SigNoz/platform-pod/issues/2529#issuecomment-4776975323

* feat(timezone): add little helper to allow format optional dates

* refactor(authz): export parse permission to get objectId when needed

* test(authz): add helper to grant by prefix

* test(test-utils): include tooltip provider

* feat(hooks): add hook to block navigation via history

* feat(roles): add hook to map API interface to own interface for easier integration

* feat(roles): add hook to fetch role permissions

* feat(roles): add config object around how to define each section for each resource

* feat(roles): add config for monaco when showing json

* feat(view-roles): add selected ids component

* feat(view-roles): add component row to render selected roles

* feat(view-roles): add card component to render each resource selected

* feat(view-roles): add overview component to view roles

* feat(view-roles): add readonly json viewer

* feat(view-roles): add view role page

* test(view-roles): add tests for view roles

* feat(create-edit-roles): add hook for form validation

* feat(create-edit-roles): add hook for unsaved changes

* feat(create-edit-roles): add hook for generic logic for create-edit

* feat(create-edit-roles): add component to render json

* feat(create-edit-roles): add component to add new selectors for a permission

* feat(create-edit-roles): add component to select scopes of a permission

* feat(create-edit-roles): add component render resources to be selected

* feat(create-edit-roles): add component render permission editor

* feat(create-edit-roles): add page to create/edit roles

* test(create-edit-roles): add tests for page and subcomponents

* feat(list-roles): refactor to css modules & add row click to open view role page

* fix(list-roles): change description when feature gate is disabled

* refactor(roles): removed action labels and use function instead

* refactor(authz): add todo about permission.config

* feat(roles): add routes for create/edit/view

* test(service-account): update missing tests

* fix(pr): address comments

* fix(pr): rename suffix of hooks from callbacks to actions

* fix(pr): remove all usages of getByText

* test(use-navigation-blocker): fix test
2026-06-25 17:24:16 +00:00
Nikhil Mantri
8ac07d3d37 feat(infra-monitoring): v2 onboarding/checks api (#11104)
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
* chore: baseline setup

* chore: endpoint detail update

* chore: added logic for hosts v3 api

* fix: bug fix

* chore: disk usage

* chore: added validate function

* chore: added some unit tests

* chore: return status as a string

* chore: yarn generate api

* chore: removed isSendingK8sAgentsMetricsCode

* chore: moved funcs

* chore: added validation on order by

* chore: added pods list logic

* chore: updated openapi yml

* chore: updated spec

* chore: pods api meta start time

* chore: nil pointer check

* chore: nil pointer dereference fix in req.Filter

* chore: added temporalities of metrics

* chore: added pods metrics temporality

* chore: unified composite key function

* chore: code improvements

* chore: added pods list api updates

* chore: hostStatusNone added for clarity that this field can be left empty as well in payload

* chore: yarn generate api

* chore: return errors from getMetadata and lint fix

* chore: return errors from getMetadata and lint fix

* chore: added hostName logic

* chore: modified getMetadata query

* chore: add type for response and files rearrange

* chore: warnings added passing from queryResponse warning to host lists response struct

* chore: added better metrics existence check

* chore: added a TODO remark

* chore: added required metrics check

* chore: distributed samples table to local table change for get metadata

* chore: frontend fix

* chore: endpoint correction

* chore: endpoint modification openapi

* chore: escape backtick to prevent sql injection

* chore: rearrage

* chore: improvements

* chore: validate order by to validate function

* chore: improved description

* chore: added TODOs and made filterByStatus a part of filter struct

* chore: ignore empty string hosts in get active hosts

* feat(infra-monitoring): v2 hosts list - return counts of active & inactive hosts for custom group by attributes (#10956)

* chore: add functionality for showing active and inactive counts in custom group by

* chore: bug fix

* chore: added subquery for active and total count

* chore: ignore empty string hosts in get active hosts

* fix: sinceUnixMilli for determining active hosts compute once per request

* chore: refactor code

* chore: rename HostsList -> ListHosts

* chore: rearrangement

* chore: inframonitoring types renaming

* chore: added types package

* chore: file structure further breakdown for clarity

* chore: comments correction

* chore: removed temporalities

* chore: pods code restructuring

* chore: comments resolve

* chore: added json tag required: true

* chore: removed pod metric temporalities

* chore: removed internal server error

* chore: added status unauthorized

* chore: remove a defensive nil map check, the function ensure non-nil map when err nil

* chore: cleanup and rename

* chore: make sort stable in case of tiebreaker by comparing composite group by keys

* chore: added types and constants

* chore: added specs for all component types

* chore: added attrs presence check function

* chore: added onboarding splits

* chore: regen api client for inframonitoring

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: added required tags

* chore: added support for pod phase unknown

* chore: removed pods - order by phase

* chore: improved api description to document -1 as no data in numeric fields

* fix: rebase fixes

* chore: added onboarding api

* chore: renamed method

* chore: get onboarding spec

* chore: simplify

* chore: readability improvement

* chore: added a note from otel

* chore: added a note from otel

* feat(infra-monitoring): v2 pods list apis - phase counts when custom grouping (#11088)

* chore: added phase counts feature

* chore: added queries for pod phase counts in custom group by

* chore: added unknown phase count

* fix: isPodUIDInGroupBy in buildPodRecords

* chore: 3 cte --> 2 cte

* chore: pod phase with local table of time series as counts

* chore: comment correction

* chore: corrected comment

* chore: value column for samples table added

* chore: removed query G for phase counts

* chore: rename variable

* chore: added PodPhaseNum constants to types

* chore: updated comment

* chore: onboarding specs updated to match v2 infra-monitoring apis

* chore: integration tests added

* chore: documentation future links added

* chore: added new metrics existence function + modified return types

* chore: not required parameter removal

* chore: reformatted integration tests

* chore: renamed onboarding -> checks

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Ashwin Bhatkal <ashwin96@gmail.com>
2026-06-25 13:57:50 +00:00
Nageshbansal
9bab8e0ae2 docs(deploy): restructure migration guide and add Docker Swarm (#11851)
* docs(deploy): restructure migration guide and add Docker Swarm

* docs(deploy): title migration guide for both install script and deploy

* docs: fix the signoz docs troubleshooting guide url

* docs(deploy): point troubleshooting at Slack and Foundry issues
2026-06-25 11:48:37 +00:00
Nityananda Gohain
8040f222ad fix: enable opamp for llmpricing/spanmmapper and remove unit from config (#11285)
* fix: enable opamp for llmpricing

* fix: test files

* chore: cleanups

* fix: don't deploy to opamp if ff is disabled

* fix: enable spanmapper behild feature flag
2026-06-25 11:38:53 +00:00
Nikhil Mantri
a45212cb79 feat(infra-monitoring): remove requiredMetricsCheck from v2 APIs + integration tests for new querier warnings behaviour. (#11792)
* chore: metric_name required checks removed

* refactor(inframonitoring): drop required-metrics test, add test hosts_warnings

* chore: updated integration tests for pods

* chore: updated integration tests for volumes

* chore: updated integration tests for nodes

* chore: updated integration tests for clusters and deployments

* chore: updated statefulsets integration tests

* chore: updated integration tests for jobs and daemonsets

* chore: removed unwanted provisional comments

* test(inframonitoring): dedupe metric-availability warning fixtures, reuse existing datasets

* test(inframonitoring): isolate hosts metric-key-pair warning fixture

Scenario-2 shared host acc-h1 + the accuracy fixture with
test_hosts_value_accuracy, causing a deterministic CI failure (empty
warnings). Restore the dedicated kp-h1 fixture + deployment.environment
groupBy so the warning assertion never depends on data shared with
another test.

* chore: removed unimportant tests for same thing

* test(inframonitoring): drop obsolete metric-key-pair warning scenario

PR #11835 removed the "key X not found on metric" warning from the metric

statement builder (lexer-derived check can't tell a key from a value, so

$variables got false-flagged). The metric_key_pair_not_seen scenario asserted

that now-removed warning, so it fails deterministically after merging main.

Remove the scenario and its orphaned fixture; never-seen-metric coverage

("never been received", still emitted) stays.
2026-06-25 10:26:50 +00:00
Abir Roy
a609a4044c fix(ui): resolve monaco find widget clipping and flickering (#11826)
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
2026-06-24 20:11:11 +00:00
Nikhil Mantri
f78d98ea71 feat(metrics-explorer): move metric_name from path param to query param (#11745)
* chore: metricName to post body for POST /api/v2/metrics/{metric_name}/metadata

* chore: metricName to query param for GET /api/v2/metrics/{metric_name}/metadata

* chore: added metricName in api get metric attributes

* chore: highlights api modified

* chore: alerts api modified

* chore: dashboards api modified

* chore: description added for metric_name query params

* feat(metrics-explorer): integrate metricName query/body API change in frontend (#11818)

* feat(metrics-explorer): integrate metricName query/body API change in frontend

The metrics-explorer endpoints moved metric_name off the URL path: the
five GETs (attributes, metadata, highlights, alerts, dashboards) now take
a required `metricName` query param, and POST /metadata reads metricName
from the request body.

- Regenerate the orval client from the updated openapi spec, so the GET
  helpers build `/api/v2/metrics/<op>?metricName=...` (URL-encoded, so
  slashed cloud metric names work) and updateMetricMetadata posts to
  `/api/v2/metrics/metadata` with metricName in the body.
- Collapse the useGetMetricAttributes call to the single merged params
  object (metricName + start/end).
- Drop the now-removed pathParams wrapper from both updateMetricMetadata
  call sites; the payload builders already include metricName in the body.
- Update the Metadata test to assert metricName inside the request body.

* revert(metrics-explorer): drop slashed-metric-name band-aid guards

These two defensive guards were added as temporary workarounds for the
metric_name-with-slash bug (SigNoz/signoz#11527, #11528), which returned
200 + HTML instead of JSON. The root cause is fixed by moving metricName
to a query/body param, so the band-aids are no longer needed and revert
to the original intended code.

- MetricDetails.tsx: `!metricMetadataResponse?.data` -> `!metricMetadataResponse`
- AllAttributes.tsx: `?.data?.attributes` -> `?.data.attributes`

* chore: added description for metricName query params

---------

Co-authored-by: Ashwin Bhatkal <ashwin96@gmail.com>
Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-06-24 19:40:09 +00:00
Ashwin Bhatkal
f60e5039be feat(dashboard-v2): toolbar repositioning, JSON editor & expandable variables bar (#11837)
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(dashboard-v2): json editor drawer to edit dashboard as raw JSON

Right-side drawer with a Monaco JSON editor (reusing the import-JSON theme),
a Format/Copy/Download/Reset toolbar, live JSON validation, and Apply via the
full-document updateDashboardV2 endpoint. Cmd/Ctrl+Enter applies; Esc closes.

* feat(dashboard-v2): grouped actions menu with clone, new section & edit-as-JSON

Regroup the actions dropdown into labelled Dashboard/Data/Layout groups, add
Clone dashboard (cloneDashboardV2 + navigate) and New section (useAddSection),
and surface an Edit as JSON button that opens the JSON editor drawer. The menu
trigger is a labelled "Actions" button with a 9-grid icon; the time selector
moves out of the actions row to the toolbar's second row.

* feat(dashboard-v2): description-on-hover and space-aware tag overflow

Collapse the dashboard description behind an info icon shown on hover, and move
tags inline after the title where they restrict to the available width and
collapse the remainder into a +N badge (reusing the alerts badge measuring).

* refactor(dashboard-v2): two-row toolbar with a floated, expandable variables bar

Second toolbar row floats the time-range selector top-right; the variables bar
flows beside it, collapsing to a single line with an inline +N trigger that hugs
the last visible pill. Expanding clears the float so the pills pack full-width
on the lines beneath the time selector (no stair-stepping). Overflow pills are
display:none but stay mounted (widths cached) so auto-selection and option
fetching keep driving the panels. Also centre the variable info icon, give the
pills a visible --l3-border (and drop the single-select's stray inner border so
it matches), and replace the toolbar's fuzzy drop shadow with a token hairline.

* feat(dashboard-v2): section title modal & scroll to the new section

New section now opens a title-entry modal instead of inserting a default-named
section, and the view scrolls the freshly created section into view once the
refetch renders it. Generalise the rename modal into a shared SectionTitleModal
reused by both create and rename.

* test(dashboard-v2): cover the JSON editor hook and drawer

useJsonEditor: seeding, live validation, format/reset, dirty tracking, apply
(no-op when clean/invalid, PUTs the narrowed body, error handling) and re-seed
on re-open. JsonEditorDrawer: toolbar/footer wiring, validation text, Apply
enablement, editor changes and Cmd/Ctrl+Enter — with Monaco and the hook mocked.

* refactor(dashboard-v2): extract generic useInlineOverflowCount hook

Address review on the variables bar: generalise the single-line overflow
measurement into hooks/useInlineOverflowCount (container of data-overflow-item
children, with gap/reserveWidth/enabled options) so it's reusable elsewhere, and
clarify the internal variable names (container/itemWidths/availableWidth/etc.).

* refactor(dashboard-v2): use descriptive names in useInlineOverflowCount

Replace single-letter/abbreviated locals (el, i, w, width/widths) with
itemElement/index/itemWidth/cachedWidth(s) inside the measure loop.
2026-06-24 12:54:34 +00:00
Vikrant Gupta
a483ef81a4 feat(authz): add transaction groups JSON schema (#11827)
* feat(authz): add transaction group schema and validations

* fix(authz): drop constant errorFormat param from wrapValidationError

unparam flagged wrapValidationError's errorFormat parameter since all
call sites passed the same "%s: %s". Inline the format and trim the
argument at each call site. No behavior change.

* feat(authz): better error handling

* chore(authz): suffix generated web settings schema with .schema.json

Rename webSettings.json to webSettings.schema.json to follow the JSON
Schema file-naming convention and match transactionGroups.schema.json.
Updates the generator output path, the json2ts input + banner in
package.json, and the generated banner comment.

* feat(authz): add schema titles
2026-06-24 11:27:19 +00:00
Abhi kumar
b9c107a851 fix(dashboards-v2): stop infinite render loop on dashboards with no variable selections (#11841)
selectVariableValues returned an inline `{}` fallback whenever a dashboard had
no stored selections. Zustand reads selectors through useSyncExternalStore,
which compares snapshots with Object.is, so a fresh object every call reads as a
perpetually-changed snapshot and React re-renders without end ("Maximum update
depth exceeded").

This surfaced specifically on fresh/empty dashboards: when a dashboard has
variables, the seeding effect in useVariableSelection populates the store with a
stable object and the loop never starts; with no variables that effect
early-returns, the entry stays undefined, and the selector mints a new `{}` on
every render. VariablesBar renders null in that case, but its hook still
subscribes, so the loop fires anyway.

Return a single module-level empty map so the snapshot is referentially stable.
2026-06-24 10:16:48 +00:00
Nikhil Soni
5f6cc4c297 feat(data-export): support client-provided offset in export_raw_data API (#11825)
* feat: add support for offset in export api

* chore: add tests similar to limit

* Remove unnecessary tests

This reverts commit 2cc123d34f.
2026-06-24 09:29:54 +00:00
Srikanth Chekuri
69e4c3c6f3 chore(querier): route metric reads to buffer/reduced tables behind a flag (#11801)
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
Fixes https://github.com/SigNoz/engineering-pod/issues/5341
2026-06-24 07:20:57 +00:00
1529 changed files with 28375 additions and 71789 deletions

View File

@@ -70,7 +70,11 @@ jobs:
cd tests/e2e && pnpm install --frozen-lockfile
- name: playwright-browsers
run: |
cd tests/e2e && pnpm playwright install --with-deps ${{ matrix.project }}
docker create --name pw mcr.microsoft.com/playwright:v1.57.0-noble
docker cp pw:/ms-playwright "$RUNNER_TEMP/ms-playwright"
docker rm pw
echo "PLAYWRIGHT_BROWSERS_PATH=$RUNNER_TEMP/ms-playwright" >> "$GITHUB_ENV"
cd tests/e2e && pnpm playwright install-deps ${{ matrix.project }}
- name: bring-up-stack
run: |
cd tests && \

View File

@@ -140,3 +140,20 @@ jobs:
run: |
go run cmd/enterprise/*.go generate config web-settings
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in web settings schema. Run go run cmd/enterprise/*.go generate config web-settings locally and commit."; exit 1)
transaction-groups:
if: |
github.event_name == 'merge_group' ||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
runs-on: ubuntu-latest
steps:
- name: self-checkout
uses: actions/checkout@v4
- name: go-install
uses: actions/setup-go@v5
with:
go-version: "1.24"
- name: generate-transaction-groups
run: |
go run cmd/enterprise/*.go generate config transaction-groups
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in transaction groups schema. Run go run cmd/enterprise/*.go generate config transaction-groups locally and commit."; exit 1)

View File

@@ -56,17 +56,6 @@ jobs:
PRIMUS_REF: main
JS_SRC: frontend
JS_PKG_MANAGER: pnpm
languages:
if: |
github.event_name == 'merge_group' ||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
runs-on: ubuntu-latest
steps:
- name: self-checkout
uses: actions/checkout@v4
- name: run
run: bash frontend/scripts/validate-md-languages.sh
openapi:
if: |
github.event_name == 'merge_group' ||

View File

@@ -29,6 +29,8 @@ import (
"github.com/SigNoz/signoz/pkg/modules/cloudintegration/implcloudintegration"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/metricreductionrule"
"github.com/SigNoz/signoz/pkg/modules/metricreductionrule/implmetricreductionrule"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/retention"
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
@@ -119,6 +121,9 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
func(_ sqlstore.SQLStore, _ dashboard.Module, _ global.Global, _ zeus.Zeus, _ gateway.Gateway, _ licensing.Licensing, _ serviceaccount.Module, _ cloudintegration.Config) (cloudintegration.Module, error) {
return implcloudintegration.NewModule(), nil
},
func(_ sqlstore.SQLStore, _ telemetrystore.TelemetryStore, _ dashboard.Module, _ queryparser.QueryParser, _ licensing.Licensing, _ flagger.Flagger, _ telemetrytypes.MetadataStore, _ factory.ProviderSettings, _ int) metricreductionrule.Module {
return implmetricreductionrule.NewModule()
},
func(c cache.Cache, am alertmanager.Alertmanager, ss sqlstore.SQLStore, ts telemetrystore.TelemetryStore, ms telemetrytypes.MetadataStore, p prometheus.Prometheus, og organization.Getter, rsh rulestatehistory.Module, q querier.Querier, qp queryparser.QueryParser) factory.NamedMap[factory.ProviderFactory[ruler.Ruler, ruler.Config]] {
return factory.MustNewNamedMap(signozruler.NewFactory(c, am, ss, ts, ms, p, og, rsh, q, qp, nil, nil))
},

View File

@@ -24,6 +24,7 @@ import (
"github.com/SigNoz/signoz/ee/modules/cloudintegration/implcloudintegration"
"github.com/SigNoz/signoz/ee/modules/cloudintegration/implcloudintegration/implcloudprovider"
"github.com/SigNoz/signoz/ee/modules/dashboard/impldashboard"
eeimplmetricreductionrule "github.com/SigNoz/signoz/ee/modules/metricreductionrule/implmetricreductionrule"
eequerier "github.com/SigNoz/signoz/ee/querier"
enterpriseapp "github.com/SigNoz/signoz/ee/query-service/app"
eerules "github.com/SigNoz/signoz/ee/query-service/rules"
@@ -46,6 +47,7 @@ import (
pkgcloudintegration "github.com/SigNoz/signoz/pkg/modules/cloudintegration/implcloudintegration"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
pkgimpldashboard "github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/metricreductionrule"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/retention"
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
@@ -182,6 +184,9 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
return implcloudintegration.NewModule(pkgcloudintegration.NewStore(sqlStore), dashboardModule, global, zeus, gateway, licensing, serviceAccount, cloudProvidersMap, config)
},
func(sqlStore sqlstore.SQLStore, ts telemetrystore.TelemetryStore, dashboardModule dashboard.Module, queryParser queryparser.QueryParser, lic licensing.Licensing, flgr pkgflagger.Flagger, ms telemetrytypes.MetadataStore, ps factory.ProviderSettings, threads int) metricreductionrule.Module {
return eeimplmetricreductionrule.NewModule(sqlStore, ts, dashboardModule, queryParser, lic, flgr, ms, ps, threads)
},
func(c cache.Cache, am alertmanager.Alertmanager, ss sqlstore.SQLStore, ts telemetrystore.TelemetryStore, ms telemetrytypes.MetadataStore, p prometheus.Prometheus, og organization.Getter, rsh rulestatehistory.Module, q querier.Querier, qp queryparser.QueryParser) factory.NamedMap[factory.ProviderFactory[ruler.Ruler, ruler.Config]] {
return factory.MustNewNamedMap(signozruler.NewFactory(c, am, ss, ts, ms, p, og, rsh, q, qp, eerules.PrepareTaskFunc, eerules.TestNotification))
},

View File

@@ -6,12 +6,15 @@ import (
"reflect"
"strings"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/web"
"github.com/spf13/cobra"
"github.com/swaggest/jsonschema-go"
)
const webSettingsSchemaPath = "docs/config/web-settings.json"
const webSettingsSchemaPath = "frontend/src/schemas/generated/webSettings.schema.json"
const transactionGroupsSchemaPath = "frontend/src/schemas/generated/transactionGroups.schema.json"
func registerGenerateConfig(parentCmd *cobra.Command) {
configCmd := &cobra.Command{
@@ -27,6 +30,14 @@ func registerGenerateConfig(parentCmd *cobra.Command) {
},
})
configCmd.AddCommand(&cobra.Command{
Use: "transaction-groups",
Short: "Generate JSON Schema for transaction groups",
RunE: func(currCmd *cobra.Command, args []string) error {
return generateTransactionGroups()
},
})
parentCmd.AddCommand(configCmd)
}
@@ -52,6 +63,7 @@ func generateWebSettings() error {
return err
}
schema.WithTitle("WebSettings")
data, err := json.MarshalIndent(schema, "", " ")
if err != nil {
return err
@@ -59,3 +71,31 @@ func generateWebSettings() error {
return os.WriteFile(webSettingsSchemaPath, append(data, '\n'), 0o600)
}
func generateTransactionGroups() error {
falseVal := false
noAdditional := jsonschema.SchemaOrBool{TypeBoolean: &falseVal}
reflector := jsonschema.Reflector{}
reflector.DefaultOptions = append(reflector.DefaultOptions,
jsonschema.InterceptSchema(func(params jsonschema.InterceptSchemaParams) (bool, error) {
if params.Value.Kind() == reflect.Struct {
params.Schema.AdditionalProperties = &noAdditional
}
return false, nil
}),
)
schema, err := reflector.Reflect(authtypes.TransactionGroups{})
if err != nil {
return err
}
schema.WithTitle("TransactionGroups")
data, err := json.MarshalIndent(schema, "", " ")
if err != nil {
return err
}
return os.WriteFile(transactionGroupsSchemaPath, append(data, '\n'), 0o600)
}

View File

@@ -141,6 +141,10 @@ querier:
flux_interval: 5m
# The maximum number of concurrent queries for missing ranges.
max_concurrent_queries: 4
# When filtering logs by trace_id, clamp the query window to the trace time
# range with padding to include slightly delayed log exports. Logs only; set
# to 0 to disable.
log_trace_id_window_padding: 5m
##################### TelemetryStore #####################
telemetrystore:

View File

@@ -1,48 +1,76 @@
# Migrating from the install script to Foundry
# Migrating from the install script and `deploy/` to Foundry
The install script (`install.sh`) and the bundled Compose and Swarm files
under `deploy/` are deprecated in favor of [Foundry][foundry], the supported
way to install and manage SigNoz. This guide moves an existing Docker Compose
or Docker Swarm deployment to Foundry and reattaches your existing volumes, so
your data is preserved.
> [!IMPORTANT]
> The install script is now deprecated and will no longer receive updates.
> This guide is only for **existing** `install.sh` / `deploy/` deployments.
> Setting up SigNoz for the first time? Skip migration and install Foundry
> directly: [SigNoz install docs][install-docs].
This guide walks you through migrating an existing SigNoz deployment running via
Docker Compose to [Foundry](https://signoz.io/docs/install/docker/).
## How it works
> [!NOTE]
> Setting up SigNoz for the first time? You don't need this guide — follow the [SigNoz installation docs](https://signoz.io/docs/install/) instead.
Foundry splits a deployment into two commands:
## Overview
To stay up to date on new installation platforms and patterns, please refer to [Foundry](https://github.com/SigNoz/foundry).
- `foundryctl forge` generates the deployment manifests from a `casting.yaml`.
It never touches running containers, so it is safe to re-run while you
iterate.
- `foundryctl cast` applies those manifests: it (re)creates the containers and
reuses the volumes you point it at.
Two `foundryctl` commands are used throughout this guide:
- **`forge`** — generates deployment manifests from your `casting.yaml`. It does not touch running containers, so it is safe to re-run while you iterate.
- **`cast`** — applies the generated manifests: it creates and starts the containers (and pulls new images).
You write one `casting.yaml`, point a few patches at your existing data
volumes, then cast. The steps below are the same for Compose and Swarm; they
differ only in the casting (step 3) and how you stop the old stack (step 5).
## Prerequisites
- [ ] Install Foundry - `curl -fsSL https://signoz.io/foundry.sh | bash`
## Migration Steps
> [!WARNING]
> **Before proceeding, back up both:**
> - **Your docker volumes** — these hold your data.
> - **Your existing `docker-compose.yaml` (and any config it references)** — keep a copy somewhere safe. The compose manifests are no longer distributed by SigNoz, so this backup is your only way to roll back to your previous setup.
- An existing SigNoz deployment from `install.sh` or `deploy/` (Compose or
Swarm).
- `foundryctl` (installed in step 1).
1. Make a note of the volume names used by your existing deployment for the following components:
- ClickHouse
- SigNoz
- ZooKeeper
## Migrate
> If you used the docker compose file we provided, the volumes will be `signoz-clickhouse`, `signoz-sqlite`, and `signoz-zookeeper-1`.
### 1. Install Foundry
2. Generate your `casting.yaml`. Based on internal testing, the following casting should generate the manifests that mimic the legacy docker compose setup (compare against your backed-up `docker-compose.yaml`). Once created, run `foundryctl forge -f casting.yaml`.
```bash
curl -fsSL https://signoz.io/foundry.sh | bash
```
Foundry has a [Docker Compose example](https://github.com/SigNoz/foundry/tree/main/docs/examples/docker/compose). Please use it as a reference.
### 2. Keep your rollback path
> [!WARNING]
> If your deployment had more than 1 shard or replica, you will need to adjust your manifest volumes accordingly.
This migration reattaches your existing volumes in place; it does not move or
delete your data. The only destructive action is passing `--volumes` / `-v`
when you stop the old stack (step 5), so avoid that flag.
> [!IMPORTANT]
> The `replica` and `shard` macros below are placeholders. Replace them with the values from your existing ClickHouse configuration (check the `macros` section of your current ClickHouse config, e.g. `config.xml`/`metrika.xml`), otherwise the generated manifests will not match your existing data.
> Keep a copy of your existing `docker-compose.yaml` / stack file (and any
> config it references). SigNoz no longer distributes these files, so this copy
> is your only way to roll back.
### 3. Write your `casting.yaml`
Use the casting for your deployment. Both reproduce the legacy single-node
setup (ClickHouse + ZooKeeper + SQLite) and reattach your existing volumes;
they differ only in `spec.deployment.flavor` and the volume-reuse patch
(Compose volumes have a `name` to replace; Swarm volumes are bare, so the whole
entry is replaced). If your deployment ran more than one shard or replica,
adjust the volume patches accordingly. The
[Docker Compose example][compose-example] is a useful reference.
> [!IMPORTANT]
> The `replica` and `shard` macros are placeholders. Replace them with the
> values from your existing ClickHouse config (the `macros` section of
> `config.xml` / `metrika.xml`), or the generated manifests will not match your
> existing data.
<details>
<summary><b>Docker Compose</b> casting.yaml</summary>
```yaml
# casting.yaml
apiVersion: v1alpha1
kind: Installation
metadata:
@@ -61,8 +89,8 @@ spec:
data:
config-0-0.yaml: |
macros:
replica: "example01-01-1" # replace with your existing ClickHouse replica macro (see legacy configuration files for reference)
shard: "01" # replace with your existing ClickHouse shard macro (see legacy configuration files for reference)
replica: "example01-01-1" # replace with your replica macro
shard: "01" # replace with your shard macro
patches:
- target: "deployment/compose.yaml"
operations:
@@ -80,50 +108,163 @@ spec:
value: root
```
> [!NOTE]
> The `user: root` patch on the ZooKeeper service is required so the container can read/write the data in your reused ZooKeeper volume, which was created with `root`-owned files by the legacy compose setup. Without it, ZooKeeper may fail to start with permission errors.
</details>
If you had custom configurations for features like SMTP or additional ingestion processors/receivers, you will need to include those in your casting file via [patches](https://github.com/SigNoz/foundry/blob/main/docs/concepts/patches.md), [custom configuration](https://github.com/SigNoz/foundry/blob/main/docs/concepts/moldings.md#custom-config-files) or [environment variables](https://github.com/SigNoz/foundry/blob/main/docs/reference/casting-file.md#molding-spec) based on your previous configuration.
<details>
<summary><b>Docker Swarm</b> casting.yaml</summary>
3. Review your manifests, we suggest executing the following checks on your manifests before proceeding:
- [ ] Validate the container images match what your deployment had, Foundry uses `latest` on generation by default.
- [ ] If your signoz version was older than latest, please check the [upgrade path](https://signoz.io/docs/operate/upgrade/) first.
- [ ] Check the produced manifests in `pours/deployment` match your older configurations. Extra consideration and review needs to be done on `compose.yaml` as this will be the main entry point for your new deployment.
- [ ] The configuration files for clickhouse are now in YAML so validate your custom settings are present.
```yaml
# casting.yaml
apiVersion: v1alpha1
kind: Installation
metadata:
name: signoz
spec:
deployment:
flavor: swarm
mode: docker
metastore:
kind: sqlite
telemetrykeeper:
kind: zookeeper
telemetrystore:
spec:
config:
data:
config-0-0.yaml: |
macros:
replica: "example01-01-1" # replace with your replica macro
shard: "01" # replace with your shard macro
patches:
- target: "deployment/compose.yaml"
operations:
- op: replace
path: /volumes/signoz-telemetrykeeper-0-data
value:
name: signoz-zookeeper-1
- op: replace
path: /volumes/signoz-telemetrystore-0-0-data
value:
name: signoz-clickhouse
- op: replace
path: /volumes/signoz-metastore-sqlite-0-data
value:
name: signoz-sqlite
- op: add
path: /services/signoz-telemetrykeeper-zookeeper-0/user
value: root
```
4. Execute a `docker compose down`. **Do not** include parameters such as `--volumes` (or `-v`), as it will wipe the volumes we need to maintain and reuse to avoid data loss.
</details>
> [!NOTE]
> This will generate downtime so please plan accordingly.
> The `user: root` patch on the ZooKeeper service lets the container read and
> write the data in your reused ZooKeeper volume, whose files the legacy setup
> created as `root`. Without it, ZooKeeper may fail to start with permission
> errors.
5. Validate the SigNoz containers are down with `docker ps -a`. Multiple containers cannot bind the same volume.
If you had custom configuration (SMTP, extra ingestion receivers/processors,
or custom ClickHouse settings), carry it over via [patches][patches],
[custom config files][custom-config], or [environment variables][env-vars].
6. Run `foundryctl cast -f casting.yaml`. This will recreate the containers based on the spec. This process will download new container images.
### 4. Generate and review the manifests
```bash
foundryctl forge -f casting.yaml
```
Review `pours/deployment/` before deploying:
- [ ] Container images match your current deployment. Foundry generates with
`latest` by default; if your SigNoz version was older than latest, check the
[upgrade path][upgrade-path] first.
- [ ] The generated manifests match your previous configuration, especially
`compose.yaml` (the new entry point for your deployment).
- [ ] The ClickHouse config is now YAML rather than XML; confirm your custom
settings carried over (see [ClickHouse configuration files][ch-config] for
the XML-to-YAML mapping).
### 5. Stop the old deployment
Use the command for your deployment. Do **not** pass `--volumes` / `-v`; that
would delete the data you are migrating.
```bash
docker compose down # Compose
docker stack rm signoz # Swarm
```
> [!NOTE]
> When `cast` is run, the migration container will execute its migrations.
> This causes downtime, so plan accordingly.
## Verifying the Migration
- SigNoz containers will be up and running.
- Log in to the SigNoz UI and verify that data is present.
- Signoz will run on localhost:8080
- Validate that your data ingestion is receiving data.
- Ingesters will receive data on localhost:4317(grpc) and localhost:4318(http)
- Review the logs from both ClickHouse and ZooKeeper; no errors should be present.
Confirm nothing is still bound to the volumes before continuing:
## Rolling Back
Because step 4 brought the legacy stack down *without* `-v`, your original volumes
are untouched and still hold your data. To roll back:
```bash
docker ps -a
```
- Stop and remove the containers created by Foundry (`docker compose down`, again without `-v`).
- Confirm the containers are gone with `docker ps -a` so nothing else is bound to the volumes.
- Reapply your original docker compose file (`docker compose up -d`). It will reattach to the
existing volumes and restore your prior state.
### 6. Deploy with Foundry
```bash
foundryctl cast -f casting.yaml
```
This recreates the containers against your existing volumes and pulls the
images. The migration container runs the schema migrations as part of `cast`.
**Prefer not to use `cast`?** The manifests in `pours/deployment/` are standard
Docker artifacts you can apply yourself. Run the command from that directory so
the relative config paths resolve:
```bash
cd pours/deployment
docker compose up -d # Compose
docker stack deploy -c compose.yaml signoz # Swarm
```
## Verify
- All SigNoz containers are running.
- The UI is reachable on `http://localhost:8080`, and OTLP on `4317` (gRPC)
and `4318` (HTTP), so already-instrumented apps and saved bookmarks keep
working.
- Your existing data is present in the UI, and new data is being ingested.
- ClickHouse and ZooKeeper logs show no errors.
## Roll back
Step 5 left your volumes untouched, so your data is intact. To return to the
previous setup:
1. Bring down the Foundry deployment (`docker compose down` or
`docker stack rm signoz`, again without `-v`).
2. Confirm the containers are gone with `docker ps -a`.
3. Re-apply your backed-up stack: `docker compose up -d` (Compose) or
`docker stack deploy -c docker-compose.yaml signoz` (Swarm). It reattaches
the same volumes and restores your prior state.
## Troubleshooting
- Please reach out to our community on [Slack](https://signoz.io/slack).
If the migration runs into trouble, reach out on [Slack][slack] or open a
[Foundry issue][foundry-issues].
## References
- [SigNoz Docker installation docs](https://signoz.io/docs/install/docker/)
- [SigNoz documentation](https://signoz.io/docs)
- [Foundry](https://github.com/SigNoz/foundry)
- [Foundry][foundry]
- [Casting file reference][casting-ref]
- [Custom config files][custom-config]
- [Patches][patches]
- [SigNoz documentation][signoz-docs]
[foundry]: https://github.com/SigNoz/foundry
[install-docs]: https://signoz.io/docs/install/
[compose-example]: https://github.com/SigNoz/foundry/tree/main/docs/examples/docker/compose
[patches]: https://github.com/SigNoz/foundry/blob/main/docs/concepts/patches.md
[custom-config]: https://github.com/SigNoz/foundry/blob/main/docs/concepts/moldings.md#custom-config-files
[env-vars]: https://github.com/SigNoz/foundry/blob/main/docs/reference/casting-file.md#molding-spec
[casting-ref]: https://github.com/SigNoz/foundry/blob/main/docs/reference/casting-file.md
[ch-config]: https://clickhouse.com/docs/operations/configuration-files
[upgrade-path]: https://signoz.io/docs/operate/upgrade/
[slack]: https://signoz.io/slack
[foundry-issues]: https://github.com/SigNoz/foundry/issues
[signoz-docs]: https://signoz.io/docs

File diff suppressed because it is too large Load Diff

View File

@@ -36,6 +36,55 @@ var (
> 💡 **Note**: Error codes must match the regex `^[a-z_]+$` otherwise the code will panic.
### Message
The primary, human-readable summary of what went wrong, set when the error is created via `errors.New` / `errors.Newf`. Note there are two distinct `message` fields in the response: this top-level one states the overall failure, while each entry under [Additional](#additional) carries its own [message](#message-1) explaining one specific facet of it.
### Url
An optional link to documentation that explains the error in more depth, set with `WithUrl`. It is left empty when the error has no associated doc.
```go
return errors.New(errors.TypeInvalidInput, CodeBadThing, "bad thing").
WithUrl("https://signoz.io/docs/...")
```
### Additional
`errors` is a list of supplementary details that explain the top-level `message`. Each entry has its own `message` and `suggestions`, so a single error can surface several distinct problems individually. Attach details with `WithAdditional` (message only) or `WithSuggestiveAdditional` (message plus the suggestions that belong to it):
#### Message
A single, self-contained sentence describing one specific facet of the error (e.g. ``field `filed` not found``), distinct from the top-level [Message](#message). Prefer one detail per distinct problem over concatenating several into one message.
#### Suggestions
The suggestions tied to that specific detail — typically a ``did you mean: `x` `` correction for the value the detail is about. These are distinct from the error-wide [Suggestions](#suggestions) below: detail-scoped suggestions never leak into the top-level list.
```go
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown field %q", field).
WithAdditional("field `field` not found")
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown field %q", field).
WithSuggestiveAdditional("field `filed` not found", "did you mean: `field`")
```
### Retry
Carries the `delay` the client should wait before retrying, set with `WithRetryAfter`. It is `null` when the error is not retryable.
```go
return errors.NewTimeoutf(CodeSlow, "upstream timed out").
WithRetryAfter(5 * time.Second)
```
### Suggestions
`WithSuggestions` sets the error-wide `suggestions` list — hints about the error as a whole (e.g. "narrow the time range window"), as opposed to suggestions tied to a single detail. Prefer the builders in [pkg/errors/suggestions.go](/pkg/errors/suggestions.go) over hand-writing the strings so the phrasing stays consistent:
- `NewSuggestionsOnLevenshteinDistance(invalidInput, noun, validInputs)` — returns a ``did you mean: `x` `` correction (when a close typo match exists) followed by the valid-references list.
- `NewValidReferences(noun, values...)` — formats a capped list as ``valid <noun> are `a`, `b` `` (e.g. `"valid fields are"`, `"valid keys are"`). Returns `""` for an empty set.
- `NewSuggestionsFromFunc(produce)` — wraps a caller-computed correction string as a one-element ``did you mean: `x` `` slice (or nil when it returns `""`), for callers with their own matching strategy.
`noun` names the kind of value being suggested. Use one of the exported `Noun*` constants (`errors.NounFields`, `errors.NounKeys`, `errors.NounServices`, …) so the wording stays uniform across the codebase.
```go
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown field %q", field).
WithSuggestions(errors.NewSuggestionsOnLevenshteinDistance(field, errors.NounFields, validFields)...)
```
## Show me some examples
### Using the error

View File

@@ -143,6 +143,10 @@ func (provider *provider) List(ctx context.Context, orgID valuer.UUID) ([]*autht
return provider.pkgAuthzService.List(ctx, orgID)
}
func (provider *provider) Collect(ctx context.Context, orgID valuer.UUID) (map[string]any, error) {
return provider.pkgAuthzService.Collect(ctx, orgID)
}
func (provider *provider) ListByOrgIDAndNames(ctx context.Context, orgID valuer.UUID, names []string) ([]*authtypes.Role, error) {
return provider.pkgAuthzService.ListByOrgIDAndNames(ctx, orgID, names)
}
@@ -370,7 +374,7 @@ func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valu
}
for _, cb := range provider.onBeforeRoleDelete {
if err := cb(ctx, orgID, id); err != nil {
if err := cb(ctx, orgID, id, role.Name); err != nil {
return err
}
}

View File

@@ -290,6 +290,10 @@ func (module *module) GetByMetricNames(ctx context.Context, orgID valuer.UUID, m
return module.pkgDashboardModule.GetByMetricNames(ctx, orgID, metricNames)
}
func (module *module) GetByMetricNamesV2(ctx context.Context, orgID valuer.UUID, metricNames []string) (map[string][]dashboardtypes.DashboardPanelRef, error) {
return module.pkgDashboardModule.GetByMetricNamesV2(ctx, orgID, metricNames)
}
func (module *module) List(ctx context.Context, orgID valuer.UUID) ([]*dashboardtypes.Dashboard, error) {
return module.pkgDashboardModule.List(ctx, orgID)
}

View File

@@ -0,0 +1,565 @@
package implmetricreductionrule
import (
"context"
"slices"
"time"
sqlbuilder "github.com/huandu/go-sqlbuilder"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
"github.com/SigNoz/signoz/pkg/types/metricreductionruletypes"
)
var (
reductionRulesTable = telemetrymetrics.DBName + "." + telemetrymetrics.ReductionRulesTableName
metadataTable = telemetrymetrics.DBName + "." + telemetrymetrics.AttributesMetadataTableName
bufferSeriesTable = telemetrymetrics.DBName + "." + telemetrymetrics.TimeseriesV4BufferTableName
)
const timeSeriesBucketMilli = int64(time.Hour / time.Millisecond)
type volumeRow struct {
MetricName string
Ingested uint64
Reduced uint64
}
type volumePoint struct {
TimestampMs int64
Ingested uint64
Reduced uint64
}
type clickhouse struct {
telemetryStore telemetrystore.TelemetryStore
threads int
}
func newClickhouse(telemetryStore telemetrystore.TelemetryStore, threads int) *clickhouse {
return &clickhouse{telemetryStore: telemetryStore, threads: threads}
}
func (c *clickhouse) withThreads(ctx context.Context) context.Context {
return ctxtypes.SetClickhouseMaxThreads(ctx, c.threads)
}
func floorToTimeSeriesBucket(ms int64) int64 {
return ms - (ms % timeSeriesBucketMilli)
}
func strictEffectiveFrom(sb *sqlbuilder.SelectBuilder, metricNames []string, effectiveFrom map[string]int64) string {
names := make([]any, 0, len(metricNames))
froms := make([]any, 0, len(metricNames))
for _, name := range metricNames {
names = append(names, name)
froms = append(froms, effectiveFrom[name])
}
return "unix_milli >= transform(metric_name, " + sb.Var(names) + ", " + sb.Var(froms) + ", 0)"
}
func (c *clickhouse) Sync(ctx context.Context, metricName string, labels []string, matchType string, effectiveFromMs int64, deleted bool, updatedAt time.Time) error {
ctx = c.withThreads(ctx)
ib := sqlbuilder.NewInsertBuilder()
ib.InsertInto(reductionRulesTable)
ib.Cols("metric_name", "labels", "match_type", "effective_from_unix_milli", "deleted", "updated_at")
ib.Values(metricName, labels, matchType, effectiveFromMs, deleted, updatedAt)
query, args := ib.BuildWithFlavor(sqlbuilder.ClickHouse)
if err := c.telemetryStore.ClickhouseDB().Exec(ctx, query, args...); err != nil {
return errors.WrapInternalf(err, errors.CodeInternal, "failed to sync reduction rule to clickhouse")
}
return nil
}
func (c *clickhouse) AttributeKeys(ctx context.Context, metricName string, startMs, endMs int64) ([]string, error) {
ctx = c.withThreads(ctx)
sb := sqlbuilder.NewSelectBuilder()
sb.Select("attr_name")
sb.Distinct()
sb.From(metadataTable)
sb.Where(
sb.E("metric_name", metricName),
"NOT startsWith(attr_name, '__')",
sb.GE("last_reported_unix_milli", startMs),
sb.LE("first_reported_unix_milli", endMs),
)
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
rows, err := c.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to fetch metric attribute keys")
}
defer rows.Close()
keys := make([]string, 0)
for rows.Next() {
var key string
if err := rows.Scan(&key); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to scan attribute key")
}
keys = append(keys, key)
}
return keys, rows.Err()
}
func (c *clickhouse) EstimateCardinality(ctx context.Context, metricName string, keptLabels []string, startMs, endMs int64) (uint64, uint64, error) {
ctx = c.withThreads(ctx)
startMs = floorToTimeSeriesBucket(startMs)
sb := sqlbuilder.NewSelectBuilder()
reducedExpr := "1"
if len(keptLabels) > 0 {
reducedExpr = "uniq(("
for i, label := range keptLabels {
if i > 0 {
reducedExpr += ", "
}
reducedExpr += "JSONExtractString(labels, " + sb.Var(label) + ")"
}
reducedExpr += "))"
}
sb.Select("uniq(fingerprint)", reducedExpr)
sb.From(bufferSeriesTable)
conds := []string{
sb.E("metric_name", metricName),
sb.GE("unix_milli", startMs),
sb.LT("unix_milli", endMs),
sb.E("is_reduced", false),
}
sb.Where(conds...)
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
var current, reduced uint64
if err := c.telemetryStore.ClickhouseDB().QueryRow(ctx, query, args...).Scan(&current, &reduced); err != nil {
return 0, 0, errors.WrapInternalf(err, errors.CodeInternal, "failed to estimate reduction impact")
}
if len(keptLabels) == 0 && current == 0 {
reduced = 0
}
if reduced > current {
reduced = current
}
return current, reduced, nil
}
// VolumeByMetric returns ingested vs reduced series counts per metric.
func (c *clickhouse) VolumeByMetric(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[string]volumeRow, error) {
if len(metricNames) == 0 {
return map[string]volumeRow{}, nil
}
ctx = c.withThreads(ctx)
ingested, err := c.ingestedSeriesCount(ctx, metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
reduced, err := c.reducedSeriesCount(ctx, metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
out := make(map[string]volumeRow, len(metricNames))
for metricName, count := range ingested {
out[metricName] = volumeRow{MetricName: metricName, Ingested: count, Reduced: out[metricName].Reduced}
}
for metricName, count := range reduced {
row := out[metricName]
row.MetricName = metricName
row.Reduced = count
out[metricName] = row
}
return out, nil
}
// ingestedSeriesCount counts distinct raw fingerprints per metric from the samples buffer over the
// window.
func (c *clickhouse) ingestedSeriesCount(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[string]uint64, error) {
names := make([]any, len(metricNames))
for i, name := range metricNames {
names[i] = name
}
sb := sqlbuilder.NewSelectBuilder()
sb.Select("metric_name", "uniq(fingerprint)")
sb.From(telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4BufferTableName)
conds := []string{
sb.In("metric_name", names...),
sb.GE("unix_milli", startMs),
sb.LT("unix_milli", endMs),
}
if len(effectiveFrom) > 0 {
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
}
sb.Where(conds...)
sb.GroupBy("metric_name")
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
rows, err := c.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to count ingested series")
}
defer rows.Close()
out := make(map[string]uint64, len(metricNames))
for rows.Next() {
var (
metricName string
count uint64
)
if err := rows.Scan(&metricName, &count); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to scan series count")
}
out[metricName] = count
}
return out, rows.Err()
}
// reducedSeriesCount counts distinct reduced_fingerprints per metric, summed across the two 60s
// reduced sample tables.
func (c *clickhouse) reducedSeriesCount(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[string]uint64, error) {
out := make(map[string]uint64, len(metricNames))
for _, table := range []string{telemetrymetrics.SamplesV4ReducedLastTableName, telemetrymetrics.SamplesV4ReducedSumTableName} {
counts, err := c.reducedSeriesCountForTable(ctx, telemetrymetrics.DBName+"."+table, metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
for metricName, count := range counts {
out[metricName] += count
}
}
return out, nil
}
func (c *clickhouse) reducedSeriesCountForTable(ctx context.Context, table string, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[string]uint64, error) {
names := make([]any, len(metricNames))
for i, name := range metricNames {
names[i] = name
}
sb := sqlbuilder.NewSelectBuilder()
sb.Select("metric_name", "uniq(reduced_fingerprint)")
sb.From(table)
conds := []string{
sb.In("metric_name", names...),
sb.GE("unix_milli", startMs),
sb.LT("unix_milli", endMs),
}
if len(effectiveFrom) > 0 {
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
}
sb.Where(conds...)
sb.GroupBy("metric_name")
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
rows, err := c.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to count reduced series")
}
defer rows.Close()
out := make(map[string]uint64, len(metricNames))
for rows.Next() {
var (
metricName string
count uint64
)
if err := rows.Scan(&metricName, &count); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to scan series count")
}
out[metricName] = count
}
return out, rows.Err()
}
// RankByVolume ranks metrics by ingested/reduced series volume. Like VolumeByMetric, the counts read
// the samples tables with a strict effective_from gate; the reduced count sums distinct
// reduced_fingerprints across the two 60s reduced sample tables.
func (c *clickhouse) RankByVolume(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, orderBy metricreductionruletypes.ReductionRuleOrderBy, order metricreductionruletypes.Order, startMs, endMs int64, offset, limit int) ([]volumeRow, error) {
if len(metricNames) == 0 {
return []volumeRow{}, nil
}
ctx = c.withThreads(ctx)
orderExpr := "ingested"
switch orderBy {
case metricreductionruletypes.OrderByReducedVolume:
orderExpr = "reduced"
case metricreductionruletypes.OrderByReduction:
orderExpr = "if(ingested = 0, 0, (toFloat64(ingested) - toFloat64(reduced)) / toFloat64(ingested))"
}
direction := "ASC"
if order == metricreductionruletypes.OrderDesc {
direction = "DESC"
}
ingestedTable := telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4BufferTableName
reducedLast := telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4ReducedLastTableName
reducedSum := telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4ReducedSumTableName
sb := sqlbuilder.NewSelectBuilder()
sb.Select("base.metric_name AS metric_name", "ifNull(i.cnt, 0) AS ingested", "ifNull(d.cnt, 0) AS reduced")
sb.From("(SELECT arrayJoin(" + sb.Var(metricNames) + ") AS metric_name) AS base")
sb.JoinWithOption(
sqlbuilder.LeftJoin,
"(SELECT metric_name, uniq(fingerprint) AS cnt FROM "+ingestedTable+" WHERE has("+sb.Var(metricNames)+", metric_name) AND unix_milli >= "+sb.Var(startMs)+" AND unix_milli < "+sb.Var(endMs)+" AND "+strictEffectiveFrom(sb, metricNames, effectiveFrom)+" GROUP BY metric_name) AS i",
"base.metric_name = i.metric_name",
)
// Reduced series are spread across two type-specific tables; union the per-table distinct
// reduced_fingerprints and sum per metric (a metric only lands in the table matching its type).
sb.JoinWithOption(
sqlbuilder.LeftJoin,
"(SELECT metric_name, sum(cnt) AS cnt FROM ("+
"SELECT metric_name, uniq(reduced_fingerprint) AS cnt FROM "+reducedLast+" WHERE has("+sb.Var(metricNames)+", metric_name) AND unix_milli >= "+sb.Var(startMs)+" AND unix_milli < "+sb.Var(endMs)+" AND "+strictEffectiveFrom(sb, metricNames, effectiveFrom)+" GROUP BY metric_name"+
" UNION ALL "+
"SELECT metric_name, uniq(reduced_fingerprint) AS cnt FROM "+reducedSum+" WHERE has("+sb.Var(metricNames)+", metric_name) AND unix_milli >= "+sb.Var(startMs)+" AND unix_milli < "+sb.Var(endMs)+" AND "+strictEffectiveFrom(sb, metricNames, effectiveFrom)+" GROUP BY metric_name"+
") GROUP BY metric_name) AS d",
"base.metric_name = d.metric_name",
)
sb.OrderBy(orderExpr + " " + direction)
if limit > 0 {
sb.Limit(limit).Offset(offset)
}
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
rows, err := c.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to rank reduction rules by volume")
}
defer rows.Close()
out := make([]volumeRow, 0, len(metricNames))
for rows.Next() {
var row volumeRow
if err := rows.Scan(&row.MetricName, &row.Ingested, &row.Reduced); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to scan volume row")
}
out = append(out, row)
}
return out, rows.Err()
}
func (c *clickhouse) SampleVolume(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (uint64, uint64, error) {
if len(metricNames) == 0 {
return 0, 0, nil
}
ctx = c.withThreads(ctx)
ingested, err := c.countRawSamples(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4BufferTableName, metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return 0, 0, err
}
last, err := c.countReducedSamples(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4ReducedLastTableName, metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return 0, 0, err
}
sum, err := c.countReducedSamples(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4ReducedSumTableName, metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return 0, 0, err
}
return ingested, min(last+sum, ingested), nil
}
func (c *clickhouse) countRawSamples(ctx context.Context, table string, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (uint64, error) {
names := make([]any, len(metricNames))
for i, name := range metricNames {
names[i] = name
}
sb := sqlbuilder.NewSelectBuilder()
sb.Select("count()")
sb.From(table)
conds := []string{sb.In("metric_name", names...), sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs)}
if len(effectiveFrom) > 0 {
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
}
sb.Where(conds...)
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
var count uint64
if err := c.telemetryStore.ClickhouseDB().QueryRow(ctx, query, args...).Scan(&count); err != nil {
return 0, errors.WrapInternalf(err, errors.CodeInternal, "failed to count ingested samples")
}
return count, nil
}
func (c *clickhouse) countReducedSamples(ctx context.Context, table string, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (uint64, error) {
names := make([]any, len(metricNames))
for i, name := range metricNames {
names[i] = name
}
sb := sqlbuilder.NewSelectBuilder()
// Reduced tables key the series on reduced_fingerprint (not fingerprint); dedupe ReplacingMergeTree recomputes.
sb.Select("uniq(reduced_fingerprint, unix_milli)")
sb.From(table)
conds := []string{sb.In("metric_name", names...), sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs)}
if len(effectiveFrom) > 0 {
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
}
sb.Where(conds...)
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
var count uint64
if err := c.telemetryStore.ClickhouseDB().QueryRow(ctx, query, args...).Scan(&count); err != nil {
return 0, errors.WrapInternalf(err, errors.CodeInternal, "failed to count reduced samples")
}
return count, nil
}
// SeriesTimeseries returns ingested vs reduced series per 60s bucket from the samples tables, gated
// to each metric's strict effective_from (see strictEffectiveFrom).
func (c *clickhouse) SeriesTimeseries(ctx context.Context, allMetrics, reducedMetrics []string, effectiveFrom map[string]int64, startMs, endMs int64) ([]volumePoint, error) {
if len(allMetrics) == 0 {
return []volumePoint{}, nil
}
ctx = c.withThreads(ctx)
ingested, err := c.ingestedSeriesByBucket(ctx, allMetrics, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
retained := make(map[int64]uint64)
if len(reducedMetrics) > 0 {
reduced, err := c.reducedSeriesByBucket(ctx, reducedMetrics, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
for ts, count := range reduced {
retained[ts] += count
}
}
reducedSet := make(map[string]struct{}, len(reducedMetrics))
for _, name := range reducedMetrics {
reducedSet[name] = struct{}{}
}
nonReduced := make([]string, 0, len(allMetrics))
for _, name := range allMetrics {
if _, ok := reducedSet[name]; !ok {
nonReduced = append(nonReduced, name)
}
}
if len(nonReduced) > 0 {
nonReducedIngested, err := c.ingestedSeriesByBucket(ctx, nonReduced, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
for ts, count := range nonReducedIngested {
retained[ts] += count
}
}
return mergeVolumePoints(ingested, retained), nil
}
func mergeVolumePoints(ingested, reduced map[int64]uint64) []volumePoint {
buckets := make(map[int64]struct{}, len(ingested))
for ts := range ingested {
buckets[ts] = struct{}{}
}
for ts := range reduced {
buckets[ts] = struct{}{}
}
timestamps := make([]int64, 0, len(buckets))
for ts := range buckets {
timestamps = append(timestamps, ts)
}
slices.Sort(timestamps)
points := make([]volumePoint, 0, len(timestamps))
for _, ts := range timestamps {
points = append(points, volumePoint{
TimestampMs: ts,
Ingested: ingested[ts],
Reduced: reduced[ts],
})
}
return points
}
// ingestedSeriesByBucket counts distinct raw fingerprints per hourly bucket from the samples buffer.
func (c *clickhouse) ingestedSeriesByBucket(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[int64]uint64, error) {
names := make([]any, len(metricNames))
for i, name := range metricNames {
names[i] = name
}
sb := sqlbuilder.NewSelectBuilder()
bucketExpr := "toInt64(toUnixTimestamp(toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalHour(1)))) * 1000 AS bucket"
sb.Select(bucketExpr, "uniq(fingerprint)")
sb.From(telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4BufferTableName)
conds := []string{sb.In("metric_name", names...), sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs)}
if len(effectiveFrom) > 0 {
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
}
sb.Where(conds...)
sb.GroupBy("bucket")
return c.scanBuckets(ctx, sb)
}
// reducedSeriesByBucket counts distinct reduced_fingerprints per hourly bucket, summed across the two
// reduced sample tables (a metric only lands in the table matching its type, so per-bucket sums are
// exact).
func (c *clickhouse) reducedSeriesByBucket(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[int64]uint64, error) {
out := make(map[int64]uint64)
for _, table := range []string{telemetrymetrics.SamplesV4ReducedLastTableName, telemetrymetrics.SamplesV4ReducedSumTableName} {
names := make([]any, len(metricNames))
for i, name := range metricNames {
names[i] = name
}
sb := sqlbuilder.NewSelectBuilder()
bucketExpr := "toInt64(toUnixTimestamp(toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalHour(1)))) * 1000 AS bucket"
sb.Select(bucketExpr, "uniq(reduced_fingerprint)")
sb.From(telemetrymetrics.DBName + "." + table)
conds := []string{sb.In("metric_name", names...), sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs)}
if len(effectiveFrom) > 0 {
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
}
sb.Where(conds...)
sb.GroupBy("bucket")
counts, err := c.scanBuckets(ctx, sb)
if err != nil {
return nil, err
}
for ts, count := range counts {
out[ts] += count
}
}
return out, nil
}
func (c *clickhouse) scanBuckets(ctx context.Context, sb *sqlbuilder.SelectBuilder) (map[int64]uint64, error) {
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
rows, err := c.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to bucket series by time")
}
defer rows.Close()
out := make(map[int64]uint64)
for rows.Next() {
var (
ts int64
count uint64
)
if err := rows.Scan(&ts, &count); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to scan series bucket")
}
out[ts] = count
}
return out, rows.Err()
}

View File

@@ -0,0 +1,571 @@
package implmetricreductionrule
import (
"context"
"log/slog"
"sort"
"strings"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/modules/metricreductionrule"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/ruler/rulestore/sqlrulestore"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/featuretypes"
"github.com/SigNoz/signoz/pkg/types/metricreductionruletypes"
"github.com/SigNoz/signoz/pkg/types/metrictypes"
"github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
const (
// effectiveFromMargin delays effective_from so the collector picks up the synced rule before it
// goes live; it must be >= the collector's rule-refresh interval (see signoz-otel-collector#839).
effectiveFromMargin = 5 * time.Minute
defaultPreviewLookback = 24 * time.Hour
pricePerMillionSamplesUSD = 0.1
monthDuration = 30 * 24 * time.Hour
)
type module struct {
store metricreductionruletypes.Store
ch *clickhouse
dashboard dashboard.Module
ruleStore ruletypes.RuleStore
licensing licensing.Licensing
flagger flagger.Flagger
metadataStore telemetrytypes.MetadataStore
logger *slog.Logger
}
func NewModule(sqlStore sqlstore.SQLStore, telemetryStore telemetrystore.TelemetryStore, dashboardModule dashboard.Module, queryParser queryparser.QueryParser, licensing licensing.Licensing, flagger flagger.Flagger, metadataStore telemetrytypes.MetadataStore, providerSettings factory.ProviderSettings, threads int) metricreductionrule.Module {
scoped := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/ee/modules/metricreductionrule/implmetricreductionrule")
return &module{
store: NewStore(sqlStore),
ch: newClickhouse(telemetryStore, threads),
dashboard: dashboardModule,
ruleStore: sqlrulestore.NewRuleStore(sqlStore, queryParser, providerSettings),
licensing: licensing,
flagger: flagger,
metadataStore: metadataStore,
logger: scoped.Logger(),
}
}
func (m *module) checkAccess(ctx context.Context, orgID valuer.UUID) error {
if !m.flagger.BooleanOrEmpty(ctx, flagger.FeatureEnableMetricsReduction, featuretypes.NewFlaggerEvaluationContext(orgID)) {
return errors.Newf(errors.TypeUnsupported, metricreductionruletypes.ErrCodeMetricReductionRuleUnsupported, "metric volume control is not enabled")
}
if _, err := m.licensing.GetActive(ctx, orgID); err != nil {
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "metric volume control requires a valid license").WithAdditional(err.Error())
}
return nil
}
func (m *module) List(ctx context.Context, orgID valuer.UUID, params *metricreductionruletypes.ListReductionRulesParams) (*metricreductionruletypes.GettableReductionRules, error) {
if err := m.checkAccess(ctx, orgID); err != nil {
return nil, err
}
if err := params.Validate(); err != nil {
return nil, err
}
now := time.Now()
startMs := now.Add(-defaultPreviewLookback).UnixMilli()
endMs := now.UnixMilli()
switch params.OrderBy {
case metricreductionruletypes.OrderByMetricName, metricreductionruletypes.OrderByLastUpdated:
return m.listSortedByColumn(ctx, orgID, params, startMs, endMs)
default:
return m.listSortedByVolume(ctx, orgID, params, startMs, endMs)
}
}
func (m *module) listSortedByColumn(ctx context.Context, orgID valuer.UUID, params *metricreductionruletypes.ListReductionRulesParams, startMs, endMs int64) (*metricreductionruletypes.GettableReductionRules, error) {
domainRules, total, err := m.store.List(ctx, orgID, params)
if err != nil {
return nil, err
}
metricNames := make([]string, len(domainRules))
effectiveFrom := make(map[string]int64, len(domainRules))
for i, rule := range domainRules {
metricNames[i] = rule.MetricName
effectiveFrom[rule.MetricName] = rule.EffectiveFrom.UnixMilli()
}
volumes, err := m.ch.VolumeByMetric(ctx, metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
rules := make([]metricreductionruletypes.GettableReductionRule, 0, len(domainRules))
for _, rule := range domainRules {
rules = append(rules, withVolume(toGettableReductionRule(rule), volumes[rule.MetricName]))
}
return &metricreductionruletypes.GettableReductionRules{Rules: rules, Total: total}, nil
}
func (m *module) listSortedByVolume(ctx context.Context, orgID valuer.UUID, params *metricreductionruletypes.ListReductionRulesParams, startMs, endMs int64) (*metricreductionruletypes.GettableReductionRules, error) {
allRules, total, err := m.store.List(ctx, orgID, &metricreductionruletypes.ListReductionRulesParams{Search: params.Search, MetricName: params.MetricName})
if err != nil {
return nil, err
}
if total == 0 {
return &metricreductionruletypes.GettableReductionRules{Rules: []metricreductionruletypes.GettableReductionRule{}, Total: 0}, nil
}
metricNames := make([]string, len(allRules))
effectiveFrom := make(map[string]int64, len(allRules))
ruleByMetric := make(map[string]*metricreductionruletypes.ReductionRule, len(allRules))
for i, rule := range allRules {
metricNames[i] = rule.MetricName
effectiveFrom[rule.MetricName] = rule.EffectiveFrom.UnixMilli()
ruleByMetric[rule.MetricName] = rule
}
ranked, err := m.ch.RankByVolume(ctx, metricNames, effectiveFrom, params.OrderBy, params.Order, startMs, endMs, params.Offset, params.Limit)
if err != nil {
return nil, err
}
rules := make([]metricreductionruletypes.GettableReductionRule, 0, len(ranked))
for _, row := range ranked {
rule, ok := ruleByMetric[row.MetricName]
if !ok {
continue
}
rules = append(rules, withVolume(toGettableReductionRule(rule), row))
}
return &metricreductionruletypes.GettableReductionRules{Rules: rules, Total: total}, nil
}
func (m *module) Create(ctx context.Context, orgID valuer.UUID, userEmail string, req *metricreductionruletypes.PostableReductionRule) (*metricreductionruletypes.GettableReductionRule, error) {
if err := m.checkAccess(ctx, orgID); err != nil {
return nil, err
}
if err := req.Validate(); err != nil {
return nil, err
}
if err := m.validateMetricForReduction(ctx, orgID, req.MetricName); err != nil {
return nil, err
}
now := time.Now()
rule := metricreductionruletypes.NewReductionRule(orgID, req.MetricName, req.MatchType, req.Labels, now.Add(effectiveFromMargin), userEmail)
if err := m.store.RunInTx(ctx, func(ctx context.Context) error {
if err := m.store.Create(ctx, rule); err != nil {
return err
}
return m.ch.Sync(ctx, rule.MetricName, rule.Labels, rule.MatchType.StringValue(), rule.EffectiveFrom.UnixMilli(), false, rule.UpdatedAt)
}); err != nil {
return nil, err
}
gettable := toGettableReductionRule(rule)
return &gettable, nil
}
func (m *module) GetByID(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*metricreductionruletypes.GettableReductionRule, error) {
if err := m.checkAccess(ctx, orgID); err != nil {
return nil, err
}
rule, err := m.store.GetByID(ctx, orgID, id)
if err != nil {
return nil, err
}
gettable := toGettableReductionRule(rule)
return &gettable, nil
}
func (m *module) UpdateByID(ctx context.Context, orgID valuer.UUID, userEmail string, id valuer.UUID, req *metricreductionruletypes.UpdatableReductionRule) (*metricreductionruletypes.GettableReductionRule, error) {
if err := m.checkAccess(ctx, orgID); err != nil {
return nil, err
}
existing, err := m.store.GetByID(ctx, orgID, id)
if err != nil {
return nil, err
}
if err := req.Validate(); err != nil {
return nil, err
}
now := time.Now()
existing.MatchType = req.MatchType
existing.Labels = metricreductionruletypes.LabelList(req.Labels)
existing.EffectiveFrom = now.Add(effectiveFromMargin)
existing.UpdatedAt = now
existing.UpdatedBy = userEmail
if err := m.store.RunInTx(ctx, func(ctx context.Context) error {
if err := m.store.Upsert(ctx, existing); err != nil {
return err
}
return m.ch.Sync(ctx, existing.MetricName, existing.Labels, existing.MatchType.StringValue(), existing.EffectiveFrom.UnixMilli(), false, existing.UpdatedAt)
}); err != nil {
return nil, err
}
gettable := toGettableReductionRule(existing)
return &gettable, nil
}
func (m *module) DeleteByID(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
if err := m.checkAccess(ctx, orgID); err != nil {
return err
}
rule, err := m.store.GetByID(ctx, orgID, id)
if err != nil {
return err
}
now := time.Now()
effectiveFromMs := now.Add(effectiveFromMargin).UnixMilli()
return m.store.RunInTx(ctx, func(ctx context.Context) error {
if err := m.store.DeleteByID(ctx, orgID, id); err != nil {
return err
}
return m.ch.Sync(ctx, rule.MetricName, []string{}, metricreductionruletypes.MatchTypeDrop.StringValue(), effectiveFromMs, true, now)
})
}
func (m *module) Preview(ctx context.Context, orgID valuer.UUID, req *metricreductionruletypes.PostableReductionRulePreview) (*metricreductionruletypes.GettableReductionRulePreview, error) {
if err := m.checkAccess(ctx, orgID); err != nil {
return nil, err
}
if err := req.Validate(); err != nil {
return nil, err
}
if err := m.validateMetricForReduction(ctx, orgID, req.MetricName); err != nil {
return nil, err
}
lookback := time.Duration(req.LookbackMs) * time.Millisecond
if lookback <= 0 {
lookback = defaultPreviewLookback
}
now := time.Now()
startMs := now.Add(-lookback).UnixMilli()
endMs := now.UnixMilli()
current, reduced, reductionPercent, dropped, err := m.estimateVolume(ctx, req.MetricName, req.MatchType, req.Labels, startMs, endMs)
if err != nil {
return nil, err
}
// Baseline is what the metric keeps today (its current rule, or raw if none) so the preview reads
// as current -> proposed.
currentReduced := current
if existing, gerr := m.store.Get(ctx, orgID, req.MetricName); gerr == nil {
if _, existingReduced, _, _, eerr := m.estimateVolume(ctx, req.MetricName, existing.MatchType, existing.Labels, startMs, endMs); eerr == nil {
currentReduced = existingReduced
}
}
return &metricreductionruletypes.GettableReductionRulePreview{
IngestedSeries: current,
CurrentRetainedSeries: currentReduced,
RetainedSeries: reduced,
ReductionPercent: reductionPercent,
DroppedLabels: dropped,
AffectedAssets: m.relatedAssetImpact(ctx, orgID, req.MetricName, dropped),
EffectiveFrom: now.Add(effectiveFromMargin),
}, nil
}
func (m *module) Stats(ctx context.Context, orgID valuer.UUID) (*metricreductionruletypes.GettableReductionRuleStats, error) {
if err := m.checkAccess(ctx, orgID); err != nil {
return nil, err
}
now := time.Now()
startMs := now.Add(-defaultPreviewLookback).UnixMilli()
endMs := now.UnixMilli()
allRules, total, err := m.store.List(ctx, orgID, &metricreductionruletypes.ListReductionRulesParams{})
if err != nil {
return nil, err
}
if total == 0 {
return &metricreductionruletypes.GettableReductionRuleStats{}, nil
}
metricNames := make([]string, len(allRules))
effectiveFrom := make(map[string]int64, len(allRules))
for i, rule := range allRules {
metricNames[i] = rule.MetricName
effectiveFrom[rule.MetricName] = rule.EffectiveFrom.UnixMilli()
}
volumes, err := m.ch.VolumeByMetric(ctx, metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
var ingestedSeries, retainedSeries uint64
reducedMetricNames := make([]string, 0, len(volumes))
reducedEffectiveFrom := make(map[string]int64, len(volumes))
for name, volume := range volumes {
ingestedSeries += volume.Ingested
retained := effectiveRetained(volume.Ingested, volume.Reduced)
retainedSeries += retained
if retained < volume.Ingested {
reducedMetricNames = append(reducedMetricNames, name)
reducedEffectiveFrom[name] = effectiveFrom[name]
}
}
ingestedSamples, reducedSamples, err := m.ch.SampleVolume(ctx, reducedMetricNames, reducedEffectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
return &metricreductionruletypes.GettableReductionRuleStats{
IngestedSeries: ingestedSeries,
RetainedSeries: retainedSeries,
EstimatedMonthlySavingsUsd: monthlySavingsUSD(ingestedSamples, reducedSamples, startMs, endMs),
}, nil
}
// monthlySavingsUSD extrapolates the windowed sample reduction to a monthly figure at the per-sample
// list price. Ingested is gated to effective_from upstream, so pre-activation hours don't inflate it.
func monthlySavingsUSD(ingestedSamples, reducedSamples uint64, startMs, endMs int64) float64 {
if reducedSamples >= ingestedSamples || endMs <= startMs {
return 0
}
savedSamples := float64(ingestedSamples - reducedSamples)
monthlySamples := savedSamples * float64(monthDuration.Milliseconds()) / float64(endMs-startMs)
return monthlySamples / 1_000_000 * pricePerMillionSamplesUSD
}
func (m *module) Timeseries(ctx context.Context, orgID valuer.UUID) (*querybuildertypesv5.QueryRangeResponse, error) {
if err := m.checkAccess(ctx, orgID); err != nil {
return nil, err
}
now := time.Now()
startMs := now.Add(-defaultPreviewLookback).UnixMilli()
endMs := now.UnixMilli()
allRules, _, err := m.store.List(ctx, orgID, &metricreductionruletypes.ListReductionRulesParams{})
if err != nil {
return nil, err
}
metricNames := make([]string, len(allRules))
effectiveFrom := make(map[string]int64, len(allRules))
for i, rule := range allRules {
metricNames[i] = rule.MetricName
effectiveFrom[rule.MetricName] = rule.EffectiveFrom.UnixMilli()
}
volumes, err := m.ch.VolumeByMetric(ctx, metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
reducedNames := make([]string, 0, len(volumes))
for name, volume := range volumes {
if effectiveRetained(volume.Ingested, volume.Reduced) < volume.Ingested {
reducedNames = append(reducedNames, name)
}
}
points, err := m.ch.SeriesTimeseries(ctx, metricNames, reducedNames, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
return buildVolumeTimeseries(points), nil
}
func buildVolumeTimeseries(points []volumePoint) *querybuildertypesv5.QueryRangeResponse {
ingested := make([]*querybuildertypesv5.TimeSeriesValue, 0, len(points))
reduced := make([]*querybuildertypesv5.TimeSeriesValue, 0, len(points))
for _, point := range points {
ingested = append(ingested, &querybuildertypesv5.TimeSeriesValue{Timestamp: point.TimestampMs, Value: float64(point.Ingested)})
reduced = append(reduced, &querybuildertypesv5.TimeSeriesValue{Timestamp: point.TimestampMs, Value: float64(point.Reduced)})
}
return &querybuildertypesv5.QueryRangeResponse{
Type: querybuildertypesv5.RequestTypeTimeSeries,
Data: querybuildertypesv5.QueryData{
Results: []any{
&querybuildertypesv5.TimeSeriesData{
QueryName: "reduction_volume",
Aggregations: []*querybuildertypesv5.AggregationBucket{
{
Series: []*querybuildertypesv5.TimeSeries{
{Labels: []*querybuildertypesv5.Label{{Key: telemetrytypes.TelemetryFieldKey{Name: "series"}, Value: "ingested"}}, Values: ingested},
{Labels: []*querybuildertypesv5.Label{{Key: telemetrytypes.TelemetryFieldKey{Name: "series"}, Value: "retained"}}, Values: reduced},
},
},
},
},
},
},
}
}
func (m *module) validateMetricForReduction(ctx context.Context, orgID valuer.UUID, metricName string) error {
lastSeen, err := m.metadataStore.FetchLastSeenInfoMulti(ctx, metricName)
if err != nil {
return err
}
if lastSeen[metricName] == 0 {
return errors.NewNotFoundf(errors.CodeNotFound, "metric not found: %q", metricName)
}
now := time.Now()
startTs := uint64(now.Add(-defaultPreviewLookback).UnixMilli())
endTs := uint64(now.UnixMilli())
_, types, _, err := m.metadataStore.FetchTemporalityAndTypeMulti(ctx, orgID, startTs, endTs, metricName)
if err != nil {
return err
}
if types[metricName] == metrictypes.ExpHistogramType {
return errors.Newf(errors.TypeInvalidInput, metricreductionruletypes.ErrCodeMetricReductionRuleUnsupportedMetricType,
"exponential histogram metrics cannot be reduced in v1")
}
return nil
}
func (m *module) relatedAssetImpact(ctx context.Context, orgID valuer.UUID, metricName string, dropped []string) []metricreductionruletypes.AffectedAsset {
affected := make([]metricreductionruletypes.AffectedAsset, 0)
droppedSet := make(map[string]struct{}, len(dropped))
for _, label := range dropped {
droppedSet[label] = struct{}{}
}
if dashboards, err := m.dashboard.GetByMetricNames(ctx, orgID, []string{metricName}); err != nil {
m.logger.WarnContext(ctx, "failed to fetch related dashboards for reduction preview", slog.String("metric_name", metricName), errors.Attr(err))
} else {
for _, item := range dashboards[metricName] {
usedLabels := append(splitCSV(item["group_by"]), splitCSV(item["filter_by"])...)
affected = append(affected, metricreductionruletypes.AffectedAsset{
Type: metricreductionruletypes.AssetTypeDashboard,
ID: item["dashboard_id"],
Name: item["dashboard_name"],
Widget: &metricreductionruletypes.AffectedWidget{ID: item["widget_id"], Name: item["widget_name"]},
ImpactedLabels: intersectLabels(usedLabels, droppedSet),
})
}
}
if alerts, err := m.ruleStore.GetStoredRulesByMetricName(ctx, orgID.String(), metricName); err != nil {
m.logger.WarnContext(ctx, "failed to fetch related alerts for reduction preview", slog.String("metric_name", metricName), errors.Attr(err))
} else {
for _, a := range alerts {
affected = append(affected, metricreductionruletypes.AffectedAsset{
Type: metricreductionruletypes.AssetTypeAlert,
ID: a.AlertID,
Name: a.AlertName,
})
}
}
return affected
}
func toGettableReductionRule(rule *metricreductionruletypes.ReductionRule) metricreductionruletypes.GettableReductionRule {
return metricreductionruletypes.GettableReductionRule{
Identifiable: rule.Identifiable,
TimeAuditable: rule.TimeAuditable,
UserAuditable: rule.UserAuditable,
MetricName: rule.MetricName,
MatchType: rule.MatchType,
Labels: rule.Labels,
EffectiveFrom: rule.EffectiveFrom,
Active: !rule.EffectiveFrom.After(time.Now()),
}
}
func effectiveRetained(ingested, reduced uint64) uint64 {
if reduced == 0 || reduced > ingested {
return ingested
}
return reduced
}
func withVolume(rule metricreductionruletypes.GettableReductionRule, volume volumeRow) metricreductionruletypes.GettableReductionRule {
rule.IngestedSeries = volume.Ingested
rule.RetainedSeries = effectiveRetained(volume.Ingested, volume.Reduced)
if volume.Ingested > 0 {
rule.ReductionPercent = (1 - float64(rule.RetainedSeries)/float64(volume.Ingested)) * 100
}
return rule
}
func intersectLabels(keys []string, droppedSet map[string]struct{}) []string {
seen := make(map[string]struct{})
var out []string
for _, key := range keys {
if _, ok := droppedSet[key]; !ok {
continue
}
if _, dup := seen[key]; dup {
continue
}
seen[key] = struct{}{}
out = append(out, key)
}
return out
}
func splitCSV(s string) []string {
if s == "" {
return nil
}
return strings.Split(s, ",")
}
func resolveDroppedKept(matchType metricreductionruletypes.MatchType, ruleLabels, keys []string) (dropped, kept []string) {
ruleSet := make(map[string]struct{}, len(ruleLabels))
for _, l := range ruleLabels {
ruleSet[l] = struct{}{}
}
for _, k := range keys {
if metricreductionruletypes.IsProtectedLabel(k) {
kept = append(kept, k)
continue
}
_, listed := ruleSet[k]
drop := listed
if matchType == metricreductionruletypes.MatchTypeKeep {
drop = !listed
}
if drop {
dropped = append(dropped, k)
} else {
kept = append(kept, k)
}
}
sort.Strings(dropped)
sort.Strings(kept)
return dropped, kept
}
func (m *module) estimateVolume(ctx context.Context, metricName string, matchType metricreductionruletypes.MatchType, labels []string, startMs, endMs int64) (current uint64, reduced uint64, reductionPercent float64, dropped []string, err error) {
keys, err := m.ch.AttributeKeys(ctx, metricName, startMs, endMs)
if err != nil {
return 0, 0, 0, nil, err
}
dropped, kept := resolveDroppedKept(matchType, labels, keys)
current, reduced, err = m.ch.EstimateCardinality(ctx, metricName, kept, startMs, endMs)
if err != nil {
return 0, 0, 0, nil, err
}
if current > 0 && reduced <= current {
reductionPercent = (1 - float64(reduced)/float64(current)) * 100
}
return current, reduced, reductionPercent, dropped, nil
}

View File

@@ -0,0 +1,145 @@
package implmetricreductionrule
import (
"context"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/metricreductionruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type store struct {
sqlstore sqlstore.SQLStore
}
func NewStore(sqlstore sqlstore.SQLStore) metricreductionruletypes.Store {
return &store{sqlstore: sqlstore}
}
func (s *store) List(ctx context.Context, orgID valuer.UUID, params *metricreductionruletypes.ListReductionRulesParams) ([]*metricreductionruletypes.ReductionRule, int, error) {
column := "metric_name"
if params.OrderBy == metricreductionruletypes.OrderByLastUpdated {
column = "updated_at"
}
direction := "ASC"
if params.Order == metricreductionruletypes.OrderDesc {
direction = "DESC"
}
rules := make([]*metricreductionruletypes.ReductionRule, 0)
query := s.sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(&rules).
Where("org_id = ?", orgID).
Order(column + " " + direction)
if params.Search != "" {
query = query.Where("metric_name LIKE ?", "%"+params.Search+"%")
}
if params.MetricName != "" {
query = query.Where("metric_name = ?", params.MetricName)
}
if params.Limit > 0 {
query = query.Limit(params.Limit).Offset(params.Offset)
}
total, err := query.ScanAndCount(ctx)
if err != nil {
return nil, 0, err
}
return rules, total, nil
}
func (s *store) Get(ctx context.Context, orgID valuer.UUID, metricName string) (*metricreductionruletypes.ReductionRule, error) {
rule := new(metricreductionruletypes.ReductionRule)
err := s.sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(rule).
Where("org_id = ?", orgID).
Where("metric_name = ?", metricName).
Scan(ctx)
if err != nil {
return nil, s.sqlstore.WrapNotFoundErrf(err, metricreductionruletypes.ErrCodeMetricReductionRuleNotFound, "no reduction rule found for metric %q", metricName)
}
return rule, nil
}
func (s *store) GetByID(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*metricreductionruletypes.ReductionRule, error) {
rule := new(metricreductionruletypes.ReductionRule)
err := s.sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(rule).
Where("org_id = ?", orgID).
Where("id = ?", id).
Scan(ctx)
if err != nil {
return nil, s.sqlstore.WrapNotFoundErrf(err, metricreductionruletypes.ErrCodeMetricReductionRuleNotFound, "no reduction rule found with id %q", id.String())
}
return rule, nil
}
func (s *store) Create(ctx context.Context, rule *metricreductionruletypes.ReductionRule) error {
res, err := s.sqlstore.
BunDBCtx(ctx).
NewInsert().
Model(rule).
On("CONFLICT (org_id, metric_name) DO NOTHING").
Exec(ctx)
if err != nil {
return err
}
rowsAffected, err := res.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return errors.Newf(errors.TypeAlreadyExists, metricreductionruletypes.ErrCodeMetricReductionRuleAlreadyExists,
"a reduction rule for metric %q already exists", rule.MetricName)
}
return nil
}
func (s *store) Upsert(ctx context.Context, rule *metricreductionruletypes.ReductionRule) error {
_, err := s.sqlstore.
BunDBCtx(ctx).
NewInsert().
Model(rule).
On("CONFLICT (org_id, metric_name) DO UPDATE").
Set("match_type = EXCLUDED.match_type").
Set("labels = EXCLUDED.labels").
Set("effective_from = EXCLUDED.effective_from").
Set("updated_at = EXCLUDED.updated_at").
Set("updated_by = EXCLUDED.updated_by").
Exec(ctx)
return err
}
func (s *store) DeleteByID(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
res, err := s.sqlstore.
BunDBCtx(ctx).
NewDelete().
Model((*metricreductionruletypes.ReductionRule)(nil)).
Where("org_id = ?", orgID).
Where("id = ?", id).
Exec(ctx)
if err != nil {
return err
}
rowsAffected, err := res.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return errors.Newf(errors.TypeNotFound, metricreductionruletypes.ErrCodeMetricReductionRuleNotFound, "no reduction rule found with id %q", id.String())
}
return nil
}
func (s *store) RunInTx(ctx context.Context, cb func(ctx context.Context) error) error {
return s.sqlstore.RunInTxCtx(ctx, nil, cb)
}

View File

@@ -107,6 +107,15 @@ func (ah *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) {
Route: "",
})
metricsReduction := ah.Signoz.Flagger.BooleanOrEmpty(ctx, flagger.FeatureEnableMetricsReduction, evalCtx)
featureSet = append(featureSet, &licensetypes.Feature{
Name: valuer.NewString(flagger.FeatureEnableMetricsReduction.String()),
Active: metricsReduction,
Usage: 0,
UsageLimit: -1,
Route: "",
})
if constants.IsDotMetricsEnabled {
for idx, feature := range featureSet {
if feature.Name == licensetypes.DotMetricsEnabled {

View File

@@ -90,8 +90,12 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
// initiate agent config handler
agentConfMgr, err := agentConf.Initiate(&agentConf.ManagerOptions{
Store: signoz.SQLStore,
AgentFeatures: []agentConf.AgentFeature{logParsingPipelineController},
Store: signoz.SQLStore,
AgentFeatures: []agentConf.AgentFeature{
logParsingPipelineController,
signoz.Modules.SpanMapper,
signoz.Modules.LLMPricingRule,
},
})
if err != nil {
return nil, err

View File

@@ -152,3 +152,7 @@ func (f *formatter) LowerExpression(expression string) []byte {
sql = append(sql, ')')
return sql
}
func (f *formatter) EscapeLikePattern(value string) string {
return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(value)
}

View File

@@ -25,7 +25,7 @@
"test": "jest",
"test:changedsince": "jest --changedSince=main --coverage --silent",
"generate:api": "orval --config ./orval.config.ts && sh scripts/post-types-generation.sh",
"generate:config:web-settings": "json2ts ../docs/config/web-settings.json -o src/types/generated/webSettings.ts --style.useTabs --style.tabWidth=1 --style.singleQuote --bannerComment '/* AUTO GENERATED FILE - DO NOT EDIT - GENERATED FROM docs/config/web-settings.json */'"
"generate:config:web-settings": "json2ts ./src/schemas/generated/webSettings.schema.json -o src/types/generated/webSettings.ts --style.useTabs --style.tabWidth=1 --style.singleQuote --bannerComment '/* AUTO GENERATED FILE - DO NOT EDIT - GENERATED FROM frontend/src/schemas/generated/webSettings.schema.json */'"
},
"engines": {
"node": ">=22.0.0",

View File

@@ -15,6 +15,8 @@
"logs_to_metrics": "Logs To Metrics",
"roles": "Roles",
"role_details": "Role Details",
"role_edit": "Edit Role",
"role_create": "Create Role",
"members": "Members",
"service_accounts": "Service Accounts",
"mcp_server": "MCP Server"

View File

@@ -82,6 +82,8 @@
"TRACE_DETAIL_OLD": "SigNoz | Trace Detail",
"SERVICE_TOP_LEVEL_OPERATIONS": "SigNoz | Service Operations",
"ROLE_DETAILS": "SigNoz | Role Details",
"ROLE_CREATE": "SigNoz | Create Role",
"ROLE_EDIT": "SigNoz | Edit Role",
"TRACES_FUNNELS_DETAIL": "SigNoz | Funnel",
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
"PUBLIC_DASHBOARD": "SigNoz | Dashboard"

View File

@@ -1,13 +0,0 @@
#!/usr/bin/env bash
# Extracts unique fenced code block language identifiers from all .md files under frontend/src/
# Usage: bash frontend/scripts/extract-md-languages.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SRC_DIR="$SCRIPT_DIR/../src"
grep -roh '```[a-zA-Z0-9_+-]*' "$SRC_DIR" --include='*.md' \
| sed 's/^```//' \
| grep -v '^$' \
| sort -u

View File

@@ -1,41 +0,0 @@
#!/usr/bin/env bash
# Validates that all fenced code block languages used in .md files are registered
# in the syntax highlighter.
# Usage: bash frontend/scripts/validate-md-languages.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SYNTAX_HIGHLIGHTER="$SCRIPT_DIR/../src/components/MarkdownRenderer/syntaxHighlighter.ts"
# Get all languages used in .md files
md_languages=$("$SCRIPT_DIR/extract-md-languages.sh")
# Get all registered languages from syntaxHighlighter.ts
registered_languages=$(grep -oP "registerLanguage\('\K[^']+" "$SYNTAX_HIGHLIGHTER" | sort -u)
missing_languages=()
for lang in $md_languages; do
# Skip ai-* block markers — these are custom AI block types rendered by
# RichCodeBlock as React components (e.g. ActionBlock, LineChartBlock),
# not real syntax languages, so they don't need highlighter registration.
if [[ "$lang" == ai-* ]]; then
continue
fi
if ! echo "$registered_languages" | grep -qx "$lang"; then
missing_languages+=("$lang")
fi
done
if [ ${#missing_languages[@]} -gt 0 ]; then
echo "Error: The following languages are used in .md files but not registered in syntaxHighlighter.ts:"
for lang in "${missing_languages[@]}"; do
echo " - $lang"
done
echo ""
echo "Please add them to: frontend/src/components/MarkdownRenderer/syntaxHighlighter.ts"
exit 1
fi
echo "All markdown code block languages are registered in syntaxHighlighter.ts"

View File

@@ -3,12 +3,12 @@ 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 { FeatureKeys } from 'constants/features';
import { LOCALSTORAGE } from 'constants/localStorage';
import { ORG_PREFERENCES } from 'constants/orgPreferences';
import ROUTES from 'constants/routes';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { useIsAIObservabilityEnabled } from 'hooks/useIsAIObservabilityEnabled';
import { isEmpty } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
import { LicensePlatform, LicenseState } from 'types/api/licensesV3/getActive';
@@ -37,11 +37,11 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
activeLicense,
isFetchingActiveLicense,
trialInfo,
featureFlags,
} = useAppContext();
const isAdmin = user.role === USER_ROLES.ADMIN;
const isAIAssistantEnabled = useIsAIAssistantEnabled();
const isAIObservabilityEnabled = useIsAIObservabilityEnabled();
const mapRoutes = useMemo(
() =>
@@ -133,6 +133,14 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
return <Redirect to={ROUTES.HOME} />;
}
if (
(pathname.startsWith(`${ROUTES.LLM_OBSERVABILITY_BASE}/`) ||
pathname === ROUTES.LLM_OBSERVABILITY_BASE) &&
!isAIObservabilityEnabled
) {
return <Redirect to={ROUTES.HOME} />;
}
// Check for workspace access restriction (cloud only)
const isCloudPlatform = activeLicense?.platform === LicensePlatform.CLOUD;
@@ -212,14 +220,6 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
}
}
// Check for GET_STARTED → GET_STARTED_WITH_CLOUD redirect (feature flag)
if (
currentRoute?.path === ROUTES.GET_STARTED &&
featureFlags?.find((e) => e.name === FeatureKeys.ONBOARDING_V3)?.active
) {
return <Redirect to={ROUTES.GET_STARTED_WITH_CLOUD} />;
}
// Main routing logic
if (currentRoute) {
const { isPrivate, key } = currentRoute;

View File

@@ -2,7 +2,6 @@ import { ReactElement } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { MemoryRouter, Route, Switch, useLocation } from 'react-router-dom';
import { act, render, screen, waitFor } from '@testing-library/react';
import { FeatureKeys } from 'constants/features';
import { LOCALSTORAGE } from 'constants/localStorage';
import { ORG_PREFERENCES } from 'constants/orgPreferences';
import ROUTES from 'constants/routes';
@@ -1263,80 +1262,6 @@ describe('PrivateRoute', () => {
});
});
describe('Get Started Route Redirect', () => {
it('should redirect to GET_STARTED_WITH_CLOUD when on GET_STARTED and ONBOARDING_V3 feature flag is active', async () => {
renderPrivateRoute({
initialRoute: ROUTES.GET_STARTED,
appContext: {
isLoggedIn: true,
featureFlags: [
{
name: FeatureKeys.ONBOARDING_V3,
active: true,
usage: 0,
usage_limit: -1,
route: '',
},
],
},
});
await assertRedirectsTo(ROUTES.GET_STARTED_WITH_CLOUD);
});
it('should not redirect when on GET_STARTED and ONBOARDING_V3 feature flag is inactive', () => {
renderPrivateRoute({
initialRoute: ROUTES.GET_STARTED,
appContext: {
isLoggedIn: true,
featureFlags: [
{
name: FeatureKeys.ONBOARDING_V3,
active: false,
usage: 0,
usage_limit: -1,
route: '',
},
],
},
});
assertStaysOnRoute(ROUTES.GET_STARTED);
});
it('should not redirect when on GET_STARTED and ONBOARDING_V3 feature flag is not present', () => {
renderPrivateRoute({
initialRoute: ROUTES.GET_STARTED,
appContext: {
isLoggedIn: true,
featureFlags: [],
},
});
assertStaysOnRoute(ROUTES.GET_STARTED);
});
it('should not redirect when on different route even if ONBOARDING_V3 is active', () => {
renderPrivateRoute({
initialRoute: ROUTES.HOME,
appContext: {
isLoggedIn: true,
featureFlags: [
{
name: FeatureKeys.ONBOARDING_V3,
active: true,
usage: 0,
usage_limit: -1,
route: '',
},
],
},
});
assertStaysOnRoute(ROUTES.HOME);
});
});
describe('Loading States', () => {
it('should not redirect while license is still being fetched', () => {
renderPrivateRoute({
@@ -1496,16 +1421,16 @@ describe('PrivateRoute', () => {
await assertRedirectsTo(ROUTES.UN_AUTHORIZED);
});
it('should allow EDITOR to access /get-started route', () => {
it('should allow EDITOR to access /get-started-with-signoz-cloud route', () => {
renderPrivateRoute({
initialRoute: ROUTES.GET_STARTED,
initialRoute: ROUTES.GET_STARTED_WITH_CLOUD,
appContext: {
isLoggedIn: true,
user: createMockUser({ role: USER_ROLES.EDITOR as ROLES }),
},
});
assertStaysOnRoute(ROUTES.GET_STARTED);
assertStaysOnRoute(ROUTES.GET_STARTED_WITH_CLOUD);
});
});

View File

@@ -57,13 +57,6 @@ export const TraceFilter = Loadable(
() => import(/* webpackChunkName: "Trace Filter Page" */ 'pages/Trace'),
);
export const TraceDetail = Loadable(
() =>
import(
/* webpackChunkName: "TraceDetail Page" */ 'pages/TraceDetailV2/index'
),
);
export const TraceDetailOldRedirect = Loadable(
() =>
import(
@@ -90,14 +83,6 @@ export const SettingsPage = Loadable(
() => import(/* webpackChunkName: "SettingsPage" */ 'pages/Settings'),
);
export const GettingStarted = Loadable(
() => import(/* webpackChunkName: "GettingStarted" */ 'pages/GettingStarted'),
);
export const Onboarding = Loadable(
() => import(/* webpackChunkName: "Onboarding" */ 'pages/OnboardingPage'),
);
export const OrgOnboarding = Loadable(
() => import(/* webpackChunkName: "OrgOnboarding" */ 'pages/OrgOnboarding'),
);
@@ -337,3 +322,17 @@ export const AIAssistantPage = Loadable(
/* webpackChunkName: "AI Assistant Page" */ 'pages/AIAssistantPage/AIAssistantPage'
),
);
export const LLMObservabilityPage = Loadable(
() =>
import(
/* webpackChunkName: "LLM Observability Page" */ 'pages/LLMObservability'
),
);
export const LLMObservabilityModelPricingPage = Loadable(
() =>
import(
/* webpackChunkName: "LLM Observability Model Pricing Page" */ 'pages/LLMObservabilityModelPricing'
),
);

View File

@@ -23,6 +23,8 @@ import {
IntegrationsDetailsPage,
LicensePage,
ListAllALertsPage,
LLMObservabilityPage,
LLMObservabilityModelPricingPage,
LiveLogs,
Login,
Logs,
@@ -33,7 +35,6 @@ import {
MeterExplorerPage,
MetricsExplorer,
OldLogsExplorer,
Onboarding,
OnboardingV2,
OrgOnboarding,
PasswordReset,
@@ -70,13 +71,6 @@ const routes: AppRoutes[] = [
isPrivate: false,
key: 'SIGN_UP',
},
{
path: ROUTES.GET_STARTED,
exact: false,
component: Onboarding,
isPrivate: true,
key: 'GET_STARTED',
},
{
path: ROUTES.GET_STARTED_WITH_CLOUD,
exact: false,
@@ -477,6 +471,13 @@ const routes: AppRoutes[] = [
key: 'METRICS_EXPLORER_VIEWS',
isPrivate: true,
},
{
path: ROUTES.METRICS_EXPLORER_VOLUME_CONTROL,
exact: true,
component: MetricsExplorer,
key: 'METRICS_EXPLORER_VOLUME_CONTROL',
isPrivate: true,
},
{
path: ROUTES.METER,
@@ -513,6 +514,20 @@ const routes: AppRoutes[] = [
key: 'AI_ASSISTANT',
isPrivate: true,
},
{
path: ROUTES.LLM_OBSERVABILITY_BASE,
exact: true,
component: LLMObservabilityPage,
key: 'LLM_OBSERVABILITY_BASE',
isPrivate: true,
},
{
path: ROUTES.LLM_OBSERVABILITY_MODEL_PRICING,
exact: true,
component: LLMObservabilityModelPricingPage,
key: 'LLM_OBSERVABILITY_MODEL_PRICING',
isPrivate: true,
},
];
export const SUPPORT_ROUTE: AppRoutes = {

View File

@@ -4,14 +4,22 @@
* * regenerate with 'pnpm generate:api'
* SigNoz
*/
import { useMutation } from 'react-query';
import { useMutation, useQuery } from 'react-query';
import type {
InvalidateOptions,
MutationFunction,
QueryClient,
QueryFunction,
QueryKey,
UseMutationOptions,
UseMutationResult,
UseQueryOptions,
UseQueryResult,
} from 'react-query';
import type {
GetChecks200,
GetChecksParams,
InframonitoringtypesPostableClustersDTO,
InframonitoringtypesPostableDaemonSetsDTO,
InframonitoringtypesPostableDeploymentsDTO,
@@ -39,7 +47,94 @@ import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
/**
* Returns a paginated list of Kubernetes clusters with key aggregated metrics derived by summing per-node values within the group: CPU usage, CPU allocatable, memory working set, memory allocatable. Each row also reports per-group nodeCountsByReadiness ({ ready, notReady } from each node's latest k8s.node.condition_ready value) and per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value). Each cluster includes metadata attributes (k8s.cluster.name). The response type is 'list' for the default k8s.cluster.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates nodes and pods in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_allocatable / memory / memory_allocatable, and pagination via offset/limit. Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (clusterCPU, clusterCPUAllocatable, clusterMemory, clusterMemoryAllocatable) return -1 as a sentinel when no data is available for that field.
* Checks whether the metrics and attributes required to power the infra-monitoring section selected by the 'type' query parameter (hosts, processes, pods, nodes, deployments, daemonsets, statefulsets, jobs, namespaces, clusters, volumes) are being received. For each collector receiver or processor that contributes required metrics or attributes, lists what is present and what is missing, with a prebuilt user-facing message and a docs link per missing component. Default-enabled metrics are those expected as soon as the receiver is configured; optional metrics require 'enabled: true' in receiver config. 'ready' is true only when every missing list is empty.
* @summary Run Infra Monitoring Setup Checks
*/
export const getChecks = (params: GetChecksParams, signal?: AbortSignal) => {
return GeneratedAPIInstance<GetChecks200>({
url: `/api/v2/infra_monitoring/checks`,
method: 'GET',
params,
signal,
});
};
export const getGetChecksQueryKey = (params?: GetChecksParams) => {
return [
`/api/v2/infra_monitoring/checks`,
...(params ? [params] : []),
] as const;
};
export const getGetChecksQueryOptions = <
TData = Awaited<ReturnType<typeof getChecks>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params: GetChecksParams,
options?: {
query?: UseQueryOptions<Awaited<ReturnType<typeof getChecks>>, TError, TData>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetChecksQueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof getChecks>>> = ({
signal,
}) => getChecks(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getChecks>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetChecksQueryResult = NonNullable<
Awaited<ReturnType<typeof getChecks>>
>;
export type GetChecksQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Run Infra Monitoring Setup Checks
*/
export function useGetChecks<
TData = Awaited<ReturnType<typeof getChecks>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params: GetChecksParams,
options?: {
query?: UseQueryOptions<Awaited<ReturnType<typeof getChecks>>, TError, TData>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetChecksQueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Run Infra Monitoring Setup Checks
*/
export const invalidateGetChecks = async (
queryClient: QueryClient,
params: GetChecksParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetChecksQueryKey(params) },
options,
);
return queryClient;
};
/**
* Returns a paginated list of Kubernetes clusters with key aggregated metrics derived by summing per-node values within the group: CPU usage, CPU allocatable, memory working set, memory allocatable. Each row also reports per-group nodeCountsByReadiness ({ ready, notReady } from each node's latest k8s.node.condition_ready value) and per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value). Each cluster includes metadata attributes (k8s.cluster.name). The response type is 'list' for the default k8s.cluster.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates nodes and pods in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_allocatable / memory / memory_allocatable, and pagination via offset/limit. Also reports whether the requested time range falls before the data retention boundary. Numeric metric fields (clusterCPU, clusterCPUAllocatable, clusterMemory, clusterMemoryAllocatable) return -1 as a sentinel when no data is available for that field.
* @summary List Clusters for Infra Monitoring
*/
export const listClusters = (
@@ -122,7 +217,7 @@ export const useListClusters = <
return useMutation(getListClustersMutationOptions(options));
};
/**
* Returns a paginated list of Kubernetes DaemonSets with key aggregated pod metrics: CPU usage and memory working set summed across pods owned by the daemonset, plus average CPU/memory request and limit utilization (daemonSetCPURequest, daemonSetCPULimit, daemonSetMemoryRequest, daemonSetMemoryLimit). Each row also reports the latest known node-level counters from kube-state-metrics: desiredNodes (k8s.daemonset.desired_scheduled_nodes, the number of nodes the daemonset wants to run on) and currentNodes (k8s.daemonset.current_scheduled_nodes, the number of nodes the daemonset currently runs on) — note these are node counts, not pod counts. It also reports per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value). Each daemonset includes metadata attributes (k8s.daemonset.name, k8s.namespace.name, k8s.cluster.name). The response type is 'list' for the default k8s.daemonset.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates pods owned by daemonsets in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_request / cpu_limit / memory / memory_request / memory_limit / desired_nodes / current_nodes, and pagination via offset/limit. Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (daemonSetCPU, daemonSetCPURequest, daemonSetCPULimit, daemonSetMemory, daemonSetMemoryRequest, daemonSetMemoryLimit, desiredNodes, currentNodes) return -1 as a sentinel when no data is available for that field.
* Returns a paginated list of Kubernetes DaemonSets with key aggregated pod metrics: CPU usage and memory working set summed across pods owned by the daemonset, plus average CPU/memory request and limit utilization (daemonSetCPURequest, daemonSetCPULimit, daemonSetMemoryRequest, daemonSetMemoryLimit). Each row also reports the latest known node-level counters from kube-state-metrics: desiredNodes (k8s.daemonset.desired_scheduled_nodes, the number of nodes the daemonset wants to run on) and currentNodes (k8s.daemonset.current_scheduled_nodes, the number of nodes the daemonset currently runs on) — note these are node counts, not pod counts. It also reports per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value). Each daemonset includes metadata attributes (k8s.daemonset.name, k8s.namespace.name, k8s.cluster.name). The response type is 'list' for the default k8s.daemonset.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates pods owned by daemonsets in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_request / cpu_limit / memory / memory_request / memory_limit / desired_nodes / current_nodes, and pagination via offset/limit. Also reports whether the requested time range falls before the data retention boundary. Numeric metric fields (daemonSetCPU, daemonSetCPURequest, daemonSetCPULimit, daemonSetMemory, daemonSetMemoryRequest, daemonSetMemoryLimit, desiredNodes, currentNodes) return -1 as a sentinel when no data is available for that field.
* @summary List DaemonSets for Infra Monitoring
*/
export const listDaemonSets = (
@@ -205,7 +300,7 @@ export const useListDaemonSets = <
return useMutation(getListDaemonSetsMutationOptions(options));
};
/**
* Returns a paginated list of Kubernetes Deployments with key aggregated pod metrics: CPU usage and memory working set summed across pods owned by the deployment, plus average CPU/memory request and limit utilization (deploymentCPURequest, deploymentCPULimit, deploymentMemoryRequest, deploymentMemoryLimit). Each row also reports the latest known desiredPods (k8s.deployment.desired) and availablePods (k8s.deployment.available) replica counts and per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value). Each deployment includes metadata attributes (k8s.deployment.name, k8s.namespace.name, k8s.cluster.name). The response type is 'list' for the default k8s.deployment.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates pods owned by deployments in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_request / cpu_limit / memory / memory_request / memory_limit / desired_pods / available_pods, and pagination via offset/limit. Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (deploymentCPU, deploymentCPURequest, deploymentCPULimit, deploymentMemory, deploymentMemoryRequest, deploymentMemoryLimit, desiredPods, availablePods) return -1 as a sentinel when no data is available for that field.
* Returns a paginated list of Kubernetes Deployments with key aggregated pod metrics: CPU usage and memory working set summed across pods owned by the deployment, plus average CPU/memory request and limit utilization (deploymentCPURequest, deploymentCPULimit, deploymentMemoryRequest, deploymentMemoryLimit). Each row also reports the latest known desiredPods (k8s.deployment.desired) and availablePods (k8s.deployment.available) replica counts and per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value). Each deployment includes metadata attributes (k8s.deployment.name, k8s.namespace.name, k8s.cluster.name). The response type is 'list' for the default k8s.deployment.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates pods owned by deployments in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_request / cpu_limit / memory / memory_request / memory_limit / desired_pods / available_pods, and pagination via offset/limit. Also reports whether the requested time range falls before the data retention boundary. Numeric metric fields (deploymentCPU, deploymentCPURequest, deploymentCPULimit, deploymentMemory, deploymentMemoryRequest, deploymentMemoryLimit, desiredPods, availablePods) return -1 as a sentinel when no data is available for that field.
* @summary List Deployments for Infra Monitoring
*/
export const listDeployments = (
@@ -288,7 +383,7 @@ export const useListDeployments = <
return useMutation(getListDeploymentsMutationOptions(options));
};
/**
* Returns a paginated list of hosts with key infrastructure metrics: CPU usage (%), memory usage (%), I/O wait (%), disk usage (%), and 15-minute load average. Each host includes its current status (active/inactive based on metrics reported in the last 10 minutes) and metadata attributes (e.g., os.type). Supports filtering via a filter expression, filtering by host status, custom groupBy to aggregate hosts by any attribute, ordering by any of the five metrics, and pagination via offset/limit. The response type is 'list' for the default host.name grouping or 'grouped_list' for custom groupBy keys. Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (cpu, memory, wait, load15, diskUsage) return -1 as a sentinel when no data is available for that field.
* Returns a paginated list of hosts with key infrastructure metrics: CPU usage (%), memory usage (%), I/O wait (%), disk usage (%), and 15-minute load average. Each host includes its current status (active/inactive based on metrics reported in the last 10 minutes) and metadata attributes (e.g., os.type). Supports filtering via a filter expression, filtering by host status, custom groupBy to aggregate hosts by any attribute, ordering by any of the five metrics, and pagination via offset/limit. The response type is 'list' for the default host.name grouping or 'grouped_list' for custom groupBy keys. Also reports whether the requested time range falls before the data retention boundary. Numeric metric fields (cpu, memory, wait, load15, diskUsage) return -1 as a sentinel when no data is available for that field.
* @summary List Hosts for Infra Monitoring
*/
export const listHosts = (
@@ -371,7 +466,7 @@ export const useListHosts = <
return useMutation(getListHostsMutationOptions(options));
};
/**
* Returns a paginated list of Kubernetes Jobs with key aggregated pod metrics: CPU usage and memory working set summed across pods owned by the job, plus average CPU/memory request and limit utilization (jobCPURequest, jobCPULimit, jobMemoryRequest, jobMemoryLimit). Each row also reports the latest known job-level counters from kube-state-metrics: desiredSuccessfulPods (k8s.job.desired_successful_pods, the target completion count), activePods (k8s.job.active_pods), failedPods (k8s.job.failed_pods, cumulative across the lifetime of the job), and successfulPods (k8s.job.successful_pods, cumulative). It also reports per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value); note podCountsByPhase.failed (current pod-phase) is distinct from failedPods (cumulative job kube-state-metric). Each job includes metadata attributes (k8s.job.name, k8s.namespace.name, k8s.cluster.name). The response type is 'list' for the default k8s.job.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates pods owned by jobs in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_request / cpu_limit / memory / memory_request / memory_limit / desired_successful_pods / active_pods / failed_pods / successful_pods, and pagination via offset/limit. Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (jobCPU, jobCPURequest, jobCPULimit, jobMemory, jobMemoryRequest, jobMemoryLimit, desiredSuccessfulPods, activePods, failedPods, successfulPods) return -1 as a sentinel when no data is available for that field.
* Returns a paginated list of Kubernetes Jobs with key aggregated pod metrics: CPU usage and memory working set summed across pods owned by the job, plus average CPU/memory request and limit utilization (jobCPURequest, jobCPULimit, jobMemoryRequest, jobMemoryLimit). Each row also reports the latest known job-level counters from kube-state-metrics: desiredSuccessfulPods (k8s.job.desired_successful_pods, the target completion count), activePods (k8s.job.active_pods), failedPods (k8s.job.failed_pods, cumulative across the lifetime of the job), and successfulPods (k8s.job.successful_pods, cumulative). It also reports per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value); note podCountsByPhase.failed (current pod-phase) is distinct from failedPods (cumulative job kube-state-metric). Each job includes metadata attributes (k8s.job.name, k8s.namespace.name, k8s.cluster.name). The response type is 'list' for the default k8s.job.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates pods owned by jobs in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_request / cpu_limit / memory / memory_request / memory_limit / desired_successful_pods / active_pods / failed_pods / successful_pods, and pagination via offset/limit. Also reports whether the requested time range falls before the data retention boundary. Numeric metric fields (jobCPU, jobCPURequest, jobCPULimit, jobMemory, jobMemoryRequest, jobMemoryLimit, desiredSuccessfulPods, activePods, failedPods, successfulPods) return -1 as a sentinel when no data is available for that field.
* @summary List Jobs for Infra Monitoring
*/
export const listJobs = (
@@ -454,7 +549,7 @@ export const useListJobs = <
return useMutation(getListJobsMutationOptions(options));
};
/**
* Returns a paginated list of Kubernetes namespaces with key aggregated pod metrics: CPU usage and memory working set (summed across pods in the group), plus per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value in the window). Each namespace includes metadata attributes (k8s.namespace.name, k8s.cluster.name). The response type is 'list' for the default k8s.namespace.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates pods in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / memory, and pagination via offset/limit. Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (namespaceCPU, namespaceMemory) return -1 as a sentinel when no data is available for that field.
* Returns a paginated list of Kubernetes namespaces with key aggregated pod metrics: CPU usage and memory working set (summed across pods in the group), plus per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value in the window). Each namespace includes metadata attributes (k8s.namespace.name, k8s.cluster.name). The response type is 'list' for the default k8s.namespace.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates pods in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / memory, and pagination via offset/limit. Also reports whether the requested time range falls before the data retention boundary. Numeric metric fields (namespaceCPU, namespaceMemory) return -1 as a sentinel when no data is available for that field.
* @summary List Namespaces for Infra Monitoring
*/
export const listNamespaces = (
@@ -537,7 +632,7 @@ export const useListNamespaces = <
return useMutation(getListNamespacesMutationOptions(options));
};
/**
* Returns a paginated list of Kubernetes nodes with key metrics: CPU usage, CPU allocatable, memory working set, memory allocatable, per-group nodeCountsByReadiness ({ ready, notReady } from each node's latest k8s.node.condition_ready in the window) and per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } for pods scheduled on the listed nodes). Each node includes metadata attributes (k8s.node.uid, k8s.cluster.name). The response type is 'list' for the default k8s.node.name grouping (each row is one node with its current condition string: ready / not_ready / no_data) or 'grouped_list' for custom groupBy keys (each row aggregates nodes in the group; condition stays no_data). Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_allocatable / memory / memory_allocatable, and pagination via offset/limit. Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (nodeCPU, nodeCPUAllocatable, nodeMemory, nodeMemoryAllocatable) return -1 as a sentinel when no data is available for that field.
* Returns a paginated list of Kubernetes nodes with key metrics: CPU usage, CPU allocatable, memory working set, memory allocatable, per-group nodeCountsByReadiness ({ ready, notReady } from each node's latest k8s.node.condition_ready in the window) and per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } for pods scheduled on the listed nodes). Each node includes metadata attributes (k8s.node.uid, k8s.cluster.name). The response type is 'list' for the default k8s.node.name grouping (each row is one node with its current condition string: ready / not_ready / no_data) or 'grouped_list' for custom groupBy keys (each row aggregates nodes in the group; condition stays no_data). Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_allocatable / memory / memory_allocatable, and pagination via offset/limit. Also reports whether the requested time range falls before the data retention boundary. Numeric metric fields (nodeCPU, nodeCPUAllocatable, nodeMemory, nodeMemoryAllocatable) return -1 as a sentinel when no data is available for that field.
* @summary List Nodes for Infra Monitoring
*/
export const listNodes = (
@@ -620,7 +715,7 @@ export const useListNodes = <
return useMutation(getListNodesMutationOptions(options));
};
/**
* Returns a paginated list of Kubernetes pods with key metrics: CPU usage, CPU request/limit utilization, memory working set, memory request/limit utilization, current pod phase (pending/running/succeeded/failed/unknown/no_data), and pod age (ms since start time). Each pod includes metadata attributes (namespace, node, workload owner such as deployment/statefulset/daemonset/job/cronjob, cluster). Supports filtering via a filter expression, custom groupBy to aggregate pods by any attribute, ordering by any of the six metrics (cpu, cpu_request, cpu_limit, memory, memory_request, memory_limit), and pagination via offset/limit. The response type is 'list' for the default k8s.pod.uid grouping (each row is one pod with its current phase) or 'grouped_list' for custom groupBy keys (each row aggregates pods in the group with per-phase counts under podCountsByPhase: { pending, running, succeeded, failed, unknown } derived from each pod's latest phase in the window). Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (podCPU, podCPURequest, podCPULimit, podMemory, podMemoryRequest, podMemoryLimit, podAge) return -1 as a sentinel when no data is available for that field.
* Returns a paginated list of Kubernetes pods with key metrics: CPU usage, CPU request/limit utilization, memory working set, memory request/limit utilization, current pod phase (pending/running/succeeded/failed/unknown/no_data), and pod age (ms since start time). Each pod includes metadata attributes (namespace, node, workload owner such as deployment/statefulset/daemonset/job/cronjob, cluster). Supports filtering via a filter expression, custom groupBy to aggregate pods by any attribute, ordering by any of the six metrics (cpu, cpu_request, cpu_limit, memory, memory_request, memory_limit), and pagination via offset/limit. The response type is 'list' for the default k8s.pod.uid grouping (each row is one pod with its current phase) or 'grouped_list' for custom groupBy keys (each row aggregates pods in the group with per-phase counts under podCountsByPhase: { pending, running, succeeded, failed, unknown } derived from each pod's latest phase in the window). Also reports whether the requested time range falls before the data retention boundary. Numeric metric fields (podCPU, podCPURequest, podCPULimit, podMemory, podMemoryRequest, podMemoryLimit, podAge) return -1 as a sentinel when no data is available for that field.
* @summary List Pods for Infra Monitoring
*/
export const listPods = (
@@ -703,7 +798,7 @@ export const useListPods = <
return useMutation(getListPodsMutationOptions(options));
};
/**
* Returns a paginated list of Kubernetes persistent volume claims (PVCs) with key volume metrics: available bytes, capacity bytes, usage (capacity - available), inodes, free inodes, and used inodes. Each row also includes metadata attributes (k8s.persistentvolumeclaim.name, k8s.pod.uid, k8s.pod.name, k8s.namespace.name, k8s.node.name, k8s.statefulset.name, k8s.cluster.name). Supports filtering via a filter expression, custom groupBy to aggregate volumes by any attribute, ordering by any of the six metrics (available, capacity, usage, inodes, inodes_free, inodes_used), and pagination via offset/limit. The response type is 'list' for the default k8s.persistentvolumeclaim.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates volumes in the group. Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (volumeAvailable, volumeCapacity, volumeUsage, volumeInodes, volumeInodesFree, volumeInodesUsed) return -1 as a sentinel when no data is available for that field.
* Returns a paginated list of Kubernetes persistent volume claims (PVCs) with key volume metrics: available bytes, capacity bytes, usage (capacity - available), inodes, free inodes, and used inodes. Each row also includes metadata attributes (k8s.persistentvolumeclaim.name, k8s.pod.uid, k8s.pod.name, k8s.namespace.name, k8s.node.name, k8s.statefulset.name, k8s.cluster.name). Supports filtering via a filter expression, custom groupBy to aggregate volumes by any attribute, ordering by any of the six metrics (available, capacity, usage, inodes, inodes_free, inodes_used), and pagination via offset/limit. The response type is 'list' for the default k8s.persistentvolumeclaim.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates volumes in the group. Also reports whether the requested time range falls before the data retention boundary. Numeric metric fields (volumeAvailable, volumeCapacity, volumeUsage, volumeInodes, volumeInodesFree, volumeInodesUsed) return -1 as a sentinel when no data is available for that field.
* @summary List Volumes for Infra Monitoring
*/
export const listVolumes = (
@@ -786,7 +881,7 @@ export const useListVolumes = <
return useMutation(getListVolumesMutationOptions(options));
};
/**
* Returns a paginated list of Kubernetes StatefulSets with key aggregated pod metrics: CPU usage and memory working set summed across pods owned by the statefulset, plus average CPU/memory request and limit utilization (statefulSetCPURequest, statefulSetCPULimit, statefulSetMemoryRequest, statefulSetMemoryLimit). Each row also reports the latest known desiredPods (k8s.statefulset.desired_pods) and currentPods (k8s.statefulset.current_pods) replica counts and per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value). Each statefulset includes metadata attributes (k8s.statefulset.name, k8s.namespace.name, k8s.cluster.name). The response type is 'list' for the default k8s.statefulset.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates pods owned by statefulsets in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_request / cpu_limit / memory / memory_request / memory_limit / desired_pods / current_pods, and pagination via offset/limit. Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (statefulSetCPU, statefulSetCPURequest, statefulSetCPULimit, statefulSetMemory, statefulSetMemoryRequest, statefulSetMemoryLimit, desiredPods, currentPods) return -1 as a sentinel when no data is available for that field.
* Returns a paginated list of Kubernetes StatefulSets with key aggregated pod metrics: CPU usage and memory working set summed across pods owned by the statefulset, plus average CPU/memory request and limit utilization (statefulSetCPURequest, statefulSetCPULimit, statefulSetMemoryRequest, statefulSetMemoryLimit). Each row also reports the latest known desiredPods (k8s.statefulset.desired_pods) and currentPods (k8s.statefulset.current_pods) replica counts and per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value). Each statefulset includes metadata attributes (k8s.statefulset.name, k8s.namespace.name, k8s.cluster.name). The response type is 'list' for the default k8s.statefulset.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates pods owned by statefulsets in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_request / cpu_limit / memory / memory_request / memory_limit / desired_pods / current_pods, and pagination via offset/limit. Also reports whether the requested time range falls before the data retention boundary. Numeric metric fields (statefulSetCPU, statefulSetCPURequest, statefulSetCPULimit, statefulSetMemory, statefulSetMemoryRequest, statefulSetMemoryLimit, desiredPods, currentPods) return -1 as a sentinel when no data is available for that field.
* @summary List StatefulSets for Infra Monitoring
*/
export const listStatefulSets = (

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,35 +0,0 @@
import { ApiV2Instance as axios } from 'api';
import { omit } from 'lodash-es';
import { ErrorResponse, SuccessResponse } from 'types/api';
import {
GetTraceV2PayloadProps,
GetTraceV2SuccessResponse,
} from 'types/api/trace/getTraceV2';
const getTraceV2 = async (
props: GetTraceV2PayloadProps,
): Promise<SuccessResponse<GetTraceV2SuccessResponse> | ErrorResponse> => {
let uncollapsedSpans = [...props.uncollapsedSpans];
if (!props.isSelectedSpanIDUnCollapsed) {
uncollapsedSpans = uncollapsedSpans.filter(
(node) => node !== props.selectedSpanId,
);
}
const postData: GetTraceV2PayloadProps = {
...props,
uncollapsedSpans,
};
const response = await axios.post<GetTraceV2SuccessResponse>(
`/traces/waterfall/${props.traceId}`,
omit(postData, 'traceId'),
);
return {
statusCode: 200,
error: null,
message: 'Success',
payload: response.data,
};
};
export default getTraceV2;

View File

@@ -41,6 +41,7 @@ const getTraceV4 = async (
> & { spans: WireSpan[] | null };
// Derive 'service.name' from resource for convenience — only derived field
// todo(tech-debt): to remove use of this and to directly use service.name from resources.
const spans: SpanV3[] = (rawPayload.spans || []).map((span) => ({
...span,
'service.name': span.resource?.['service.name'] || '',

View File

@@ -1,25 +0,0 @@
import { Color } from '@signozhq/design-tokens';
import { useIsDarkMode } from 'hooks/useDarkMode';
function FlamegraphImg(): JSX.Element {
const isDarkMode = useIsDarkMode();
return (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8 3c1 3 2.5 3.5 3.5 4.5A5 5 0 0113 11a5 5 0 11-10 0c0-.3 0-.6.1-.9a2 2 0 103.3-2C4 5.5 7 3 8 3zM21 4h-8M20 14.5h-3M20 9.5h-3M21 20H4"
stroke={isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export default FlamegraphImg;

View File

@@ -21,6 +21,8 @@ interface ErrorInPlaceProps {
width?: string | number;
/** Custom content instead of ErrorContent */
children?: ReactNode;
/** Test ID for testing */
'data-testid'?: string;
}
/**
@@ -44,6 +46,7 @@ function ErrorInPlace({
height = '100%',
width = '100%',
children,
'data-testid': dataTestId,
}: ErrorInPlaceProps): JSX.Element {
const containerStyle: React.CSSProperties = {
display: 'flex',
@@ -59,7 +62,11 @@ function ErrorInPlace({
};
return (
<div className={`error-in-place ${className}`.trim()} style={containerStyle}>
<div
className={`error-in-place ${className}`.trim()}
style={containerStyle}
data-testid={dataTestId}
>
{children || <ErrorContent error={error} />}
</div>
);

View File

@@ -62,13 +62,13 @@ function ErrorTitleAndKey({
switch (parentTitle) {
case 'Consumers':
link = `${ROUTES.GET_STARTED_APPLICATION_MONITORING}?${QueryParams.getStartedSource}=kafka&${QueryParams.getStartedSourceService}=${MessagingQueueHealthCheckService.Consumers}`;
link = `${ROUTES.GET_STARTED_WITH_CLOUD}?${QueryParams.getStartedSource}=self-hosted-kafka&${QueryParams.getStartedSourceService}=${MessagingQueueHealthCheckService.Consumers}`;
break;
case 'Producers':
link = `${ROUTES.GET_STARTED_APPLICATION_MONITORING}?${QueryParams.getStartedSource}=kafka&${QueryParams.getStartedSourceService}=${MessagingQueueHealthCheckService.Producers}`;
link = `${ROUTES.GET_STARTED_WITH_CLOUD}?${QueryParams.getStartedSource}=self-hosted-kafka&${QueryParams.getStartedSourceService}=${MessagingQueueHealthCheckService.Producers}`;
break;
case 'Kafka':
link = `${ROUTES.GET_STARTED_INFRASTRUCTURE_MONITORING}?${QueryParams.getStartedSource}=kafka&${QueryParams.getStartedSourceService}=${MessagingQueueHealthCheckService.Kafka}`;
link = `${ROUTES.GET_STARTED_WITH_CLOUD}?${QueryParams.getStartedSource}=self-hosted-kafka&${QueryParams.getStartedSourceService}=${MessagingQueueHealthCheckService.Kafka}`;
break;
default:
link = '';

View File

@@ -5,13 +5,9 @@ describe('PermissionDeniedFullPage', () => {
it('renders the title and subtitle with the permissionName interpolated', () => {
render(<PermissionDeniedFullPage permissionName="serviceaccount:list" />);
expect(
screen.getByText("Uh-oh! You don't have permission to view this page."),
).toBeInTheDocument();
expect(screen.getByText('Uh-oh! You are not authorized')).toBeInTheDocument();
expect(screen.getByText(/serviceaccount:list/)).toBeInTheDocument();
expect(
screen.getByText(/Please ask your SigNoz administrator to grant access/),
).toBeInTheDocument();
expect(screen.getByText(/is not authorized to perform/)).toBeInTheDocument();
});
it('renders with a different permissionName', () => {

View File

@@ -2,6 +2,7 @@ import { CircleSlash2 } from '@signozhq/icons';
import styles from './PermissionDeniedFullPage.module.scss';
import { Style } from '@signozhq/design-tokens';
import { useAppContext } from 'providers/App/App';
interface PermissionDeniedFullPageProps {
permissionName: string;
@@ -10,18 +11,18 @@ interface PermissionDeniedFullPageProps {
function PermissionDeniedFullPage({
permissionName,
}: PermissionDeniedFullPageProps): JSX.Element {
const { user } = useAppContext();
return (
<div className={styles.container}>
<div className={styles.content}>
<span className={styles.icon}>
<CircleSlash2 color={Style.CALLOUT_WARNING_TITLE} size={14} />
</span>
<p className={styles.title}>
Uh-oh! You don&apos;t have permission to view this page.
</p>
<p className={styles.title}>Uh-oh! You are not authorized</p>
<p className={styles.subtitle}>
You need <code className={styles.permission}>{permissionName}</code> to
view this page. Please ask your SigNoz administrator to grant access.
<code className={styles.permission}>user/{user.id}</code> is not authorized
to perform <code className={styles.permission}>{permissionName}</code>
</p>
</div>
</div>

View File

@@ -32,10 +32,13 @@ export function useRoles(): {
};
}
export function getRoleOptions(roles: AuthtypesRoleDTO[]): RoleOption[] {
export function getRoleOptions(
roles: AuthtypesRoleDTO[],
valueField: 'id' | 'name' = 'id',
): RoleOption[] {
return roles.map((role) => ({
label: role.name ?? '',
value: role.id ?? '',
value: role[valueField] ?? '',
}));
}
@@ -82,6 +85,7 @@ interface BaseProps {
error?: APIError;
onRefetch?: () => void;
disabled?: boolean;
valueField?: 'id' | 'name';
}
interface SingleProps extends BaseProps {
@@ -113,7 +117,7 @@ function RolesSelect(props: RolesSelectProps): JSX.Element {
});
const roles = externalRoles ?? data?.data ?? [];
const options = getRoleOptions(roles);
const options = getRoleOptions(roles, props.valueField);
const {
mode,

View File

@@ -1,7 +1,9 @@
import { useCallback } from 'react';
import { LockKeyhole } from '@signozhq/icons';
import { useCallback, useEffect, useState } from 'react';
import { Check, Copy, LockKeyhole } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { useCopyToClipboard } from 'react-use';
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import RolesSelect from 'components/RolesSelect';
@@ -46,6 +48,23 @@ function OverviewTab({
saveErrors = [],
}: OverviewTabProps): JSX.Element {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const [, copyToClipboard] = useCopyToClipboard();
const [hasCopiedId, setHasCopiedId] = useState(false);
const handleCopyId = useCallback((): void => {
if (account.id) {
copyToClipboard(account.id);
setHasCopiedId(true);
}
}, [account.id, copyToClipboard]);
useEffect(() => {
if (hasCopiedId) {
const timer = setTimeout(() => setHasCopiedId(false), 2000);
return (): void => clearTimeout(timer);
}
return undefined;
}, [hasCopiedId]);
const formatTimestamp = useCallback(
(ts: string | null | undefined): string => {
@@ -93,6 +112,17 @@ function OverviewTab({
</label>
<div className="sa-drawer__input-wrapper sa-drawer__input-wrapper--disabled">
<span className="sa-drawer__input-text">{account.id || '—'}</span>
{account.id && (
<Button
variant="link"
color="secondary"
onClick={handleCopyId}
className="sa-drawer__copy-btn"
data-testid="copy-id-btn"
>
{hasCopiedId ? <Check size={14} /> : <Copy size={14} />}
</Button>
)}
<LockKeyhole size={14} className="sa-drawer__lock-icon" />
</div>
</div>

View File

@@ -203,6 +203,19 @@
opacity: 0.6;
}
&__copy-btn {
flex-shrink: 0;
padding: 0;
height: auto;
min-height: auto;
color: var(--foreground);
opacity: 0.6;
&:hover {
opacity: 1;
}
}
&__disabled-roles {
display: flex;
flex-wrap: wrap;

View File

@@ -16,7 +16,6 @@ import {
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import { GuardAuthZ } from 'components/GuardAuthZ/GuardAuthZ';
import PermissionDeniedCallout from 'components/PermissionDeniedCallout/PermissionDeniedCallout';
import { useRoles } from 'components/RolesSelect';
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
@@ -477,15 +476,9 @@ function ServiceAccountDrawer({
!isAccountLoading &&
!isAccountError &&
selectedAccountId && (
<GuardAuthZ
relation="read"
object={`serviceaccount:${selectedAccountId}`}
fallbackOnNoPermissions={(): JSX.Element => (
<PermissionDeniedCallout permissionName="serviceaccount:read" />
)}
>
<>
{activeTab === ServiceAccountDrawerTab.Overview && account && (
<>
{activeTab === ServiceAccountDrawerTab.Overview &&
(canRead && account ? (
<OverviewTab
account={account}
localName={localName}
@@ -504,23 +497,24 @@ function ServiceAccountDrawer({
onRefetchRoles={refetchRoles}
saveErrors={saveErrors}
/>
)}
{activeTab === ServiceAccountDrawerTab.Keys &&
(canListKeys ? (
<KeysTab
keys={keys}
isLoading={keysLoading}
isDisabled={isDeleted}
canUpdate={canUpdate}
accountId={selectedAccountId}
currentPage={keysPage}
pageSize={PAGE_SIZE}
/>
) : (
<PermissionDeniedCallout permissionName="factor-api-key:list" />
))}
</>
</GuardAuthZ>
) : (
<PermissionDeniedCallout permissionName="serviceaccount:read" />
))}
{activeTab === ServiceAccountDrawerTab.Keys &&
(canListKeys ? (
<KeysTab
keys={keys}
isLoading={keysLoading}
isDisabled={isDeleted}
canUpdate={canUpdate}
accountId={selectedAccountId}
currentPage={keysPage}
pageSize={PAGE_SIZE}
/>
) : (
<PermissionDeniedCallout permissionName="factor-api-key:list" />
))}
</>
)}
</div>
</div>

View File

@@ -1,106 +0,0 @@
.span-hover-card {
.ant-popover-inner {
background: linear-gradient(
139deg,
color-mix(in srgb, var(--card) 32%, transparent) 0%,
color-mix(in srgb, var(--card) 36%, transparent) 98.68%
);
padding: 12px 16px;
border: 1px solid var(--l1-border);
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
139deg,
color-mix(in srgb, var(--card) 32%, transparent) 0%,
color-mix(in srgb, var(--card) 36%, transparent) 98.68%
);
backdrop-filter: blur(20px);
border-radius: 4px;
z-index: -1;
will-change: background-color, backdrop-filter;
}
}
&__title {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-bottom: 0.5rem;
}
&__operation {
color: var(--l1-foreground);
font-size: 12px;
font-weight: 500;
line-height: 20px;
letter-spacing: 0.48px;
}
&__service {
font-size: 0.875rem;
color: var(--muted-foreground);
font-weight: 400;
}
&__error {
font-size: 0.75rem;
color: var(--danger-background);
font-weight: 500;
}
&__row {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 8px;
gap: 16px;
}
&__label {
color: var(--muted-foreground);
font-size: 12px;
font-weight: 500;
line-height: 20px;
}
&__value {
color: var(--l1-foreground);
font-size: 12px;
font-weight: 500;
line-height: 20px;
text-align: right;
}
&__relative-time {
display: flex;
align-items: center;
margin-top: 4px;
gap: 8px;
border-radius: 1px 0 0 1px;
background: linear-gradient(
90deg,
hsla(358, 75%, 59%, 0.2) 0%,
transparent 100%
);
&-icon {
width: 2px;
height: 20px;
flex-shrink: 0;
border-radius: 2px;
background: var(--danger-background);
}
}
&__relative-text {
color: var(--bg-cherry-300);
font-size: 12px;
line-height: 20px;
}
}

View File

@@ -1,103 +0,0 @@
import { ReactNode } from 'react';
import { Popover } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
import dayjs from 'dayjs';
import { useTimezone } from 'providers/Timezone';
import { Span } from 'types/api/trace/getTraceV2';
import { toFixed } from 'utils/toFixed';
import './SpanHoverCard.styles.scss';
interface ITraceMetadata {
startTime: number;
endTime: number;
}
interface SpanHoverCardProps {
span: Span;
traceMetadata: ITraceMetadata;
children: ReactNode;
}
function SpanHoverCard({
span,
traceMetadata,
children,
}: SpanHoverCardProps): JSX.Element {
const duration = span.durationNano / 1e6; // Convert nanoseconds to milliseconds
const { time: formattedDuration, timeUnitName } =
convertTimeToRelevantUnit(duration);
const { timezone } = useTimezone();
// Calculate relative start time from trace start
const relativeStartTime = span.timestamp - traceMetadata.startTime;
const { time: relativeTime, timeUnitName: relativeTimeUnit } =
convertTimeToRelevantUnit(relativeStartTime);
// Format absolute start time
const startTimeFormatted = dayjs(span.timestamp)
.tz(timezone.value)
.format(DATE_TIME_FORMATS.DD_MMM_YYYY_HH_MM_SS);
const getContent = (): JSX.Element => (
<div className="span-hover-card">
<div className="span-hover-card__row">
<Typography.Text className="span-hover-card__label">
Duration:
</Typography.Text>
<Typography.Text className="span-hover-card__value">
{toFixed(formattedDuration, 2)}
{timeUnitName}
</Typography.Text>
</div>
<div className="span-hover-card__row">
<Typography.Text className="span-hover-card__label">
Events:
</Typography.Text>
<Typography.Text className="span-hover-card__value">
{span.event?.length || 0}
</Typography.Text>
</div>
<div className="span-hover-card__row">
<Typography.Text className="span-hover-card__label">
Start time:
</Typography.Text>
<Typography.Text className="span-hover-card__value">
{startTimeFormatted}
</Typography.Text>
</div>
<div className="span-hover-card__relative-time">
<div className="span-hover-card__relative-time-icon" />
<Typography.Text className="span-hover-card__relative-text">
{toFixed(relativeTime, 2)}
{relativeTimeUnit} after trace start
</Typography.Text>
</div>
</div>
);
return (
<Popover
title={
<div className="span-hover-card__title">
<Typography.Text className="span-hover-card__operation">
{span.name}
</Typography.Text>
</div>
}
mouseEnterDelay={0.2}
content={getContent()}
trigger="hover"
rootClassName="span-hover-card"
autoAdjustOverflow
arrow={false}
>
{children}
</Popover>
);
}
export default SpanHoverCard;

View File

@@ -1,291 +0,0 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { TimezoneContextType } from 'providers/Timezone';
import { Span } from 'types/api/trace/getTraceV2';
import SpanHoverCard from '../SpanHoverCard';
// Mock timezone provider so SpanHoverCard can use useTimezone without a real context
jest.mock('providers/Timezone', () => ({
__esModule: true,
useTimezone: (): TimezoneContextType => ({
timezone: {
name: 'Coordinated Universal Time — UTC, GMT',
value: 'UTC',
offset: 'UTC',
searchIndex: 'UTC',
},
browserTimezone: {
name: 'Coordinated Universal Time — UTC, GMT',
value: 'UTC',
offset: 'UTC',
searchIndex: 'UTC',
},
updateTimezone: jest.fn(),
formatTimezoneAdjustedTimestamp: jest.fn(() => 'mock-date'),
isAdaptationEnabled: true,
setIsAdaptationEnabled: jest.fn(),
}),
}));
// Mock dayjs for testing, including timezone helpers used in timezoneUtils
jest.mock('dayjs', () => {
const mockDayjsInstance: any = {};
mockDayjsInstance.format = jest.fn((formatString: string) =>
// Match the DD_MMM_YYYY_HH_MM_SS format: 'DD MMM YYYY, HH:mm:ss'
formatString === 'DD MMM YYYY, HH:mm:ss'
? '15 Mar 2024, 14:23:45'
: 'mock-date',
);
// Support chaining: dayjs().tz(timezone).format(...) and dayjs().tz(timezone).utcOffset()
mockDayjsInstance.tz = jest.fn(() => mockDayjsInstance);
mockDayjsInstance.utcOffset = jest.fn(() => 0);
const mockDayjs = jest.fn(() => mockDayjsInstance);
Object.assign(mockDayjs, {
extend: jest.fn(),
// Support dayjs.tz.guess()
tz: { guess: jest.fn(() => 'UTC') },
});
return mockDayjs;
});
const HOVER_ELEMENT_ID = 'hover-element';
const mockSpan: Span = {
spanId: 'test-span-id',
traceId: 'test-trace-id',
rootSpanId: 'root-span-id',
parentSpanId: 'parent-span-id',
name: 'GET /api/users',
timestamp: 1679748225000000,
durationNano: 150000000,
serviceName: 'user-service',
kind: 1,
hasError: false,
level: 1,
references: [],
tagMap: {},
event: [
{
name: 'event1',
timeUnixNano: 1679748225100000,
attributeMap: {},
isError: false,
},
{
name: 'event2',
timeUnixNano: 1679748225200000,
attributeMap: {},
isError: false,
},
],
rootName: 'root-span',
statusMessage: '',
statusCodeString: 'OK',
spanKind: 'server',
hasChildren: false,
hasSibling: false,
subTreeNodeCount: 1,
};
const mockTraceMetadata = {
startTime: 1679748225000000,
endTime: 1679748226000000,
};
describe('SpanHoverCard', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('renders child element correctly', () => {
render(
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
<div data-testid="child-element">Hover me</div>
</SpanHoverCard>,
);
expect(screen.getByTestId('child-element')).toBeInTheDocument();
expect(screen.getByText('Hover me')).toBeInTheDocument();
});
it('shows popover after 0.2 second delay on hover', async () => {
render(
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
<div data-testid={HOVER_ELEMENT_ID}>Hover for details</div>
</SpanHoverCard>,
);
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
// Hover over the element
fireEvent.mouseEnter(hoverElement);
// Popover should NOT appear immediately
expect(screen.queryByText('Duration:')).not.toBeInTheDocument();
// Advance time by 0.5 seconds
act(() => {
jest.advanceTimersByTime(200);
});
// Now popover should appear
expect(screen.getByText('Duration:')).toBeInTheDocument();
});
it('does not show popover if hover is too brief', async () => {
render(
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
<div data-testid={HOVER_ELEMENT_ID}>Quick hover test</div>
</SpanHoverCard>,
);
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
// Quick hover and unhover (less than the 0.2s delay)
fireEvent.mouseEnter(hoverElement);
act(() => {
jest.advanceTimersByTime(100); // Only 0.1 seconds
});
fireEvent.mouseLeave(hoverElement);
// Advance past the full delay
act(() => {
jest.advanceTimersByTime(400);
});
// Popover should not appear
expect(screen.queryByText('Duration:')).not.toBeInTheDocument();
});
it('displays span information in popover content after delay', async () => {
render(
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
<div data-testid={HOVER_ELEMENT_ID}>Test span</div>
</SpanHoverCard>,
);
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
// Hover and wait for popover
fireEvent.mouseEnter(hoverElement);
act(() => {
jest.advanceTimersByTime(500);
});
// Check that popover shows span operation name in title
expect(screen.getByText('GET /api/users')).toBeInTheDocument();
// Check duration information
expect(screen.getByText('Duration:')).toBeInTheDocument();
expect(screen.getByText('150ms')).toBeInTheDocument();
// Check events count
expect(screen.getByText('Events:')).toBeInTheDocument();
expect(screen.getByText('2')).toBeInTheDocument();
// Check start time label
expect(screen.getByText('Start time:')).toBeInTheDocument();
});
it('displays date in DD MMM YYYY, HH:mm:ss format with seconds', async () => {
render(
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
<div data-testid={HOVER_ELEMENT_ID}>Date format test</div>
</SpanHoverCard>,
);
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
// Hover and wait for popover
fireEvent.mouseEnter(hoverElement);
act(() => {
jest.advanceTimersByTime(500);
});
// Verify the DD MMM YYYY, HH:mm:ss format is displayed
expect(screen.getByText('15 Mar 2024, 14:23:45')).toBeInTheDocument();
});
it('displays relative time information', async () => {
const spanWithRelativeTime: Span = {
...mockSpan,
timestamp: mockTraceMetadata.startTime + 1000000, // 1 second later
};
render(
<SpanHoverCard span={spanWithRelativeTime} traceMetadata={mockTraceMetadata}>
<div data-testid={HOVER_ELEMENT_ID}>Relative time test</div>
</SpanHoverCard>,
);
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
// Hover and wait for popover
fireEvent.mouseEnter(hoverElement);
act(() => {
jest.advanceTimersByTime(500);
});
// Check relative time display
expect(screen.getByText(/after trace start/)).toBeInTheDocument();
});
it('handles spans with no events correctly', async () => {
const spanWithoutEvents: Span = {
...mockSpan,
event: [],
};
render(
<SpanHoverCard span={spanWithoutEvents} traceMetadata={mockTraceMetadata}>
<div data-testid={HOVER_ELEMENT_ID}>No events test</div>
</SpanHoverCard>,
);
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
// Hover and wait for popover
fireEvent.mouseEnter(hoverElement);
act(() => {
jest.advanceTimersByTime(500);
});
expect(screen.getByText('Events:')).toBeInTheDocument();
expect(screen.getByText('0')).toBeInTheDocument();
});
it('verifies mouseEnterDelay prop is set to 0.5', () => {
const { container } = render(
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
<div data-testid={HOVER_ELEMENT_ID}>Delay test</div>
</SpanHoverCard>,
);
// The mouseEnterDelay prop should be set on the Popover component
// This test verifies the implementation includes the delay
const popover = container.querySelector('.ant-popover');
expect(popover).not.toBeInTheDocument(); // Initially not visible
// Hover to trigger delay mechanism
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
fireEvent.mouseEnter(hoverElement);
// Should not appear before delay
expect(screen.queryByText('Duration:')).not.toBeInTheDocument();
// Should appear after delay
act(() => {
jest.advanceTimersByTime(500);
});
expect(screen.getByText('Duration:')).toBeInTheDocument();
});
});

View File

@@ -7,10 +7,10 @@ export enum FeatureKeys {
GATEWAY = 'gateway',
PREMIUM_SUPPORT = 'premium_support',
ANOMALY_DETECTION = 'anomaly_detection',
ONBOARDING_V3 = 'onboarding_v3',
DOT_METRICS_ENABLED = 'dot_metrics_enabled',
USE_JSON_BODY = 'use_json_body',
USE_FINE_GRAINED_AUTHZ = 'use_fine_grained_authz',
USE_DASHBOARD_V2 = 'use_dashboard_v2',
EMABLE_AI_OBSERVABILITY = 'enable_ai_observability',
ENABLE_AI_OBSERVABILITY = 'enable_ai_observability',
ENABLE_METRICS_REDUCTION = 'enable_metrics_reduction',
}

View File

@@ -11,14 +11,7 @@ const ROUTES = {
TRACE_DETAIL_OLD: '/trace-old/:id',
TRACES_EXPLORER: '/traces-explorer',
ONBOARDING: '/onboarding',
GET_STARTED: '/get-started',
GET_STARTED_WITH_CLOUD: '/get-started-with-signoz-cloud',
GET_STARTED_APPLICATION_MONITORING: '/get-started/application-monitoring',
GET_STARTED_LOGS_MANAGEMENT: '/get-started/logs-management',
GET_STARTED_INFRASTRUCTURE_MONITORING:
'/get-started/infrastructure-monitoring',
GET_STARTED_AWS_MONITORING: '/get-started/aws-monitoring',
GET_STARTED_AZURE_MONITORING: '/get-started/azure-monitoring',
USAGE_EXPLORER: '/usage-explorer',
APPLICATION: '/services',
ALL_DASHBOARD: '/dashboard',
@@ -56,7 +49,9 @@ const ROUTES = {
TRACE_EXPLORER: '/trace-explorer',
BILLING: '/settings/billing',
ROLES_SETTINGS: '/settings/roles',
ROLE_CREATE: '/settings/roles/new',
ROLE_DETAILS: '/settings/roles/:roleId',
ROLE_EDIT: '/settings/roles/:roleId/edit',
MEMBERS_SETTINGS: '/settings/members',
SUPPORT: '/support',
LOGS_SAVE_VIEWS: '/logs/saved-views',
@@ -79,6 +74,7 @@ const ROUTES = {
METRICS_EXPLORER: '/metrics-explorer/summary',
METRICS_EXPLORER_EXPLORER: '/metrics-explorer/explorer',
METRICS_EXPLORER_VIEWS: '/metrics-explorer/views',
METRICS_EXPLORER_VOLUME_CONTROL: '/metrics-explorer/volume-control',
API_MONITORING_BASE: '/api-monitoring',
API_MONITORING: '/api-monitoring/explorer',
METRICS_EXPLORER_BASE: '/metrics-explorer',
@@ -93,6 +89,8 @@ const ROUTES = {
AI_ASSISTANT_BASE: '/ai-assistant',
AI_ASSISTANT_ICON_PREVIEW: '/ai-assistant-icon-preview',
MCP_SERVER: '/settings/mcp-server',
LLM_OBSERVABILITY_BASE: '/llm-observability',
LLM_OBSERVABILITY_MODEL_PRICING: '/llm-observability/settings/model-pricing',
} as const;
export default ROUTES;

View File

@@ -377,9 +377,63 @@
}
.contextPopoverEmpty {
// Fill the entity panel so the state sits centred in the dead space rather
// than clinging to the top-left corner.
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
padding: 24px 20px;
text-align: center;
font-size: 12px;
color: var(--l3-foreground);
padding: 10px 8px;
}
.contextPopoverEmptyIcon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
// `--empty-accent` is set per category on the root (robin/cherry/forest).
color: var(--empty-accent);
background: color-mix(in srgb, var(--empty-accent), transparent 88%);
border-radius: var(--radius-2);
}
.contextPopoverEmptyTitle {
margin: 0;
max-width: 280px;
color: var(--l2-foreground);
font-size: 13px;
font-weight: 600;
line-height: 1.4;
// Clamp to 2 lines with an ellipsis so a long query can't blow out the
// popover height. The CTA below is a stock DS link button, so the query is
// kept readable here rather than forcing the button to wrap.
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
text-overflow: ellipsis;
overflow-wrap: anywhere;
}
.contextPopoverEmptyCta {
max-width: 100%;
}
.contextPopoverEmptyCtaLabel {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
overflow-wrap: anywhere;
text-align: center;
}
.micBtn {

View File

@@ -42,19 +42,22 @@ import { useSpeechRecognition } from '../../hooks/useSpeechRecognition';
import { MessageAttachment } from '../../types';
import { MessageContext } from '../../../../api/ai-assistant/chat';
import {
Bell,
LayoutDashboard,
Mic,
Plus,
Search,
Send,
ShieldCheck,
Square,
TriangleAlert,
X,
} from '@signozhq/icons';
import styles from './ChatInput.module.scss';
import ContextPickerEmptyState from './ContextPickerEmptyState';
import {
CONTEXT_CATEGORIES,
CONTEXT_CATEGORY_ICONS,
ContextCategory,
} from './contextPicker';
interface ChatInputProps {
onSend: (
@@ -162,10 +165,6 @@ const HOME_SERVICES_INTERVAL = 30 * 60 * 1000;
/** sessionStorage key for the "voice input failed this tab" flag. */
const VOICE_UNAVAILABLE_KEY = 'ai-assistant-voice-unavailable';
const CONTEXT_CATEGORIES = ['Dashboards', 'Alerts', 'Services'] as const;
type ContextCategory = (typeof CONTEXT_CATEGORIES)[number];
interface SelectedContextItem {
category: ContextCategory;
entityId: string;
@@ -205,12 +204,6 @@ interface ContextEntityItem {
value: string;
}
const CONTEXT_CATEGORY_ICONS = {
Dashboards: LayoutDashboard,
Alerts: Bell,
Services: ShieldCheck,
} satisfies Record<ContextCategory, unknown>;
function fileToDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
@@ -331,6 +324,30 @@ export default function ChatInput({
[mentionRange, selectedContexts, text],
);
// Empty-state CTA: drop a starter prompt into the composer (never auto-sent)
// and hand the user the caret at the end so they can finish the sentence.
const handleContextPrefill = useCallback(
(prompt: string) => {
const next = capText(prompt);
setText(next);
committedTextRef.current = next;
setMentionRange(null);
setPickerSearchQuery('');
setIsContextPickerOpen(false);
// Defer so React commits the new value before we place the caret.
requestAnimationFrame(() => {
const el = textareaRef.current;
if (!el) {
return;
}
el.focus();
const end = el.value.length;
el.setSelectionRange(end, end);
});
},
[capText],
);
const focusCategory = useCallback((category: ContextCategory) => {
setActiveContextCategory(category);
setPickerSearchQuery('');
@@ -824,10 +841,14 @@ export default function ChatInput({
// Type-ahead filter against the `@<query>` typed in the textarea. When
// the picker was opened from the "Add Context" button there's no
// mention query, so fall back to the in-popover search input.
const mentionQuery = mentionRange
? text.slice(mentionRange.start + 1, mentionRange.end).toLowerCase()
const rawMentionQuery = mentionRange
? text.slice(mentionRange.start + 1, mentionRange.end)
: '';
const mentionQuery = rawMentionQuery.toLowerCase();
const activeQuery = mentionQuery || pickerSearchQuery.trim().toLowerCase();
// Original-case query for empty-state copy + prefill ("checkout", not the
// lowercased filter key). Mirrors `activeQuery`'s mention-then-search order.
const displayQuery = rawMentionQuery || pickerSearchQuery.trim();
const filteredContextOptions = activeQuery
? contextEntitiesByCategory[activeContextCategory].filter((entity) =>
entity.value.toLowerCase().includes(activeQuery),
@@ -1071,9 +1092,11 @@ export default function ChatInput({
Failed to load {activeContextCategory.toLowerCase()}.
</div>
) : filteredContextOptions.length === 0 ? (
<div className={styles.contextPopoverEmpty}>
No matching entities
</div>
<ContextPickerEmptyState
category={activeContextCategory}
query={displayQuery}
onPrefill={handleContextPrefill}
/>
) : (
filteredContextOptions.map((option, index) => {
const isSelected = selectedContexts.some(

View File

@@ -0,0 +1,68 @@
import { Sparkles } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import type { CSSProperties } from 'react';
import styles from './ChatInput.module.scss';
import {
CONTEXT_CATEGORY_ICONS,
ContextCategory,
getContextPickerEmptyContent,
} from './contextPicker';
// Per-category accent, mapped to semantic accent tokens (robin is the brand
// primary). Exposed to the SCSS as the `--empty-accent` custom property so the
// icon and CTA share one colour per category.
const CATEGORY_ACCENT: Record<ContextCategory, string> = {
Dashboards: 'var(--accent-primary)',
Alerts: 'var(--accent-cherry)',
Services: 'var(--accent-forest)',
};
interface ContextPickerEmptyStateProps {
category: ContextCategory;
/** The active search query (mention or in-popover search), original case. */
query: string;
/** Drops the starter prompt into the composer (never auto-sends). */
onPrefill: (prompt: string) => void;
}
/**
* Empty state for the @-mention context picker. Distinguishes a brand-new user
* with nothing to pick (onboarding) from a search that matched nothing, and in
* both cases offers a clickable CTA that seeds the composer.
*/
export default function ContextPickerEmptyState({
category,
query,
onPrefill,
}: ContextPickerEmptyStateProps): JSX.Element {
const { title, ctaLabel, prefill } = getContextPickerEmptyContent(
category,
query,
);
const CategoryIcon = CONTEXT_CATEGORY_ICONS[category];
return (
<div
className={styles.contextPopoverEmpty}
style={{ '--empty-accent': CATEGORY_ACCENT[category] } as CSSProperties}
>
<span className={styles.contextPopoverEmptyIcon} aria-hidden="true">
<CategoryIcon size={16} />
</span>
<p className={styles.contextPopoverEmptyTitle}>{title}</p>
<Button
type="button"
variant="link"
size="sm"
color="primary"
className={styles.contextPopoverEmptyCta}
onClick={(): void => onPrefill(prefill)}
data-testid={`ai-context-empty-cta-${category}`}
prefix={<Sparkles size={14} />}
>
<span className={styles.contextPopoverEmptyCtaLabel}>{ctaLabel}</span>
</Button>
</div>
);
}

View File

@@ -0,0 +1,113 @@
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
// The prefill flow only depends on the context-picker data hooks resolving to
// empty lists (so the empty state renders) — mock them to skip real fetches.
jest.mock('hooks/dashboard/useGetAllDashboard', () => ({
useGetAllDashboard: (): unknown => ({
data: [],
isLoading: false,
isError: false,
}),
}));
jest.mock('api/generated/services/rules', () => ({
useListRules: (): unknown => ({ data: [], isLoading: false, isError: false }),
getListRulesQueryKey: (): string[] => ['rules'],
}));
jest.mock('hooks/useQueryService', () => ({
useQueryService: (): unknown => ({
data: [],
isLoading: false,
isFetching: false,
isError: false,
}),
}));
// Irrelevant to the prefill flow and otherwise require browser APIs / extra
// context providers, so stub them out.
jest.mock('../../../hooks/useSpeechRecognition', () => ({
useSpeechRecognition: (): unknown => ({
isListening: false,
isSupported: false,
permission: 'prompt',
start: jest.fn(),
discard: jest.fn(),
}),
}));
jest.mock('../../../hooks/useAIAssistantAnalyticsContext', () => ({
useAIAssistantAnalyticsContext: (): unknown => ({
threadId: undefined,
page: '/',
mode: 'sidepane',
}),
}));
// eslint-disable-next-line import/first
import { TooltipProvider } from '@signozhq/ui/tooltip';
// eslint-disable-next-line import/first
import ChatInput from '../ChatInput';
function renderChatInput(): void {
render(
<TooltipProvider>
<ChatInput onSend={jest.fn()} />
</TooltipProvider>,
);
}
function getComposer(): HTMLTextAreaElement {
return screen.getByPlaceholderText(/Ask anything/i) as HTMLTextAreaElement;
}
describe('ChatInput — empty-state CTA prefill flow', () => {
it('full-replaces existing prose with the query-seeded prompt and closes the picker', async () => {
renderChatInput();
// Pre-existing prose in the composer.
await userEvent.type(getComposer(), 'show me something');
// Open the picker and search for an entity that does not exist.
await userEvent.click(screen.getByRole('button', { name: /add context/i }));
await userEvent.type(
await screen.findByPlaceholderText(/search dashboards/i),
'chk',
);
const cta = await screen.findByTestId('ai-context-empty-cta-Dashboards');
expect(cta).toHaveTextContent('Create a dashboard for "chk"');
await userEvent.click(cta);
// Full-replace is intentional: the prefill is a complete sentence, so the
// prior "show me something" prose is discarded rather than producing
// broken grammar (see handleContextPrefill). The query is seeded in.
expect(getComposer().value).toBe('Create a dashboard for chk');
// Picker closed → the empty-state CTA is gone.
await waitFor(() =>
expect(
screen.queryByTestId('ai-context-empty-cta-Dashboards'),
).not.toBeInTheDocument(),
);
});
it('seeds only the prefix (with trailing space) in the onboarding case', async () => {
renderChatInput();
await userEvent.click(screen.getByRole('button', { name: /add context/i }));
const cta = await screen.findByTestId('ai-context-empty-cta-Dashboards');
expect(cta).toHaveTextContent('Ask me to create one');
await userEvent.click(cta);
expect(getComposer().value).toBe('Create a dashboard for ');
await waitFor(() =>
expect(
screen.queryByTestId('ai-context-empty-cta-Dashboards'),
).not.toBeInTheDocument(),
);
});
});

View File

@@ -0,0 +1,135 @@
import { render, screen, userEvent } from 'tests/test-utils';
import { ContextCategory } from '../contextPicker';
import ContextPickerEmptyState from '../ContextPickerEmptyState';
function renderEmptyState(
category: ContextCategory,
query: string,
onPrefill = jest.fn(),
): { onPrefill: jest.Mock; container: HTMLElement } {
const { container } = render(
<ContextPickerEmptyState
category={category}
query={query}
onPrefill={onPrefill}
/>,
);
return { onPrefill, container };
}
function ctaFor(category: ContextCategory): HTMLElement {
return screen.getByTestId(`ai-context-empty-cta-${category}`);
}
describe('ContextPickerEmptyState', () => {
describe('onboarding (no query)', () => {
it('renders dashboards copy and prefills the prefix only', async () => {
const { onPrefill } = renderEmptyState('Dashboards', '');
expect(screen.getByText('No dashboards yet.')).toBeInTheDocument();
const cta = ctaFor('Dashboards');
expect(cta).toHaveTextContent('Ask me to create one');
await userEvent.click(cta);
expect(onPrefill).toHaveBeenCalledTimes(1);
expect(onPrefill).toHaveBeenCalledWith('Create a dashboard for ');
});
it('renders alerts copy and prefills the prefix only', async () => {
const { onPrefill } = renderEmptyState('Alerts', '');
expect(screen.getByText('No alerts yet.')).toBeInTheDocument();
expect(ctaFor('Alerts')).toHaveTextContent('Ask me to create one');
await userEvent.click(ctaFor('Alerts'));
expect(onPrefill).toHaveBeenCalledWith('Create an alert for ');
});
it('renders instrumentation-flavoured services copy and prefill', async () => {
const { onPrefill } = renderEmptyState('Services', '');
expect(
screen.getByText('No services reporting data yet.'),
).toBeInTheDocument();
expect(ctaFor('Services')).toHaveTextContent(
'Ask me to help set up instrumentation',
);
await userEvent.click(ctaFor('Services'));
expect(onPrefill).toHaveBeenCalledWith(
'Help me set up instrumentation for ',
);
});
it('treats a whitespace-only query as onboarding', () => {
renderEmptyState('Dashboards', ' ');
expect(screen.getByText('No dashboards yet.')).toBeInTheDocument();
});
});
describe('search miss (query, no match)', () => {
it('seeds the query into dashboards copy and prefill', async () => {
const { onPrefill } = renderEmptyState('Dashboards', 'checkout');
expect(
screen.getByText('No dashboards match "checkout".'),
).toBeInTheDocument();
expect(ctaFor('Dashboards')).toHaveTextContent(
'Create a dashboard for "checkout"',
);
await userEvent.click(ctaFor('Dashboards'));
expect(onPrefill).toHaveBeenCalledWith('Create a dashboard for checkout');
});
it('seeds the query into alerts copy and prefill', async () => {
const { onPrefill } = renderEmptyState('Alerts', 'checkout');
expect(screen.getByText('No alerts match "checkout".')).toBeInTheDocument();
await userEvent.click(ctaFor('Alerts'));
expect(onPrefill).toHaveBeenCalledWith('Create an alert for checkout');
});
it('uses instrumentation wording for services search misses', async () => {
const { onPrefill } = renderEmptyState('Services', 'checkout');
expect(
screen.getByText('No services match "checkout".'),
).toBeInTheDocument();
await userEvent.click(ctaFor('Services'));
expect(onPrefill).toHaveBeenCalledWith(
'Help me set up instrumentation for checkout',
);
});
it('preserves the original casing of the query in copy and prefill', async () => {
const { onPrefill } = renderEmptyState('Dashboards', 'Checkout API');
expect(
screen.getByText('No dashboards match "Checkout API".'),
).toBeInTheDocument();
await userEvent.click(ctaFor('Dashboards'));
expect(onPrefill).toHaveBeenCalledWith(
'Create a dashboard for Checkout API',
);
});
});
describe('per-category accent token', () => {
it.each<[ContextCategory, string]>([
['Dashboards', 'var(--accent-primary)'],
['Alerts', 'var(--accent-cherry)'],
['Services', 'var(--accent-forest)'],
])('maps %s to the semantic accent %s', (category, accent) => {
const { container } = renderEmptyState(category, '');
const root = container.firstChild as HTMLElement;
expect(root.style.getPropertyValue('--empty-accent')).toBe(accent);
});
});
it('does not auto-send: nothing fires until the CTA is clicked', () => {
const { onPrefill } = renderEmptyState('Dashboards', 'checkout');
expect(onPrefill).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,100 @@
import {
CONTEXT_CATEGORIES,
getContextPickerEmptyContent,
} from '../contextPicker';
describe('getContextPickerEmptyContent', () => {
describe('onboarding (no query)', () => {
it('returns per-category copy and prefix-only prefill for dashboards', () => {
expect(getContextPickerEmptyContent('Dashboards', '')).toStrictEqual({
title: 'No dashboards yet.',
ctaLabel: 'Ask me to create one',
prefill: 'Create a dashboard for ',
});
});
it('returns per-category copy and prefix-only prefill for alerts', () => {
expect(getContextPickerEmptyContent('Alerts', '')).toStrictEqual({
title: 'No alerts yet.',
ctaLabel: 'Ask me to create one',
prefill: 'Create an alert for ',
});
});
it('returns instrumentation-flavoured copy for services', () => {
expect(getContextPickerEmptyContent('Services', '')).toStrictEqual({
title: 'No services reporting data yet.',
ctaLabel: 'Ask me to help set up instrumentation',
prefill: 'Help me set up instrumentation for ',
});
});
it('treats a whitespace-only query as no query', () => {
expect(getContextPickerEmptyContent('Dashboards', ' ')).toStrictEqual({
title: 'No dashboards yet.',
ctaLabel: 'Ask me to create one',
prefill: 'Create a dashboard for ',
});
});
it('leaves the prefill ending in a space so the caret sits after it', () => {
CONTEXT_CATEGORIES.forEach((category) => {
expect(getContextPickerEmptyContent(category, '').prefill).toMatch(/ $/);
});
});
});
describe('search miss (query, no match)', () => {
it('seeds the query into dashboards copy and prefill', () => {
expect(getContextPickerEmptyContent('Dashboards', 'checkout')).toStrictEqual(
{
title: 'No dashboards match "checkout".',
ctaLabel: 'Create a dashboard for "checkout"',
prefill: 'Create a dashboard for checkout',
},
);
});
it('seeds the query into alerts copy and prefill', () => {
expect(getContextPickerEmptyContent('Alerts', 'checkout')).toStrictEqual({
title: 'No alerts match "checkout".',
ctaLabel: 'Create an alert for "checkout"',
prefill: 'Create an alert for checkout',
});
});
it('uses instrumentation wording for services search misses', () => {
expect(getContextPickerEmptyContent('Services', 'checkout')).toStrictEqual({
title: 'No services match "checkout".',
ctaLabel: 'Set up instrumentation for "checkout"',
prefill: 'Help me set up instrumentation for checkout',
});
});
it('preserves the original casing of the query', () => {
const { title, ctaLabel, prefill } = getContextPickerEmptyContent(
'Dashboards',
'Checkout API',
);
expect(title).toBe('No dashboards match "Checkout API".');
expect(ctaLabel).toBe('Create a dashboard for "Checkout API"');
expect(prefill).toBe('Create a dashboard for Checkout API');
});
it('trims surrounding whitespace from the query', () => {
expect(
getContextPickerEmptyContent('Dashboards', ' checkout ').prefill,
).toBe('Create a dashboard for checkout');
});
});
it('never emits an em-dash (house style)', () => {
CONTEXT_CATEGORIES.forEach((category) => {
const empty = getContextPickerEmptyContent(category, '');
const miss = getContextPickerEmptyContent(category, 'q');
[empty, miss].forEach(({ title, ctaLabel, prefill }) => {
expect(`${title}${ctaLabel}${prefill}`).not.toContain('—');
});
});
});
});

View File

@@ -0,0 +1,103 @@
import { Bell, LayoutDashboard, ShieldCheck } from '@signozhq/icons';
/** Ordered category tabs shown in the @-mention context picker. */
export const CONTEXT_CATEGORIES = ['Dashboards', 'Alerts', 'Services'] as const;
export type ContextCategory = (typeof CONTEXT_CATEGORIES)[number];
/**
* Icon per category, shared by the picker tablist and the empty state. `satisfies`
* keeps the concrete component types so callers can render `<Icon size={n} />`.
*/
export const CONTEXT_CATEGORY_ICONS = {
Dashboards: LayoutDashboard,
Alerts: Bell,
Services: ShieldCheck,
} satisfies Record<ContextCategory, unknown>;
/**
* Resolved copy + composer prefill for one render of the context picker's empty
* state. The picker is tabbed, so a user only ever views one category at a
* time — each category gets its own onboarding and search-miss copy rather than
* a single combined "nothing to show" line.
*/
export interface ContextPickerEmptyContent {
/** Primary line explaining why the list is empty. */
title: string;
/** Clickable call to action that prefills (never auto-sends) the composer. */
ctaLabel: string;
/**
* Text dropped into the composer when the CTA is clicked. When there's no
* search query this is just the prefix with a trailing space, leaving the
* caret at the end for the user to type the entity name.
*/
prefill: string;
}
interface CategoryCopy {
/** Onboarding line, e.g. "No dashboards yet." */
emptyTitle: string;
/** Onboarding CTA label, e.g. "Ask me to create one". */
emptyCtaLabel: string;
/** Search-miss line, e.g. `No dashboards match "checkout".` */
matchTitle: (query: string) => string;
/** Search-miss CTA label, e.g. `Create a dashboard for "checkout"`. */
matchCtaLabel: (query: string) => string;
/**
* Composer prefill prefix (with trailing space). The prefill is always
* `${prefillPrefix}${query}`, so the search-miss case seeds the query and
* the onboarding case leaves only the prefix.
*/
prefillPrefix: string;
}
// Services get instrumentation-flavoured copy: the assistant can't "create" a
// service, they come from telemetry, so the CTA points at setup instead.
const CONTEXT_PICKER_COPY: Record<ContextCategory, CategoryCopy> = {
Dashboards: {
emptyTitle: 'No dashboards yet.',
emptyCtaLabel: 'Ask me to create one',
matchTitle: (query) => `No dashboards match "${query}".`,
matchCtaLabel: (query) => `Create a dashboard for "${query}"`,
prefillPrefix: 'Create a dashboard for ',
},
Alerts: {
emptyTitle: 'No alerts yet.',
emptyCtaLabel: 'Ask me to create one',
matchTitle: (query) => `No alerts match "${query}".`,
matchCtaLabel: (query) => `Create an alert for "${query}"`,
prefillPrefix: 'Create an alert for ',
},
Services: {
emptyTitle: 'No services reporting data yet.',
emptyCtaLabel: 'Ask me to help set up instrumentation',
matchTitle: (query) => `No services match "${query}".`,
matchCtaLabel: (query) => `Set up instrumentation for "${query}"`,
prefillPrefix: 'Help me set up instrumentation for ',
},
};
/**
* Build the empty-state copy for a category. The two states are driven solely
* by whether a search query is active: a non-empty query yields the search-miss
* variant, an empty query the onboarding variant.
*/
export function getContextPickerEmptyContent(
category: ContextCategory,
query: string,
): ContextPickerEmptyContent {
const copy = CONTEXT_PICKER_COPY[category];
const trimmed = query.trim();
if (trimmed) {
return {
title: copy.matchTitle(trimmed),
ctaLabel: copy.matchCtaLabel(trimmed),
prefill: `${copy.prefillPrefix}${trimmed}`,
};
}
return {
title: copy.emptyTitle,
ctaLabel: copy.emptyCtaLabel,
prefill: copy.prefillPrefix,
};
}

View File

@@ -413,14 +413,8 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
const isPanelEditorV2 = routeKey === 'DASHBOARD_PANEL_EDITOR';
const renderFullScreen =
pathname === ROUTES.GET_STARTED ||
pathname === ROUTES.ONBOARDING ||
pathname === ROUTES.GET_STARTED_WITH_CLOUD ||
pathname === ROUTES.GET_STARTED_APPLICATION_MONITORING ||
pathname === ROUTES.GET_STARTED_INFRASTRUCTURE_MONITORING ||
pathname === ROUTES.GET_STARTED_LOGS_MANAGEMENT ||
pathname === ROUTES.GET_STARTED_AWS_MONITORING ||
pathname === ROUTES.GET_STARTED_AZURE_MONITORING ||
isPublicDashboard ||
isPanelEditorV2;

View File

@@ -1,29 +0,0 @@
.full-screen-header-container {
display: flex;
justify-content: center;
align-items: center;
padding: 24px 0;
.brand-logo {
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
cursor: pointer;
img {
height: 32px;
width: 32px;
}
.brand-logo-name {
font-family: 'Work Sans', sans-serif;
font-size: 24px;
font-style: normal;
font-weight: 500;
line-height: 18px;
color: var(--l1-foreground);
}
}
}

View File

@@ -1,28 +0,0 @@
import history from 'lib/history';
import signozBrandLogoUrl from '@/assets/Logos/signoz-brand-logo.svg';
import './FullScreenHeader.styles.scss';
export default function FullScreenHeader({
overrideRoute,
}: {
overrideRoute?: string;
}): React.ReactElement {
const handleLogoClick = (): void => {
history.push(overrideRoute || '/');
};
return (
<div className="full-screen-header-container">
<div className="brand-logo" onClick={handleLogoClick}>
<img src={signozBrandLogoUrl} alt="SigNoz" />
<div className="brand-logo-name">SigNoz</div>
</div>
</div>
);
}
FullScreenHeader.defaultProps = {
overrideRoute: '/',
};

View File

@@ -0,0 +1,27 @@
.llmObservability {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-12) var(--spacing-16);
}
.pageHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--spacing-8);
}
.pageHeaderTitle {
.title {
margin: 0;
font-size: var(--font-size-xl);
font-weight: var(--font-weight-semibold);
}
.subtitle {
margin: var(--spacing-2) 0 0;
color: var(--text-vanilla-400);
font-size: var(--periscope-font-size-base);
}
}

View File

@@ -0,0 +1,18 @@
import styles from './LLMObservability.module.scss';
function LLMObservability(): JSX.Element {
return (
<div className={styles.llmObservability} data-testid="llm-observability-page">
<header className={styles.pageHeader}>
<div className={styles.pageHeaderTitle}>
<h1 className={styles.title}>LLM Observability</h1>
<p className={styles.subtitle}>
Monitor and analyze your LLM usage, costs, and performance
</p>
</div>
</header>
</div>
);
}
export default LLMObservability;

View File

@@ -0,0 +1,27 @@
.llmObservabilityModelPricing {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-12) var(--spacing-16);
}
.pageHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--spacing-8);
}
.pageHeaderTitle {
.title {
margin: 0;
font-size: var(--font-size-xl);
font-weight: var(--font-weight-semibold);
}
.subtitle {
margin: var(--spacing-2) 0 0;
color: var(--text-vanilla-400);
font-size: var(--periscope-font-size-base);
}
}

View File

@@ -0,0 +1,21 @@
import styles from './LLMObservabilityModelPricing.module.scss';
function LLMObservabilityModelPricing(): JSX.Element {
return (
<div
className={styles.llmObservabilityModelPricing}
data-testid="llm-observability-model-pricing-page"
>
<header className={styles.pageHeader}>
<div className={styles.pageHeaderTitle}>
<h1 className={styles.title}>Configuration</h1>
<p className={styles.subtitle}>
Model pricing and cost estimation settings
</p>
</div>
</header>
</div>
);
}
export default LLMObservabilityModelPricing;

View File

@@ -1,10 +1,12 @@
.members-settings {
.members-settings-page {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--padding-4) var(--padding-2) var(--padding-6) var(--padding-4);
height: 100%;
}
.members-settings {
&__header {
display: flex;
flex-direction: column;

View File

@@ -9,6 +9,7 @@ import EditMemberDrawer from 'components/EditMemberDrawer/EditMemberDrawer';
import InviteMembersModal from 'components/InviteMembersModal/InviteMembersModal';
import MembersTable, { MemberRow } from 'components/MembersTable/MembersTable';
import useUrlQuery from 'hooks/useUrlQuery';
import { parseAsBoolean, useQueryState } from 'nuqs';
import { toISOString } from 'utils/app';
import { FilterMode, MemberStatus, toMemberStatus } from './utils';
@@ -26,7 +27,10 @@ function MembersSettings(): JSX.Element {
// TODO(nuqs): Replace with nuqs once the nuqs setup and integration is done - for search
const [searchQuery, setSearchQuery] = useState('');
const [filterMode, setFilterMode] = useState<FilterMode>(FilterMode.All);
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false);
const [isInviteModalOpen, setIsInviteModalOpen] = useQueryState(
'invite',
parseAsBoolean.withDefault(false),
);
const [selectedMember, setSelectedMember] = useState<MemberRow | null>(null);
const { data: usersData, isLoading, refetch: refetchUsers } = useListUsers();
@@ -160,7 +164,7 @@ function MembersSettings(): JSX.Element {
}, [refetchUsers]);
return (
<>
<div className="members-settings-page">
<div className="members-settings">
<div className="members-settings__header">
<h1 className="members-settings__title">Members</h1>
@@ -201,7 +205,7 @@ function MembersSettings(): JSX.Element {
<Button
variant="solid"
color="primary"
onClick={(): void => setIsInviteModalOpen(true)}
onClick={(): void => void setIsInviteModalOpen(true)}
>
<Plus size={12} />
Invite member
@@ -221,7 +225,7 @@ function MembersSettings(): JSX.Element {
<InviteMembersModal
open={isInviteModalOpen}
onClose={(): void => setIsInviteModalOpen(false)}
onClose={(): void => void setIsInviteModalOpen(null)}
onComplete={handleInviteComplete}
/>
@@ -231,7 +235,7 @@ function MembersSettings(): JSX.Element {
onClose={handleDrawerClose}
onComplete={handleMemberEditComplete}
/>
</>
</div>
);
}

View File

@@ -130,4 +130,14 @@ describe('MembersSettings (integration)', () => {
screen.findAllByPlaceholderText('john@signoz.io'),
).resolves.toHaveLength(3);
});
it('opens InviteMembersModal when invite=true query param is present', async () => {
render(<MembersSettings />, undefined, {
initialRoute: '/settings/members?invite=true',
});
await expect(
screen.findAllByPlaceholderText('john@signoz.io'),
).resolves.toHaveLength(3);
});
});

View File

@@ -191,9 +191,6 @@ function TimeSeries({
if (metrics[0] && yAxisUnit) {
updateMetricMetadata(
{
pathParams: {
metricName: metricNames[0],
},
data: buildUpdateMetricYAxisUnitPayload(
metricNames[0],
metrics[0],

View File

@@ -48,18 +48,14 @@ function AllAttributes({
isLoading: isLoadingAttributes,
isError: isErrorAttributes,
refetch: refetchAttributes,
} = useGetMetricAttributes(
{
metricName,
},
{
start: minTime ? Math.floor(minTime / 1000000) : undefined,
end: maxTime ? Math.floor(maxTime / 1000000) : undefined,
},
);
} = useGetMetricAttributes({
metricName,
start: minTime ? Math.floor(minTime / 1000000) : undefined,
end: maxTime ? Math.floor(maxTime / 1000000) : undefined,
});
const attributes = useMemo(
() => attributesData?.data?.attributes ?? [],
() => attributesData?.data.attributes ?? [],
[attributesData],
);

View File

@@ -237,9 +237,6 @@ function Metadata({
const handleSave = useCallback(() => {
updateMetricMetadata(
{
pathParams: {
metricName,
},
data: transformUpdateMetricMetadataRequest(metricName, metricMetadataState),
},
{

View File

@@ -21,6 +21,7 @@ import AllAttributes from './AllAttributes';
import DashboardsAndAlertsPopover from './DashboardsAndAlertsPopover';
import Highlights from './Highlights';
import Metadata from './Metadata';
import VolumeControlSection from '../VolumeControl/components/VolumeControlSection/VolumeControlSection';
import { MetricDetailsProps } from './types';
import { getMetricDetailsQuery } from './utils';
@@ -56,7 +57,7 @@ function MetricDetails({
);
const metadata = useMemo(() => {
if (!metricMetadataResponse?.data) {
if (!metricMetadataResponse) {
return null;
}
const { type, description, unit, temporality, isMonotonic } =
@@ -190,6 +191,7 @@ function MetricDetails({
isLoadingMetricMetadata={isLoadingMetricMetadata}
refetchMetricMetadata={refetchMetricMetadata}
/>
<VolumeControlSection metricName={metricName} />
<AllAttributes
metricName={metricName}
metricType={metadata?.type}

View File

@@ -195,14 +195,12 @@ describe('Metadata', () => {
expect(mockUseUpdateMetricMetadata).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
metricName: MOCK_METRIC_NAME,
type: MetrictypesTypeDTO.sum,
temporality: MetrictypesTemporalityDTO.cumulative,
unit: 'By',
isMonotonic: true,
}),
pathParams: {
metricName: MOCK_METRIC_NAME,
},
}),
expect.objectContaining({
onSuccess: expect.any(Function),

View File

@@ -77,6 +77,14 @@ jest.mock(
},
);
jest.mock(
'container/MetricsExplorer/VolumeControl/components/VolumeControlSection/VolumeControlSection',
() =>
function MockVolumeControlSection(): JSX.Element {
return <div data-testid="volume-control-section-mock">Volume Control</div>;
},
);
const useGetMetricMetadataMock = jest.spyOn(
metricsExplorerHooks,
'useGetMetricMetadata',

View File

@@ -58,6 +58,7 @@
}
.metrics-table-container {
padding-bottom: 48px;
.ant-table {
margin-left: -16px;
margin-right: -16px;

View File

@@ -0,0 +1,64 @@
import {
Querybuildertypesv5QueryRangeResponseDTO,
Querybuildertypesv5TimeSeriesDataDTO,
Querybuildertypesv5TimeSeriesDTO,
Querybuildertypesv5TimeSeriesValueDTO,
} from 'api/generated/services/sigNoz.schemas';
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
function findSeries(
series: Querybuildertypesv5TimeSeriesDTO[] | null | undefined,
label: string,
): Querybuildertypesv5TimeSeriesDTO | undefined {
return series?.find((entry) =>
(entry.labels ?? []).some((value) => value.value === label),
);
}
function toChartValues(
points: Querybuildertypesv5TimeSeriesValueDTO[],
): [number, string][] {
return points.map((point) => [
Math.floor((point.timestamp ?? 0) / 1000),
String(point.value ?? 0),
]);
}
export function buildVolumeChartPayload(
response?: Querybuildertypesv5QueryRangeResponseDTO,
): SuccessResponse<MetricRangePayloadProps> {
const result = response?.data?.results?.[0] as
| Querybuildertypesv5TimeSeriesDataDTO
| undefined;
const series = result?.aggregations?.[0]?.series;
const ingested = findSeries(series, 'ingested')?.values ?? [];
const retained = findSeries(series, 'retained')?.values ?? [];
return {
statusCode: 200,
message: 'Success',
error: null,
payload: {
data: {
resultType: 'matrix',
result: [
{
queryName: 'ingested',
legend: 'Ingested',
metric: {},
values: toChartValues(ingested),
},
{
queryName: 'retained',
legend: 'Retained',
metric: {},
values: toChartValues(retained),
},
],
newResult: { data: { result: [], resultType: 'matrix' } },
},
},
};
}

View File

@@ -0,0 +1,23 @@
import { Gauge } from '@signozhq/icons';
import { MetricreductionruletypesGettableReductionRuleDTO } from 'api/generated/services/sigNoz.schemas';
import { Badge } from '@signozhq/ui/badge';
interface VolumeControlBadgeProps {
rule: MetricreductionruletypesGettableReductionRuleDTO;
}
function VolumeControlBadge({ rule }: VolumeControlBadgeProps): JSX.Element {
return (
<Badge
data-testid="vc-badge-active"
variant="outline"
color={!rule.active ? 'success' : 'warning'}
>
<Gauge size={12} />
{!rule.active ? 'Active' : 'Pending'}
</Badge>
);
}
export default VolumeControlBadge;

View File

@@ -0,0 +1,17 @@
.chart {
display: flex;
flex-direction: column;
gap: 8px;
border: 1px solid var(--bg-slate-400, #1d212d);
border-radius: 6px;
padding: 12px 16px 0 16px;
}
.chartTitle {
text-transform: uppercase;
letter-spacing: 0.05em;
}
.chartBody {
height: 340px;
}

View File

@@ -0,0 +1,88 @@
import { Color } from '@signozhq/design-tokens';
import { Typography } from '@signozhq/ui/typography';
import { useGetMetricReductionRuleTimeseries } from 'api/generated/services/metrics';
import { PANEL_TYPES } from 'constants/queryBuilder';
import BarChart from 'container/DashboardContainer/visualization/charts/BarChart/BarChart';
import { buildBaseConfig } from 'container/DashboardContainer/visualization/panels/utils/baseConfigBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { DrawStyle } from 'lib/uPlotV2/config/types';
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
import { useTimezone } from 'providers/Timezone';
import { useMemo, useRef } from 'react';
import { buildVolumeChartPayload } from '../../chartUtils';
import styles from './VolumeControlChart.module.scss';
const COLOR_MAPPING: Record<string, string> = {
Ingested: Color.BG_ROBIN_500,
Retained: Color.BG_FOREST_500,
};
interface VolumeControlChartProps {
enabled: boolean;
}
function VolumeControlChart({ enabled }: VolumeControlChartProps): JSX.Element {
const { data } = useGetMetricReductionRuleTimeseries({ query: { enabled } });
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
const graphRef = useRef<HTMLDivElement>(null);
const dimensions = useResizeObserver(graphRef);
const payload = useMemo(
() => buildVolumeChartPayload(data?.data).payload,
[data],
);
const chartData = useMemo(() => getUPlotChartData(payload), [payload]);
const config = useMemo(() => {
const timestamps = (chartData[0] as number[]) ?? [];
const builder = buildBaseConfig({
id: 'metric-volume-control',
isDarkMode,
apiResponse: payload,
timezone,
panelType: PANEL_TYPES.BAR,
yAxisUnit: 'short',
onDragSelect: (): void => {},
minTimeScale: timestamps[0],
maxTimeScale: timestamps[timestamps.length - 1],
});
(payload.data.result ?? []).forEach((series) => {
builder.addSeries({
scaleKey: 'y',
drawStyle: DrawStyle.Bar,
label: series.legend ?? series.queryName,
colorMapping: COLOR_MAPPING,
isDarkMode,
});
});
return builder;
}, [payload, chartData, isDarkMode, timezone]);
return (
<div className={styles.chart} data-testid="volume-control-chart">
<Typography.Text className={styles.chartTitle} size={'small'}>
Series volume over time · ingested vs retained
</Typography.Text>
<div className={styles.chartBody} ref={graphRef}>
{dimensions.width > 0 && (
<BarChart
config={config}
data={chartData}
width={dimensions.width}
height={dimensions.height}
yAxisUnit="short"
timezone={timezone}
legendConfig={{ position: LegendPosition.BOTTOM }}
/>
)}
</div>
</div>
);
}
export default VolumeControlChart;

View File

@@ -0,0 +1,27 @@
.impactPanel {
border: 1px solid var(--bg-slate-400, #1d212d);
border-radius: 6px;
background: var(--bg-ink-300, #16181d);
padding: 14px;
}
.meterGrid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.meter {
display: flex;
flex-direction: column;
gap: 5px;
}
.meterLabel {
text-transform: uppercase;
letter-spacing: 0.04em;
}
.meterValue {
font-family: 'Geist Mono', monospace;
}

View File

@@ -0,0 +1,81 @@
import { Typography } from '@signozhq/ui/typography';
import { Spin } from 'antd';
import { MetricreductionruletypesGettableReductionRulePreviewDTO } from 'api/generated/services/sigNoz.schemas';
import { formatCompact } from '../../../configUtils';
import { RuleMode } from '../../../types';
import styles from './ImpactPanel.module.scss';
interface ImpactPanelProps {
mode: RuleMode;
preview?: MetricreductionruletypesGettableReductionRulePreviewDTO;
isLoading: boolean;
}
function ImpactPanel({
mode,
preview,
isLoading,
}: ImpactPanelProps): JSX.Element {
if (mode === 'all') {
return (
<div className={styles.impactPanel} data-testid="volume-control-impact">
<Typography.Text size="small" color="muted">
All attributes remain queryable, no reduction.
</Typography.Text>
</div>
);
}
const current = preview?.currentRetainedSeries ?? 0;
const proposed = preview?.retainedSeries ?? 0;
const deltaPct = current > 0 ? (1 - proposed / current) * 100 : 0;
const reductionLabel = `${deltaPct >= 0 ? '' : '+'}${Math.round(
Math.abs(deltaPct),
)}%`;
return (
<div className={styles.impactPanel} data-testid="volume-control-impact">
{isLoading && <Spin size="small" />}
{!isLoading && preview && (
<div className={styles.meterGrid}>
<div className={styles.meter}>
<Typography.Text size="xs" color="muted" className={styles.meterLabel}>
Current series
</Typography.Text>
<Typography.Text size="2xl" className={styles.meterValue}>
{formatCompact(current)}
</Typography.Text>
</div>
<div className={styles.meter}>
<Typography.Text size="xs" color="muted" className={styles.meterLabel}>
Proposed series
</Typography.Text>
<Typography.Text size="2xl" className={styles.meterValue}>
{formatCompact(proposed)}
</Typography.Text>
</div>
<div className={styles.meter}>
<Typography.Text size="xs" color="muted" className={styles.meterLabel}>
Reduction
</Typography.Text>
<Typography.Text
size="2xl"
color={deltaPct >= 0 ? 'success' : undefined}
className={styles.meterValue}
>
{reductionLabel}
</Typography.Text>
</div>
</div>
)}
{!isLoading && !preview && (
<Typography.Text size="small" color="muted">
Select attributes to preview the impact.
</Typography.Text>
)}
</div>
);
}
export default ImpactPanel;

View File

@@ -0,0 +1,10 @@
.fieldGroup {
display: flex;
flex-direction: column;
gap: 6px;
}
.attributeSelect {
width: 100%;
margin-top: 4px;
}

View File

@@ -0,0 +1,54 @@
import { Typography } from '@signozhq/ui/typography';
import { Select } from 'antd';
import { popupContainer } from 'utils/selectPopupContainer';
import { RuleMode } from '../../../types';
import styles from './LabelSelector.module.scss';
interface LabelSelectorProps {
mode: RuleMode;
options: string[];
value: string[];
onChange: (labels: string[]) => void;
loading?: boolean;
}
function LabelSelector({
mode,
options,
value,
onChange,
loading,
}: LabelSelectorProps): JSX.Element {
const helpText =
mode === 'include'
? 'Only the selected attributes will remain queryable.'
: 'The selected attributes will be aggregated away; all others stay queryable.';
return (
<div
className={styles.fieldGroup}
data-testid="volume-control-label-selector"
>
<Typography.Text size="small" weight="semibold">
Attributes
</Typography.Text>
<Typography.Text size="sm" color="muted">
{helpText}
</Typography.Text>
<Select
mode="multiple"
className={styles.attributeSelect}
placeholder="Select attributes"
value={value}
onChange={onChange}
loading={loading}
options={options.map((key) => ({ label: key, value: key }))}
getPopupContainer={popupContainer}
data-testid="volume-control-label-select"
/>
</div>
);
}
export default LabelSelector;

View File

@@ -0,0 +1,29 @@
.modeOptions {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
.modeOption {
display: flex;
flex-direction: column;
gap: 4px;
text-align: left;
border: 1px solid var(--bg-slate-400, #1d212d);
border-radius: 6px;
background: var(--bg-ink-300, #16181d);
padding: 12px;
cursor: pointer;
transition:
border-color 0.12s ease,
background 0.12s ease;
&:hover {
border-color: var(--bg-slate-200, #2c3140);
}
}
.modeOptionSelected {
border-color: var(--bg-robin-500, #4e74f8);
background: var(--callout-primary-background);
}

View File

@@ -0,0 +1,64 @@
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import { RuleMode } from '../../../types';
import styles from './ModeSelector.module.scss';
interface ModeOption {
mode: RuleMode;
title: string;
description: string;
}
const MODE_OPTIONS: ModeOption[] = [
{
mode: 'all',
title: 'Allow all attributes',
description: 'All attributes stay queryable. Removes any existing rule.',
},
{
mode: 'include',
title: 'Include attributes',
description: 'Allowlist: only the selected attributes stay queryable.',
},
{
mode: 'exclude',
title: 'Exclude attributes',
description: 'Blocklist: the selected attributes are aggregated away.',
},
];
interface ModeSelectorProps {
mode: RuleMode;
onChange: (mode: RuleMode) => void;
}
function ModeSelector({ mode, onChange }: ModeSelectorProps): JSX.Element {
return (
<div
className={styles.modeOptions}
data-testid="volume-control-mode-selector"
>
{MODE_OPTIONS.map((option) => (
<button
type="button"
key={option.mode}
className={cx(styles.modeOption, {
[styles.modeOptionSelected]: mode === option.mode,
})}
onClick={(): void => onChange(option.mode)}
data-testid={`volume-control-mode-${option.mode}`}
>
<Typography.Text size="small" weight="semibold">
{option.title}
</Typography.Text>
<Typography.Text size="sm" color="muted">
{option.description}
</Typography.Text>
</button>
))}
</div>
);
}
export default ModeSelector;

View File

@@ -0,0 +1,20 @@
.warning {
display: flex;
gap: 10px;
padding: 11px 13px;
border-radius: 6px;
background: var(--callout-warning-background);
border: 1px solid var(--callout-warning-border);
color: var(--callout-warning-icon);
}
.warningBody {
display: flex;
flex-direction: column;
gap: 4px;
}
.assetList {
margin: 2px 0 0;
padding-left: 16px;
}

View File

@@ -0,0 +1,97 @@
import { Info } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import {
MetricreductionruletypesAffectedAssetDTO,
MetricreductionruletypesAssetTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import styles from './RelatedAssetsWarning.module.scss';
const AssetType = MetricreductionruletypesAssetTypeDTO;
function assetHref(
asset: MetricreductionruletypesAffectedAssetDTO,
): string | undefined {
if (!asset.id) {
return undefined;
}
if (asset.type === AssetType.dashboard) {
const base = ROUTES.DASHBOARD.replace(':dashboardId', asset.id);
return asset.widget?.id
? `${base}?${QueryParams.expandedWidgetId}=${asset.widget.id}`
: base;
}
if (asset.type === AssetType.alert_rule) {
return `${ROUTES.EDIT_ALERTS}?ruleId=${asset.id}`;
}
return undefined;
}
interface RelatedAssetsWarningProps {
affectedAssets?: MetricreductionruletypesAffectedAssetDTO[] | null;
}
function RelatedAssetsWarning({
affectedAssets,
}: RelatedAssetsWarningProps): JSX.Element | null {
const impacted = (affectedAssets ?? []).filter(
(asset) =>
asset.type === AssetType.alert_rule ||
(asset.impactedLabels ?? []).length > 0,
);
if (impacted.length === 0) {
return null;
}
const impactedLabels = Array.from(
new Set(impacted.flatMap((asset) => asset.impactedLabels ?? [])),
);
return (
<div className={styles.warning} data-testid="volume-control-warning">
<Info size={14} />
<div className={styles.warningBody}>
<Typography.Text as="div" size="small" weight="semibold" color="warning">
This rule affects {impacted.length} related asset
{impacted.length > 1 ? 's' : ''}.
</Typography.Text>
{impactedLabels.length > 0 && (
<Typography.Text as="div" size="sm" color="muted">
{impactedLabels.join(', ')} will no longer be queryable; affected panels
fall back to aggregated data once the rule applies.
</Typography.Text>
)}
<ul className={styles.assetList}>
{impacted.map((asset) => {
const href = assetHref(asset);
const label = `${asset.name}${
asset.widget ? ` · ${asset.widget.name}` : ''
}`;
return (
<li key={`${asset.type}-${asset.id}-${asset.widget?.id ?? ''}`}>
{href ? (
<Typography.Link
size="sm"
href={href}
target="_blank"
rel="noopener noreferrer"
>
{label}
</Typography.Link>
) : (
<Typography.Text size="sm" color="muted">
{label}
</Typography.Text>
)}
</li>
);
})}
</ul>
</div>
</div>
);
}
export default RelatedAssetsWarning;

View File

@@ -0,0 +1,30 @@
.body {
display: flex;
flex-direction: column;
gap: 20px;
padding: 4px 2px;
}
.adminRow {
display: flex;
align-items: center;
gap: 10px;
}
.adminOnlyTag {
border: 1px solid var(--bg-amber-500, #ffcc56);
border-radius: 99px;
padding: 1px 8px;
letter-spacing: 0.03em;
}
.footer {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
}
.footerSpacer {
flex: 1;
}

View File

@@ -0,0 +1,123 @@
import { Button } from '@signozhq/ui/button';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import { Typography } from '@signozhq/ui/typography';
import { MetricreductionruletypesGettableReductionRuleDTO } from 'api/generated/services/sigNoz.schemas';
import { useVolumeControlConfig } from '../../hooks/useVolumeControlConfig';
import ImpactPanel from './ImpactPanel/ImpactPanel';
import LabelSelector from './LabelSelector/LabelSelector';
import ModeSelector from './ModeSelector/ModeSelector';
import RelatedAssetsWarning from './RelatedAssetsWarning/RelatedAssetsWarning';
import styles from './VolumeControlConfigDrawer.module.scss';
interface VolumeControlConfigDrawerProps {
metricName: string;
existingRule: MetricreductionruletypesGettableReductionRuleDTO | null;
open: boolean;
onClose: () => void;
}
function VolumeControlConfigDrawer({
metricName,
existingRule,
open,
onClose,
}: VolumeControlConfigDrawerProps): JSX.Element {
const {
mode,
setMode,
labels,
setLabels,
attributeKeys,
isLoadingAttributes,
preview,
isPreviewLoading,
save,
remove,
isSaving,
isRemoving,
hasExistingRule,
isSaveDisabled,
} = useVolumeControlConfig({ metricName, existingRule, open, onClose });
const footer = (
<div className={styles.footer}>
<Button
variant="outlined"
color="secondary"
onClick={onClose}
data-testid="volume-control-cancel"
>
Cancel
</Button>
<div className={styles.footerSpacer} />
{hasExistingRule && (
<Button
variant="ghost"
color="destructive"
onClick={remove}
loading={isRemoving}
data-testid="volume-control-remove"
>
Remove rule
</Button>
)}
<Button
variant="solid"
color="primary"
onClick={save}
disabled={isSaveDisabled}
loading={isSaving}
data-testid="volume-control-save"
>
Save rule
</Button>
</div>
);
return (
<DrawerWrapper
open={open}
onOpenChange={(next: boolean): void => {
if (!next) {
onClose();
}
}}
title={`Manage attributes · ${metricName}`}
direction="right"
showCloseButton
width="wide"
footer={footer}
showOverlay={false}
className="volume-control-config-drawer"
style={{ zIndex: 1100 }}
>
<div className={styles.body} data-testid="volume-control-config-drawer">
<div className={styles.adminRow}>
<Typography.Text
size="xs"
weight="semibold"
color="warning"
className={styles.adminOnlyTag}
>
Admin only
</Typography.Text>
</div>
<ModeSelector mode={mode} onChange={setMode} />
{mode !== 'all' && (
<LabelSelector
mode={mode}
options={attributeKeys}
value={labels}
onChange={setLabels}
loading={isLoadingAttributes}
/>
)}
<ImpactPanel mode={mode} preview={preview} isLoading={isPreviewLoading} />
<RelatedAssetsWarning affectedAssets={preview?.affectedAssets} />
</div>
</DrawerWrapper>
);
}
export default VolumeControlConfigDrawer;

View File

@@ -0,0 +1,12 @@
.emptyState {
display: flex;
flex-direction: column;
align-items: flex-start;
border: 1px dashed var(--bg-slate-300, #242834);
border-radius: 6px;
padding: 14px;
}
.setupButton {
margin-top: 12px;
}

View File

@@ -0,0 +1,36 @@
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import styles from './NoRuleEmptyState.module.scss';
interface NoRuleEmptyStateProps {
canManage: boolean;
onSetup: () => void;
}
function NoRuleEmptyState({
canManage,
onSetup,
}: NoRuleEmptyStateProps): JSX.Element {
return (
<div className={styles.emptyState} data-testid="volume-control-empty">
<Typography.Text size="small" color="muted">
No volume control rule. All series are retained. Aggregate away
high-cardinality attributes to reduce cost.
</Typography.Text>
{canManage && (
<Button
variant="solid"
color="primary"
className={styles.setupButton}
onClick={onSetup}
data-testid="volume-control-setup"
>
Set up volume control
</Button>
)}
</div>
);
}
export default NoRuleEmptyState;

View File

@@ -0,0 +1,10 @@
.banner {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 9px 12px;
border-radius: 6px;
background: var(--callout-primary-background);
border: 1px solid var(--callout-primary-border);
color: var(--callout-primary-icon);
}

View File

@@ -0,0 +1,18 @@
import { Info } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import styles from './PendingActivationBanner.module.scss';
function PendingActivationBanner(): JSX.Element {
return (
<div className={styles.banner} data-testid="volume-control-pending-banner">
<Info size={13} />
<Typography.Text size="sm" color="muted">
This metric&apos;s configuration was recently updated. Volume changes will
take effect within a few minutes.
</Typography.Text>
</div>
);
}
export default PendingActivationBanner;

View File

@@ -0,0 +1,48 @@
.card {
display: flex;
flex-direction: column;
gap: 8px;
border: 1px solid var(--bg-slate-400, #1d212d);
border-radius: 6px;
padding: 12px 14px;
background: var(--bg-ink-300, #16181d);
}
.cardRow {
display: flex;
align-items: center;
gap: 8px;
}
.statusIndicator {
width: 7px;
height: 7px;
border-radius: 50%;
flex: 0 0 auto;
}
.statusActive {
background: var(--bg-forest-500, #25e192);
}
.statusPending {
background: var(--bg-amber-500, #ffcc56);
}
.editButton {
margin-left: auto;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.labelChip {
font-family: 'Geist Mono', monospace;
padding: 2px 8px;
border-radius: 4px;
background: var(--bg-ink-200, #23262e);
border: 1px solid var(--bg-slate-400, #1d212d);
}

View File

@@ -0,0 +1,60 @@
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { MetricreductionruletypesGettableReductionRuleDTO } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import { getLabelVerb, getMatchTypeLabel } from '../../../ruleUtils';
import styles from './RuleSummaryCard.module.scss';
interface RuleSummaryCardProps {
rule: MetricreductionruletypesGettableReductionRuleDTO;
canManage: boolean;
onEdit: () => void;
}
function RuleSummaryCard({
rule,
canManage,
onEdit,
}: RuleSummaryCardProps): JSX.Element {
return (
<div className={styles.card} data-testid="volume-control-active">
<div className={styles.cardRow}>
<span
className={cx(
styles.statusIndicator,
rule.active ? styles.statusActive : styles.statusPending,
)}
/>
<Typography.Text weight="semibold">
{rule.active
? 'Aggregation rule active'
: 'Aggregation rule pending activation'}
</Typography.Text>
{canManage && (
<Button
variant="ghost"
color="secondary"
className={styles.editButton}
onClick={onEdit}
data-testid="volume-control-edit"
>
Edit
</Button>
)}
</div>
<Typography.Text as="div" size="small" color="muted">
{getMatchTypeLabel(rule.matchType)}
</Typography.Text>
<div className={styles.chips}>
{(rule.labels ?? []).map((label) => (
<Typography.Text size="sm" className={styles.labelChip} key={label}>
{getLabelVerb(rule.matchType)} {label}
</Typography.Text>
))}
</div>
</div>
);
}
export default RuleSummaryCard;

View File

@@ -0,0 +1,16 @@
.section {
margin-top: 16px;
}
.header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
color: var(--bg-vanilla-400, #c0c1c3);
}
.title {
text-transform: uppercase;
letter-spacing: 0.05em;
}

View File

@@ -0,0 +1,84 @@
import { Gauge } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import { Skeleton } from 'antd';
import { useListMetricReductionRules } from 'api/generated/services/metrics';
import { useVolumeControlFeatureGate } from 'hooks/metricsExplorer/useVolumeControlFeatureGate';
import { useState } from 'react';
import NoRuleEmptyState from './NoRuleEmptyState/NoRuleEmptyState';
import PendingActivationBanner from './PendingActivationBanner/PendingActivationBanner';
import RuleSummaryCard from './RuleSummaryCard/RuleSummaryCard';
import VolumeControlConfigDrawer from '../VolumeControlConfigDrawer/VolumeControlConfigDrawer';
import styles from './VolumeControlSection.module.scss';
interface VolumeControlSectionProps {
metricName: string;
}
function VolumeControlSection({
metricName,
}: VolumeControlSectionProps): JSX.Element | null {
const { isVolumeControlEnabled, canManageVolumeControl } =
useVolumeControlFeatureGate();
const [isConfigOpen, setIsConfigOpen] = useState(false);
const { data, isLoading, error } = useListMetricReductionRules(
{ metricName },
{
query: {
enabled: isVolumeControlEnabled && !!metricName,
retry: false,
},
},
);
if (!isVolumeControlEnabled) {
return null;
}
const rule = data?.data.rules?.[0];
const hasRule = !!rule && !error;
const openConfig = (): void => setIsConfigOpen(true);
const closeConfig = (): void => setIsConfigOpen(false);
return (
<div className={styles.section} data-testid="volume-control-section">
<div className={styles.header}>
<Gauge size={14} />
<Typography.Text size="sm" weight="semibold" className={styles.title}>
Volume control
</Typography.Text>
</div>
{isLoading && <Skeleton active title={false} paragraph={{ rows: 2 }} />}
{!isLoading && hasRule && rule && !rule.active && (
<PendingActivationBanner />
)}
{!isLoading && hasRule && rule && (
<RuleSummaryCard
rule={rule}
canManage={canManageVolumeControl}
onEdit={openConfig}
/>
)}
{!isLoading && !hasRule && (
<NoRuleEmptyState canManage={canManageVolumeControl} onSetup={openConfig} />
)}
{canManageVolumeControl && isConfigOpen && (
<VolumeControlConfigDrawer
metricName={metricName}
existingRule={rule ?? null}
open={isConfigOpen}
onClose={closeConfig}
/>
)}
</div>
);
}
export default VolumeControlSection;

View File

@@ -0,0 +1,20 @@
.header {
display: flex;
flex-direction: column;
gap: 4px;
}
.titleRow {
display: flex;
align-items: center;
gap: 8px;
color: var(--bg-robin-400, #7190f9);
}
.title {
margin: 0 !important;
}
.subtitle {
max-width: 680px;
}

View File

@@ -0,0 +1,23 @@
import { Gauge } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import styles from './VolumeControlHeader.module.scss';
function VolumeControlHeader(): JSX.Element {
return (
<div className={styles.header}>
<div className={styles.titleRow}>
<Gauge size={18} />
<Typography.Title level={4} className={styles.title}>
Volume Control
</Typography.Title>
</div>
<Typography.Text size="small" color="muted" className={styles.subtitle}>
Aggregate away high-cardinality attributes to reduce stored metric volume
and cost.
</Typography.Text>
</div>
);
}
export default VolumeControlHeader;

View File

@@ -0,0 +1,34 @@
.stats {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.statCard {
display: flex;
flex: 1 1 0;
flex-direction: column;
gap: 6px;
min-width: 150px;
padding: 12px 16px;
border: 1px solid var(--bg-slate-400, #1d212d);
border-radius: 6px;
background: var(--bg-ink-400, #121317);
}
.statCardHighlighted {
border-color: var(--callout-success-border);
background: var(--callout-success-background);
}
.statCardLabel {
text-transform: uppercase;
letter-spacing: 0.05em;
}
.statCardValue {
display: flex;
align-items: baseline;
gap: 6px;
font-family: 'Geist Mono', monospace;
}

View File

@@ -0,0 +1,88 @@
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import { formatCompact, formatUsd } from '../../../configUtils';
import styles from './VolumeControlStats.module.scss';
interface VolumeControlStatsProps {
activeRules: number;
ingestedSeries: number;
retainedSeries: number;
estimatedMonthlySavingsUsd: number;
}
interface StatItem {
label: string;
value: string;
delta?: string;
unit?: string;
highlighted?: boolean;
valueGood?: boolean;
}
function VolumeControlStats({
activeRules,
ingestedSeries,
retainedSeries,
estimatedMonthlySavingsUsd,
}: VolumeControlStatsProps): JSX.Element {
const overallReduction =
ingestedSeries > 0
? Math.round((1 - retainedSeries / ingestedSeries) * 100)
: 0;
const items: StatItem[] = [
{ label: 'Active rules', value: String(activeRules) },
{ label: 'Ingested series', value: formatCompact(ingestedSeries) },
{
label: 'Retained series',
value: formatCompact(retainedSeries),
delta: overallReduction > 0 ? `${overallReduction}%` : undefined,
},
{
label: 'Est. monthly savings',
value: formatUsd(estimatedMonthlySavingsUsd),
unit: '/mo',
highlighted: true,
valueGood: true,
},
];
return (
<div className={styles.stats} data-testid="volume-control-stats">
{items.map((item) => (
<div
key={item.label}
className={cx(styles.statCard, {
[styles.statCardHighlighted]: item.highlighted,
})}
>
<Typography.Text size="sm" color="muted" className={styles.statCardLabel}>
{item.label}
</Typography.Text>
<Typography.Text
as="div"
size="large"
weight="semibold"
color={item.valueGood ? 'success' : undefined}
className={styles.statCardValue}
>
{item.value}
{item.delta && (
<Typography.Text size="small" weight="semibold" color="success">
{item.delta}
</Typography.Text>
)}
{item.unit && (
<Typography.Text size="small" weight="medium" color="muted">
{item.unit}
</Typography.Text>
)}
</Typography.Text>
</div>
))}
</div>
);
}
export default VolumeControlStats;

View File

@@ -0,0 +1,28 @@
.tab {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px 0;
}
.metricNameCell {
font-family: 'Geist Mono', monospace;
}
.attributesCell {
font-family: 'Geist Mono', monospace;
}
.reductionCell {
font-family: 'Geist Mono', monospace;
}
.empty {
padding: 32px 16px;
text-align: center;
}
.unavailable {
padding: 48px 16px;
text-align: center;
}

View File

@@ -0,0 +1,319 @@
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { Table } from 'antd';
import type { TableColumnsType, TableProps } from 'antd';
import {
useGetMetricReductionRuleStats,
useListMetricReductionRules,
} from 'api/generated/services/metrics';
import {
ListMetricReductionRulesParams,
MetricreductionruletypesGettableReductionRuleDTO,
MetricreductionruletypesOrderDTO,
MetricreductionruletypesReductionRuleOrderByDTO,
} from 'api/generated/services/sigNoz.schemas';
import dayjs from 'dayjs';
import { useVolumeControlFeatureGate } from 'hooks/metricsExplorer/useVolumeControlFeatureGate';
import useDebounce from 'hooks/useDebounce';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { formatCompact } from '../../configUtils';
import { getLabelVerb, getMatchTypeLabel } from '../../ruleUtils';
import VolumeControlBadge from '../VolumeControlBadge';
import VolumeControlChart from '../VolumeControlChart/VolumeControlChart';
import VolumeControlConfigDrawer from '../VolumeControlConfigDrawer/VolumeControlConfigDrawer';
import VolumeControlHeader from './VolumeControlHeader/VolumeControlHeader';
import VolumeControlStats from './VolumeControlStats/VolumeControlStats';
import styles from './VolumeControlTab.module.scss';
import VolumeControlToolbar from './VolumeControlToolbar/VolumeControlToolbar';
const OrderBy = MetricreductionruletypesReductionRuleOrderByDTO;
const SortOrder = MetricreductionruletypesOrderDTO;
const DEFAULT_PAGE_SIZE = 10;
type VolumeControlTableParams = Required<
Omit<ListMetricReductionRulesParams, 'metricName'>
>;
const DEFAULT_PARAMS: VolumeControlTableParams = {
orderBy: OrderBy.reduction,
order: SortOrder.desc,
search: '',
offset: 0,
limit: DEFAULT_PAGE_SIZE,
};
function VolumeControlTab(): JSX.Element {
const { isVolumeControlEnabled, canManageVolumeControl } =
useVolumeControlFeatureGate();
const [selectedRule, setSelectedRule] =
useState<MetricreductionruletypesGettableReductionRuleDTO | null>(null);
const [params, setParams] = useState<VolumeControlTableParams>(DEFAULT_PARAMS);
const [searchInput, setSearchInput] = useState('');
const debouncedSearch = useDebounce(searchInput, 400);
useEffect(() => {
setParams((prev) =>
prev.search === debouncedSearch
? prev
: { ...prev, search: debouncedSearch, offset: 0 },
);
}, [debouncedSearch]);
const { data, isLoading } = useListMetricReductionRules(params, {
query: { enabled: isVolumeControlEnabled },
});
const { data: statsData } = useGetMetricReductionRuleStats({
query: { enabled: isVolumeControlEnabled },
});
const stats = statsData?.data;
const rules = data?.data.rules ?? [];
const total = data?.data.total ?? 0;
const sortOrderFor = useCallback(
(
key: MetricreductionruletypesReductionRuleOrderByDTO,
): 'ascend' | 'descend' | undefined => {
if (params.orderBy !== key) {
return undefined;
}
return params.order === SortOrder.desc ? 'descend' : 'ascend';
},
[params],
);
const columns: TableColumnsType<MetricreductionruletypesGettableReductionRuleDTO> =
useMemo(
() => [
{
title: 'METRIC',
dataIndex: 'metricName',
key: OrderBy.metric,
sorter: true,
sortOrder: sortOrderFor(OrderBy.metric),
render: (metricName: string): JSX.Element => (
<Typography.Text size="small" className={styles.metricNameCell}>
{metricName}
</Typography.Text>
),
},
{
title: 'STATUS',
key: 'status',
width: 130,
render: (
_value: unknown,
rule: MetricreductionruletypesGettableReductionRuleDTO,
): JSX.Element => <VolumeControlBadge rule={rule} />,
},
{
title: 'MODE',
key: 'mode',
width: 160,
render: (
_value: unknown,
rule: MetricreductionruletypesGettableReductionRuleDTO,
): JSX.Element => (
<Typography.Text size="small">
{getMatchTypeLabel(rule.matchType)}
</Typography.Text>
),
},
{
title: 'ATTRIBUTES',
key: 'attributes',
render: (
_value: unknown,
rule: MetricreductionruletypesGettableReductionRuleDTO,
): JSX.Element => (
<Typography.Text
size="small"
color="muted"
className={styles.attributesCell}
>
{getLabelVerb(rule.matchType)} {(rule.labels ?? []).join(', ') || '—'}
</Typography.Text>
),
},
{
title: 'INGESTED',
key: OrderBy.ingested_volume,
width: 130,
sorter: true,
sortOrder: sortOrderFor(OrderBy.ingested_volume),
render: (
_value: unknown,
rule: MetricreductionruletypesGettableReductionRuleDTO,
): JSX.Element => (
<Typography.Text size="small" color="muted">
{formatCompact(rule.ingestedSeries)}
</Typography.Text>
),
},
{
title: 'RETAINED',
key: OrderBy.reduced_volume,
width: 130,
sorter: true,
sortOrder: sortOrderFor(OrderBy.reduced_volume),
render: (
_value: unknown,
rule: MetricreductionruletypesGettableReductionRuleDTO,
): JSX.Element => (
<Typography.Text size="small">
{formatCompact(rule.retainedSeries)}
</Typography.Text>
),
},
{
title: 'CHANGE',
key: OrderBy.reduction,
width: 110,
sorter: true,
sortOrder: sortOrderFor(OrderBy.reduction),
render: (
_value: unknown,
rule: MetricreductionruletypesGettableReductionRuleDTO,
): JSX.Element => {
if (rule.reductionPercent <= 0) {
return (
<Typography.Text size="small" color="muted">
</Typography.Text>
);
}
return (
<Typography.Text
size="small"
weight="semibold"
color="success"
className={styles.reductionCell}
>
{Math.round(rule.reductionPercent)}%
</Typography.Text>
);
},
},
{
title: 'LAST CONFIGURED',
key: OrderBy.last_updated,
width: 240,
sorter: true,
sortOrder: sortOrderFor(OrderBy.last_updated),
render: (
_value: unknown,
rule: MetricreductionruletypesGettableReductionRuleDTO,
): JSX.Element => (
<Typography.Text size="small" color="muted">
{dayjs(rule.updatedAt).format('MMM D, YYYY · h:mm A')}
{rule.updatedBy ? ` · ${rule.updatedBy}` : ''}
</Typography.Text>
),
},
...(canManageVolumeControl
? ([
{
title: '',
key: 'action',
width: 110,
render: (
_value: unknown,
rule: MetricreductionruletypesGettableReductionRuleDTO,
): JSX.Element => (
<Button
variant="ghost"
color="secondary"
onClick={(): void => setSelectedRule(rule)}
data-testid={`volume-control-manage-${rule.metricName}`}
>
Manage
</Button>
),
},
] as TableColumnsType<MetricreductionruletypesGettableReductionRuleDTO>)
: []),
],
[canManageVolumeControl, sortOrderFor],
);
const handleTableChange: TableProps<MetricreductionruletypesGettableReductionRuleDTO>['onChange'] =
(pagination, _filters, sorter): void => {
const active = Array.isArray(sorter) ? sorter[0] : sorter;
const pageSize = pagination.pageSize ?? DEFAULT_PAGE_SIZE;
const current = pagination.current ?? 1;
setParams((prev) => ({
...prev,
orderBy: active?.order
? (active.columnKey as MetricreductionruletypesReductionRuleOrderByDTO)
: DEFAULT_PARAMS.orderBy,
order: active?.order === 'descend' ? SortOrder.desc : SortOrder.asc,
limit: pageSize,
offset: (current - 1) * pageSize,
}));
};
if (!isVolumeControlEnabled) {
return (
<div className={styles.unavailable} data-testid="volume-control-unavailable">
<Typography.Text color="muted">
Volume control is available on enterprise and cloud plans.
</Typography.Text>
</div>
);
}
return (
<div className={styles.tab} data-testid="volume-control-tab">
<VolumeControlHeader />
<VolumeControlStats
activeRules={total}
ingestedSeries={stats?.ingestedSeries ?? 0}
retainedSeries={stats?.retainedSeries ?? 0}
estimatedMonthlySavingsUsd={stats?.estimatedMonthlySavingsUsd ?? 0}
/>
<VolumeControlChart enabled={isVolumeControlEnabled} />
<VolumeControlToolbar value={searchInput} onChange={setSearchInput} />
<Table<MetricreductionruletypesGettableReductionRuleDTO>
rowKey="metricName"
loading={isLoading}
dataSource={rules}
columns={columns}
onChange={handleTableChange}
pagination={{
current: Math.floor(params.offset / params.limit) + 1,
pageSize: params.limit,
total,
showSizeChanger: false,
}}
locale={{
emptyText: (
<div className={styles.empty} data-testid="volume-control-tab-empty">
<Typography.Text color="muted">
No volume control rules yet. Open a metric and set one up to start
reducing its series volume.
</Typography.Text>
</div>
),
}}
/>
{canManageVolumeControl && selectedRule && (
<VolumeControlConfigDrawer
metricName={selectedRule.metricName}
existingRule={selectedRule}
open={!!selectedRule}
onClose={(): void => setSelectedRule(null)}
/>
)}
</div>
);
}
export default VolumeControlTab;

View File

@@ -0,0 +1,8 @@
.toolbar {
display: flex;
justify-content: flex-end;
}
.search {
max-width: 320px;
}

View File

@@ -0,0 +1,28 @@
import { Input } from 'antd';
import styles from './VolumeControlToolbar.module.scss';
interface VolumeControlToolbarProps {
value: string;
onChange: (value: string) => void;
}
function VolumeControlToolbar({
value,
onChange,
}: VolumeControlToolbarProps): JSX.Element {
return (
<div className={styles.toolbar}>
<Input
className={styles.search}
placeholder="Search metrics"
allowClear
value={value}
onChange={(e): void => onChange(e.target.value)}
data-testid="volume-control-search"
/>
</div>
);
}
export default VolumeControlToolbar;

Some files were not shown because too many files have changed in this diff Show More