Compare commits

...

7 Commits

Author SHA1 Message Date
Tushar Vats
7663ab02d4 feat(logparsingpipeline): add json_body_dual_ingestion flag for dual body ingestion (#12831)
#### Description

Server counterpart to SigNoz/signoz-otel-collector#891, which makes the
collector write each log body to both the legacy `body` column and
`body_v2` while a `json_body_dual_ingestion` flag is on.

- Adds the `json_body_dual_ingestion` feature flag (experimental, off by
default).
- Normalize is placed by read mode, so user pipelines always see the
body the explorer shows. With `use_json_body` on it stays ahead of user
pipelines. With dual ingestion alone, reads are still on the legacy
`body`, so it runs after them and only feeds `body_v2`.
- Whenever dual ingestion is on, the operator carries
`json_body_dual_ingestion: true` so it stashes the original body for the
exporter to restore. Running last under dual makes that stash the
post-pipeline body, exactly what legacy ingestion stores today.
- The pipeline preview follows the same rule and normalizes only under
`use_json_body`.

Design notes: [Normalize Operator and
Pipelines](https://app.notion.com/p/signoz/Normalize-Operator-and-Pipelines-3d7fcc6bcd19802396b9e8e817e30381),
[Dual JSON body
ingestion](https://app.notion.com/p/signoz/Dual-JSON-body-ingestion-3c6fcc6bcd198049963bce6ce2648af8)

#### Additional Information

- Collectors must run a build containing
SigNoz/signoz-otel-collector#891 before this flag is turned on. Verified
locally against v0.144.9: an older collector does not reject the unknown
operator key, it silently ignores it (operator configs are decoded with
`confmap.WithIgnoreUnused()`), runs normalize, and writes the normalized
body into the legacy `body` column until it is upgraded. No collector
release includes #891 yet.
- The exporter's own `json_body_dual_ingestion` key is collector deploy
config and is flipped together with this flag; the server does not set
it.
- Toggling either flag does not bump the pipeline config version, so
connected agents need a new pipeline save to pick up the operator or its
position. Same caveat as `use_json_body` today.
- Under dual, normalize is last among the SigNoz pipelines; custom
collector processors placed after them still see the normalized map.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-10-02 10:56:38 +00:00
Srikanth Chekuri
1643df620b feat: resolve semconv families across logs and metrics (#12870)
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
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

Phase 2 of #6143: semantic-convention families resolve on logs and
metrics, behind the `resolve_semconv_families` flag (default off), on
the storage contract of #12802.

- Registry: each member carries the scope of its own rename edges, so a
fan-out keeps one membership per target and an ambiguous name stays
literal.
- Logs and metrics families need no family code of their own. The gate
applies to every signal, and `LogicalRead` merges members through each
storage's `Read`.
- Metric-name families union the storage names in every `metric_name`
filter, and the querier reads type, temporality, and the reduced flag
across the family.
- Span-metrics labels: the metrics the processor emits, listed by name,
also read each family member with the `resource_` prefix. Requested
names are never rewritten.
- Values suggestions and related values cover every spelling of the
family.
- `deployment.environment.name` resolves on all three signals.
`db.system.name` stays off until a value-mapping reader exists.

#### Additional Information

- `pkg/semconv.Family` fields are now unexported, and `transition.go` is
removed. #12446 reads the old API and needs an update when stacked.
- A target that emits both names of a metric-name family double-counts
in `sum()` during the overlap window. Reading both names is the feature.
Pinned by a test.
- A family of metrics labels keeps the keyless contract of a single
label: no guard, no NULL group.
2026-10-01 20:28:15 +00:00
Aditya Singh
fd21f8b760 feat(bottom-strip): show the page count on the left for noz, alerts and home (#12963)
#### Description

- noz page, alert rules, home, exceptions and services now push their
count to the left of the strip. each page has its own `useXStripInfo`
hook that builds the config and publishes it.. same pattern trace
details already uses, five more times.
- the hook does the whole thing, so the page call is one line. noz and
home fetch or subscribe inside the hook so the page does not re-render
just to keep the strip current.. the rest pass values they already hold.
- alert rules and exceptions show "N of M".. first number is what is on
the page right now. both read the same two values the table hands its
own pagination, so the strip cannot disagree with the table. services,
home and noz are a plain count.
- services renders one of two tables on `use_span_metrics`, so the hook
is called from both.. the count text lives in one place either way.
- dashboards is left out for now, it already shows this on its own
strip.

#### Issues closed by this PR

Part of https://github.com/SigNoz/events-pod/issues/53
Part of https://github.com/SigNoz/events-pod/issues/55


#### Screenshots

Ai Assistant  Bottom strip

<img width="3456" height="1968" alt="image"
src="https://github.com/user-attachments/assets/0042f71b-1b56-417a-8283-af9ba9596351"
/>


Alert rules

<img width="3452" height="1992" alt="image"
src="https://github.com/user-attachments/assets/a674de13-9841-4717-89ce-a656c9df646c"
/>

Exceptions 

<img width="3456" height="1970" alt="image"
src="https://github.com/user-attachments/assets/6333ea6b-2342-46dc-985d-2855bebe4003"
/>



Services

<img width="3456" height="1996" alt="image"
src="https://github.com/user-attachments/assets/abe0c5cb-cfe3-48b6-9591-ee71e677a1a6"
/>




#### Additional Information
2026-10-01 19:30:00 +00:00
Vinicius Lourenço
434b282549 chore(dashboard-story): answer variable endpoints outside the Data state control (#13000)
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
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

To try prevent flakyness of
https://github.com/SigNoz/signoz/actions/runs/36460317287/job/109056950899#step:7:9349
2026-10-01 14:07:41 +00:00
Aditya Singh
ecdc57836c feat(bottom-strip): add right side actions and the per page left slot (#12961)
#### Description

- adds ask noz and support to the right side of the strip. support is
one button covering both floating bubbles.. only one of them ever showed
at a time, so it is pylon for users who have it and the add credit card
modal for trial users without a card. only the bubbles are hidden, noz
in the header and side nav stays for now.
- the support gating was spread across AppRoutes and two components,
each rebuilding the same feature flag + license + trial checks. pulled
into `useChatSupport`, and the add credit card modal into one shared
component.. support page still has its own copy, that goes with cleanup.
- left side is a store the strip subscribes to. pages push a node with
`useBottomStripLeft` and it clears on unmount.. strip knows nothing
about pages. falls back to the build version when nothing is set.
- the node comes from the page, so it is wrapped in an error boundary
that falls back to the version. the strip sits outside the app layout
boundary on purpose so it survives a page crash.. without this a bad
node would reach the top level one and blank the whole app.
- trace details is the first consumer, spans and errors. page passes the
values instead of the node fetching them.. the trace query key includes
the selected span so a self fetching node would refetch on every span
click. header counts stay as is.

#### Issues closed by this PR

Part of https://github.com/SigNoz/events-pod/issues/51
Part of https://github.com/SigNoz/events-pod/issues/52

#### Screen recording


https://github.com/user-attachments/assets/12d476e5-a486-4903-974e-e0964ffa3e3c



#### Additional Information

- no loading state or error handling on the count yet and the numbers
are raw.. doing all three in one pass once the rest of the pages are
wired.
- no analytics here, that comes with the analytics ticket.
- pylon and the add card flow were tested locally with temp code. the
pylon chat window offset and bubble hiding still need a pylon enabled
tenant to verify.
- the conversation history popover for the right side is still to do, so
6075 stays open.
2026-10-01 13:39:35 +00:00
Vikrant Gupta
572345be63 feat(authz): enable FGA for users and reset password tokens (#13020)
#### Description

- Moves the users API off the legacy `AdminAccess` gate onto
`CheckResources` + `ResourceDef`s; `me` and anonymous password flows
stay `OpenAccess`.
- Invite checks `role:attach` per requested role; an empty role list
resolves to no link, so the sibling def skips the check.
- Migration `131_add_user_tuples` backfills admin `user` and
`factor-password` tuples for existing orgs.

#### Issues closed by this PR

Closes: SigNoz/keystone-pod#38
2026-10-01 11:26:18 +00:00
Nikhil Soni
bcf96f2816 feat(traces-qb): gate JSON span attribute reads behind a feature flag (#12966)
#### Description

- The new feature flag `use_trace_attributes_json` (default off) now
gates the JSON columns in the traces `getColumn`, alongside the
evolution entry.
- `SelectEvolutionsForColumns` now ignores evolutions of columns the
mapper didn't return instead of erroring, so a flag-off attribute
resolves to its map even though the key carries the JSON evolution.

Part of https://github.com/SigNoz/signoz/pull/12966

#### Additional Information

- Evolution entry migration: SigNoz/signoz-otel-collector#928
- Original QB PR: #4781
2026-10-01 10:21:10 +00:00
133 changed files with 6851 additions and 1882 deletions

View File

@@ -67,7 +67,7 @@ jobs:
with:
go-version: "1.24"
- name: check-semconv-generated-files
run: go run ./scripts/semconv -check
run: make semconv-check
build:
if: |
github.event_name == 'merge_group' ||

View File

@@ -262,6 +262,10 @@ py-clean: ## Clear all pycache and pytest cache from tests directory recursively
semconv-generate: ## Regenerate semantic-convention families for Go and TypeScript
@go run ./scripts/semconv
.PHONY: semconv-check
semconv-check: ## Fail if the generated semantic-convention files are stale
@go run ./scripts/semconv -check
.PHONY: gen-mocks
gen-mocks:
@echo ">> Generating mocks"

View File

@@ -21186,9 +21186,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- role:read
- tokenizer:
- ADMIN
- role:read
summary: Get users by role id
tags:
- users
@@ -25774,9 +25774,11 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- user:attach
- role:attach
- tokenizer:
- ADMIN
- user:attach
- role:attach
summary: Create user role
tags:
- users
@@ -25827,9 +25829,11 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- user:detach
- role:detach
- tokenizer:
- ADMIN
- user:detach
- role:detach
summary: Delete user role
tags:
- users
@@ -25891,9 +25895,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- user:read
- tokenizer:
- ADMIN
- user:read
summary: Get user role
tags:
- users
@@ -25940,9 +25944,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- user:list
- tokenizer:
- ADMIN
- user:list
summary: List users v2
tags:
- users
@@ -26003,9 +26007,13 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- user:create
- user:attach
- role:attach
- tokenizer:
- ADMIN
- user:create
- user:attach
- role:attach
summary: Create user
tags:
- users
@@ -26050,9 +26058,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- user:delete
- tokenizer:
- ADMIN
- user:delete
summary: Delete user
tags:
- users
@@ -26108,9 +26116,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- user:read
- tokenizer:
- ADMIN
- user:read
summary: Get user by user id
tags:
- users
@@ -26165,9 +26173,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- user:update
- tokenizer:
- ADMIN
- user:update
summary: Update user v2
tags:
- users
@@ -26224,9 +26232,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- factor-password:list
- tokenizer:
- ADMIN
- factor-password:list
summary: Get reset password token for a user
tags:
- users
@@ -26290,9 +26298,11 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- factor-password:create
- user:attach
- tokenizer:
- ADMIN
- factor-password:create
- user:attach
summary: Create or regenerate reset password token for a user
tags:
- users
@@ -26351,9 +26361,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- user:read
- tokenizer:
- ADMIN
- user:read
summary: Get user roles
tags:
- users
@@ -26667,10 +26677,7 @@ paths:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
- tokenizer: []
summary: Updates my password
tags:
- users

View File

@@ -121,7 +121,7 @@ The pieces:
- **`ResourceDef`** — declares the resource, verb, audit category, how to extract the instance ID, and how to turn that ID into selectors. ID extractors live in [pkg/types/coretypes/extractor.go](/pkg/types/coretypes/extractor.go): `PathParam("id")`, `BodyJSONPath("data.id")`, `BodyJSONArray("ids")`, and `ResponseJSONPath("data.id")` for IDs only known after the handler runs (e.g. `create`).
- **`SecuritySchemes`** — advertises the required scope (`resource.Scope(verb)`, e.g. `serviceaccount:create`) in the OpenAPI spec.
For routes that link two resources, use `AttachDetachSiblingResourceDef` (both sides are authz-checked, e.g. attaching a role to a service account requires `attach` on **both** the service account and the role). For parent-child routes (e.g. creating an API key under a service account), both sides are checked too, but with different verbs: declare a `BasicResourceDef` checking the child with `create`/`delete`, alongside an `AttachDetachParentChildResourceDef` checking the parent with `attach`/`detach` (within that def the child is only recorded for audit) — see the `/api/v1/service_accounts/{id}/keys` route in [pkg/apiserver/signozapiserver/serviceaccount.go](/pkg/apiserver/signozapiserver/serviceaccount.go).
For routes that link two resources, use `AttachDetachSiblingResourceDef` (both sides are authz-checked, e.g. attaching a role to a service account requires `attach` on **both** the service account and the role). When a side's ids come from a list extractor (`BodyJSONArray` or a custom `ResourceIDsExtractor`) and that list resolves to nothing at request time, there is nothing to link: the def resolves to no resources, so no check runs and no audit event is emitted (e.g. inviting a user with an empty `userRoles`). A single-id side (`OneID`) always resolves to exactly one id and fails closed when it is empty. For parent-child routes (e.g. creating an API key under a service account), both sides are checked too, but with different verbs: declare a `BasicResourceDef` checking the child with `create`/`delete`, alongside an `AttachDetachParentChildResourceDef` checking the parent with `attach`/`detach` (within that def the child is only recorded for audit) — see the `/api/v1/service_accounts/{id}/keys` route in [pkg/apiserver/signozapiserver/serviceaccount.go](/pkg/apiserver/signozapiserver/serviceaccount.go).
Prefer `CheckResources` with a `ResourceDef` for anything resource-shaped. The older coarse gates `ViewAccess`/`EditAccess`/`AdminAccess` only check "does the caller hold one of these roles" and give up per-resource granularity; `OpenAccess` performs no authorization (authentication still applies); `CheckWithoutClaims` serves anonymous routes such as public dashboards.

View File

@@ -84,9 +84,9 @@ A storage answers four questions and nothing else:
| WhenAbsent | Absent row reads | Positive filter | Raw select | Multi-candidate column | Field keys |
|---|---|---|---|---|---|
| `AlwaysPresent` | a real value | no guard | no guard | no branch, ends the candidate list | table columns |
| `AbsentIsSentinel` | `''`, 0, false, and that is not a value | exists guard | exists guard | presence branch | map attributes, cast JSON paths, string families |
| `AbsentIsSentinel` | `''`, 0, false, and that is not a value | exists guard | exists guard | presence branch | map attributes, cast JSON paths, string families of such members |
| `AbsentIsNull` | NULL | no guard | no guard | presence branch | multi-era folds, body JSON paths, numeric families |
| `AbsentIsValue` | `''`, and that is the keyless contract | no guard | no guard | no presence branch | metrics labels, rule state history labels |
| `AbsentIsValue` | `''`, and that is the keyless contract | no guard | no guard | no presence branch | metrics labels, rule state history labels, and families of such members |
### The generic layer
@@ -109,7 +109,7 @@ The functions, from the outside in:
| `RejectsBodyFunction(traits, operator)` | Runs before resolution. A storage without body functions (`has`, `hasAny`, `hasAll`, `hasToken`, `search`) errors. The fingerprint side of a split skips the term, because the main query evaluates it. After resolution, `Condition` errors when `has`, `hasAny`, `hasAll`, or `hasToken` lands on a map-backed key (resource, attribute, scope), before the split can drop it. |
| `SharedCondition(...)` | The `Compile` of every storage without its own condition language: `LogicalRead`, the shared data-type collision cast, `OperatorCondition`, then the guard rule. |
| `OperatorCondition(...)` | The operator switch over an already cast read. A storage with its own cast policy composes with it. |
| `LogicalRead(...)` | The only place family expressions are built. A single-member field reads through its member. A family merges the member reads, current member first: `COALESCE(NULLIF(m1, ''), NULLIF(m2, ''), '')` for strings, `multiIf` with a NULL tail for numbers. It ORs the member presence tests. A row without any member reads what the tail of the merge reads. A member with a value map reads through `TransformRead`. `NOT EXISTS` is the read's `Absence`, the storage's own negated form. |
| `LogicalRead(...)` | The only place family expressions are built. A single-member field reads through its member. A family merges the member reads, current member first: `COALESCE(NULLIF(m1, ''), NULLIF(m2, ''), '')` for strings, `multiIf` with a NULL tail for numbers. It ORs the member presence tests. A row without any member reads what the tail of the merge reads. When every member reads its sentinel as a value, so does the family. A member with a value map reads through `TransformRead`. `NOT EXISTS` is the read's `Absence`, the storage's own negated form. |
### A resolved key

View File

@@ -12,13 +12,14 @@ import { CmdKPalette } from 'components/cmdKPalette/cmdKPalette';
import NotFound from 'components/NotFound';
import { ShiftHoldOverlayController } from 'components/ShiftOverlay/ShiftHoldOverlayController';
import Spinner from 'components/Spinner';
import { FeatureKeys } from 'constants/features';
import { LOCALSTORAGE } from 'constants/localStorage';
import ROUTES from 'constants/routes';
import AppLayout from 'container/AppLayout';
import Hex from 'crypto-js/enc-hex';
import HmacSHA256 from 'crypto-js/hmac-sha256';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
import { ChatSupportState, useChatSupport } from 'hooks/useChatSupport';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { StatusCodes } from 'http-status-codes';
@@ -58,7 +59,6 @@ function App(): JSX.Element {
isFetchingActiveLicense,
activeLicenseFetchError,
userFetchError,
featureFlagsFetchError,
isLoggedIn: isLoggedInState,
featureFlags,
org,
@@ -66,6 +66,8 @@ function App(): JSX.Element {
} = useAppContext();
const [routes, setRoutes] = useState<AppRoutes[]>(defaultRoutes);
const isAIAssistantEnabled = useIsAIAssistantEnabled();
const isSavedViewEnabled = useSavedViewEnabled();
const chatSupport = useChatSupport();
const { hostname } = window.location;
const [pathname, setPathname] = useState(history.location.pathname);
@@ -253,7 +255,9 @@ function App(): JSX.Element {
}, [isDarkMode]);
useEffect(() => {
// The bottom strip carries Support, so the floating bubble goes entirely.
if (
isSavedViewEnabled ||
pathname === ROUTES.ONBOARDING ||
pathname.startsWith('/public/dashboard/') ||
pathname === '/ai-assistant' ||
@@ -263,71 +267,32 @@ function App(): JSX.Element {
} else {
window.Pylon?.('showChatBubble');
}
}, [pathname]);
}, [pathname, isSavedViewEnabled]);
// eslint-disable-next-line sonarjs/cognitive-complexity
// Identity for the Pylon widget. Whether this user gets Pylon at all is
// `useChatSupport`'s call — this only fills in who they are.
useEffect(() => {
// feature flag shouldn't be loading and featureFlags or fetchError any one of this should be true indicating that req is complete
// licenses should also be present. there is no check for licenses for loading and error as that is mandatory if not present then routing
// to something went wrong which would ideally need a reload.
if (
!isFetchingFeatureFlags &&
(featureFlags || featureFlagsFetchError) &&
activeLicense &&
trialInfo
) {
let isChatSupportEnabled = false;
let isPremiumSupportEnabled = false;
if (featureFlags && featureFlags.length > 0) {
isChatSupportEnabled =
featureFlags.find((flag) => flag.name === FeatureKeys.CHAT_SUPPORT)
?.active || false;
isPremiumSupportEnabled =
featureFlags.find((flag) => flag.name === FeatureKeys.PREMIUM_SUPPORT)
?.active || false;
}
const showAddCreditCardModal =
!isPremiumSupportEnabled && !trialInfo?.trialConvertedToSubscription;
if (
isLoggedInState &&
isChatSupportEnabled &&
!showAddCreditCardModal &&
(isCloudUser || isEnterpriseSelfHostedUser) &&
window.signozBootData?.settings?.pylon?.enabled
) {
const email = user.email || '';
const secret = window.signozBootData?.settings?.pylon?.identitySecret || '';
let emailHash = '';
if (email && secret) {
emailHash = HmacSHA256(email, Hex.parse(secret)).toString(Hex);
}
window.pylon = {
chat_settings: {
app_id: window.signozBootData?.settings?.pylon?.appId,
email: user.email,
name: user.displayName || user.email,
email_hash: emailHash,
},
};
}
if (chatSupport !== ChatSupportState.Pylon) {
return;
}
}, [
isLoggedInState,
user,
pathname,
trialInfo?.trialConvertedToSubscription,
featureFlags,
isFetchingFeatureFlags,
featureFlagsFetchError,
activeLicense,
trialInfo,
isCloudUser,
isEnterpriseSelfHostedUser,
]);
const email = user.email || '';
const secret = window.signozBootData?.settings?.pylon?.identitySecret || '';
let emailHash = '';
if (email && secret) {
emailHash = HmacSHA256(email, Hex.parse(secret)).toString(Hex);
}
window.pylon = {
chat_settings: {
app_id: window.signozBootData?.settings?.pylon?.appId,
email: user.email,
name: user.displayName || user.email,
email_hash: emailHash,
},
};
}, [chatSupport, user]);
useEffect(() => {
if (!isFetchingUser && isCloudUser && user && user.email) {

View File

@@ -0,0 +1,103 @@
import { useMutation } from 'react-query';
import { Button, Modal } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { createSubscription } from 'api/generated/services/subscriptions';
import type { CreateSubscription201 } from 'api/generated/services/sigNoz.schemas';
import { useNotifications } from 'hooks/useNotifications';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { SubscriptionCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
import { CreditCard, X } from '@signozhq/icons';
import APIError from 'types/api/error';
import { getBaseUrl } from 'utils/basePath';
interface AddCreditCardModalProps {
open: boolean;
onClose: () => void;
onAddCreditCard?: () => void;
}
/**
* Shown to trial users who have not added a card, in place of chat support.
* Submitting creates the subscription and opens the returned billing URL.
*/
function AddCreditCardModal({
open,
onClose,
onAddCreditCard,
}: AddCreditCardModalProps): JSX.Element {
const { notifications } = useNotifications();
const handleBillingOnSuccess = (data: CreateSubscription201): void => {
if (data?.data?.redirectURL) {
const newTab = document.createElement('a');
newTab.href = data.data.redirectURL;
newTab.target = '_blank';
newTab.rel = 'noopener noreferrer';
newTab.click();
}
};
const handleBillingOnError = (error: APIError): void => {
notifications.error({
message: error.getErrorCode(),
description: error.getErrorMessage(),
});
};
const { mutate: updateCreditCard, isLoading: isLoadingBilling } = useMutation(
createSubscription,
{ onSuccess: handleBillingOnSuccess, onError: handleBillingOnError },
);
const handleAddCreditCard = (): void => {
onAddCreditCard?.();
updateCreditCard({ url: getBaseUrl() });
};
return (
<Modal
className="add-credit-card-modal"
title={<span className="title">Add Credit Card for Chat Support</span>}
open={open}
closable
onCancel={onClose}
destroyOnClose
footer={[
<Button
key="cancel"
onClick={onClose}
className="cancel-btn"
icon={<X size={16} />}
>
Cancel
</Button>,
<AuthZTooltip
key="submit"
checks={[SubscriptionCreatePermission]}
withPortal={false}
>
<Button
type="primary"
icon={<CreditCard size={16} />}
size="middle"
loading={isLoadingBilling}
disabled={isLoadingBilling}
onClick={handleAddCreditCard}
className="add-credit-card-btn"
>
Add Credit Card
</Button>
</AuthZTooltip>,
]}
>
<Typography.Text className="add-credit-card-text">
You&apos;re currently on <span className="highlight-text">Trial plan</span>.
Add a credit card to access SigNoz chat support to your workspace.
</Typography.Text>
</Modal>
);
}
AddCreditCardModal.defaultProps = { onAddCreditCard: undefined };
export default AddCreditCardModal;

View File

@@ -0,0 +1,9 @@
import { rest } from 'msw';
const SUBSCRIPTIONS = 'http://localhost/api/v1/subscriptions';
export const createSubscriptionHandlers = [
rest.post(SUBSCRIPTIONS, (_req, res, ctx) =>
res(ctx.status(200), ctx.json({ data: { redirectURL: '' } })),
),
];

View File

@@ -0,0 +1,54 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Button } from '@signozhq/ui/button';
import { screen, userEvent, within } from 'storybook/test';
import { withCanvas } from '@/storybook/decorators/withCanvas';
import { createSubscriptionHandlers } from './AddCreditCardModal.stories.mocks';
import AddCreditCardModal from '../AddCreditCardModal';
/** Stands in for the surfaces that open it: the strip's Support button, and
* `LaunchChatSupport` in the custom domain and onboarding flows. */
function ModalFixture(): JSX.Element {
const [open, setOpen] = useState(false);
return (
<>
<Button
data-testid="open-add-credit-card"
onClick={(): void => setOpen(true)}
>
Contact support
</Button>
<AddCreditCardModal open={open} onClose={(): void => setOpen(false)} />
</>
);
}
const meta = {
title: 'Components/Add Credit Card Modal',
component: ModalFixture,
tags: ['play'],
decorators: [withCanvas({ maxWidth: 400 })],
parameters: {
msw: { handlers: createSubscriptionHandlers },
},
} satisfies Meta<typeof ModalFixture>;
export default meta;
type Story = StoryObj<typeof meta>;
/** What a trial user without a card sees in place of chat support. */
export const Default: Story = {
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
within(canvasElement).getByTestId('open-add-credit-card'),
);
await screen.findByRole('dialog', {
name: /Add Credit Card for Chat Support/i,
});
},
};

View File

@@ -1,63 +1,15 @@
import { useState } from 'react';
import { useMutation } from 'react-query';
import { useLocation } from 'react-router-dom';
import { Button, Modal } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { Button } from 'antd';
import AddCreditCardModal from 'components/AddCreditCardModal/AddCreditCardModal';
import logEvent from 'api/common/logEvent';
import { createSubscription } from 'api/generated/services/subscriptions';
import type { CreateSubscription201 } from 'api/generated/services/sigNoz.schemas';
import { useNotifications } from 'hooks/useNotifications';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { SubscriptionCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
import { CreditCard, MessageSquareText, X } from '@signozhq/icons';
import APIError from 'types/api/error';
import { getBaseUrl } from 'utils/basePath';
import { MessageSquareText } from '@signozhq/icons';
export default function ChatSupportGateway(): JSX.Element {
const { notifications } = useNotifications();
const [isAddCreditCardModalOpen, setIsAddCreditCardModalOpen] =
useState(false);
const handleBillingOnSuccess = (data: CreateSubscription201): void => {
if (data?.data?.redirectURL) {
const newTab = document.createElement('a');
newTab.href = data.data.redirectURL;
newTab.target = '_blank';
newTab.rel = 'noopener noreferrer';
newTab.click();
}
};
const handleBillingOnError = (error: APIError): void => {
notifications.error({
message: error.getErrorCode(),
description: error.getErrorMessage(),
});
};
const { mutate: updateCreditCard, isLoading: isLoadingBilling } = useMutation(
createSubscription,
{
onSuccess: (data) => {
handleBillingOnSuccess(data);
},
onError: handleBillingOnError,
},
);
const { pathname } = useLocation();
const handleAddCreditCard = (): void => {
logEvent('Add Credit card modal: Clicked', {
source: `chat support icon`,
page: pathname,
});
updateCreditCard({
url: getBaseUrl(),
});
};
return (
<>
<div className="chat-support-gateway">
@@ -76,47 +28,16 @@ export default function ChatSupportGateway(): JSX.Element {
</Button>
</div>
{/* Add Credit Card Modal */}
<Modal
className="add-credit-card-modal"
title={<span className="title">Add Credit Card for Chat Support</span>}
<AddCreditCardModal
open={isAddCreditCardModalOpen}
closable
onCancel={(): void => setIsAddCreditCardModalOpen(false)}
destroyOnClose
footer={[
<Button
key="cancel"
onClick={(): void => setIsAddCreditCardModalOpen(false)}
className="cancel-btn"
icon={<X size={16} />}
>
Cancel
</Button>,
<AuthZTooltip
key="submit"
checks={[SubscriptionCreatePermission]}
withPortal={false}
>
<Button
type="primary"
icon={<CreditCard size={16} />}
size="middle"
loading={isLoadingBilling}
disabled={isLoadingBilling}
onClick={handleAddCreditCard}
className="add-credit-card-btn"
>
Add Credit Card
</Button>
</AuthZTooltip>,
]}
>
<Typography.Text className="add-credit-card-text">
You&apos;re currently on <span className="highlight-text">Trial plan</span>
. Add a credit card to access SigNoz chat support to your workspace.
</Typography.Text>
</Modal>
onClose={(): void => setIsAddCreditCardModalOpen(false)}
onAddCreditCard={(): void => {
logEvent('Add Credit card modal: Clicked', {
source: `chat support icon`,
page: pathname,
});
}}
/>
</>
);
}

View File

@@ -1,22 +1,14 @@
import { useMemo, useState } from 'react';
import { useMutation } from 'react-query';
import { useLocation } from 'react-router-dom';
import { Button, Modal, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { Button, Tooltip } from 'antd';
import AddCreditCardModal from 'components/AddCreditCardModal/AddCreditCardModal';
import logEvent from 'api/common/logEvent';
import { createSubscription } from 'api/generated/services/subscriptions';
import type { CreateSubscription201 } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import { FeatureKeys } from 'constants/features';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useNotifications } from 'hooks/useNotifications';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { SubscriptionCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
import { defaultTo } from 'lodash-es';
import { CircleHelp, CreditCard, X } from '@signozhq/icons';
import { CircleHelp } from '@signozhq/icons';
import { useAppContext } from 'providers/App/App';
import APIError from 'types/api/error';
import { getBaseUrl } from 'utils/basePath';
import './LaunchChatSupport.styles.scss';
@@ -41,7 +33,6 @@ function LaunchChatSupport({
chatMessageDisabled = false,
}: LaunchChatSupportProps): JSX.Element | null {
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
const { notifications } = useNotifications();
const {
trialInfo,
featureFlags,
@@ -119,43 +110,12 @@ function LaunchChatSupport({
}
};
const handleBillingOnSuccess = (data: CreateSubscription201): void => {
if (data?.data?.redirectURL) {
const newTab = document.createElement('a');
newTab.href = data.data.redirectURL;
newTab.target = '_blank';
newTab.rel = 'noopener noreferrer';
newTab.click();
}
};
const handleBillingOnError = (error: APIError): void => {
notifications.error({
message: error.getErrorCode(),
description: error.getErrorMessage(),
});
};
const { mutate: updateCreditCard, isLoading: isLoadingBilling } = useMutation(
createSubscription,
{
onSuccess: (data) => {
handleBillingOnSuccess(data);
},
onError: handleBillingOnError,
},
);
const handleAddCreditCard = (): void => {
logEvent('Add Credit card modal: Clicked', {
source: `facing issues button`,
page: pathname,
...attributes,
});
updateCreditCard({
url: getBaseUrl(),
});
};
return isCloudUserVal && isChatSupportEnabled ? ( // Note: we would need to move this condition to license based in future
@@ -175,47 +135,11 @@ function LaunchChatSupport({
</Button>
</Tooltip>
{/* Add Credit Card Modal */}
<Modal
className="add-credit-card-modal"
title={<span className="title">Add Credit Card for Chat Support</span>}
<AddCreditCardModal
open={isAddCreditCardModalOpen}
closable
onCancel={(): void => setIsAddCreditCardModalOpen(false)}
destroyOnClose
footer={[
<Button
key="cancel"
onClick={(): void => setIsAddCreditCardModalOpen(false)}
className="cancel-btn"
icon={<X size={16} />}
>
Cancel
</Button>,
<AuthZTooltip
key="submit"
checks={[SubscriptionCreatePermission]}
withPortal={false}
>
<Button
type="primary"
icon={<CreditCard size={16} />}
size="middle"
loading={isLoadingBilling}
disabled={isLoadingBilling}
onClick={handleAddCreditCard}
className="add-credit-card-btn"
>
Add Credit Card
</Button>
</AuthZTooltip>,
]}
>
<Typography.Text className="add-credit-card-text">
You&apos;re currently on <span className="highlight-text">Trial plan</span>
. Add a credit card to access SigNoz chat support to your workspace.
</Typography.Text>
</Modal>
onClose={(): void => setIsAddCreditCardModalOpen(false)}
onAddCreditCard={handleAddCreditCard}
/>
</div>
) : null;
}

View File

@@ -1,32 +1,82 @@
// Code generated by scripts/semconv. DO NOT EDIT.
export type SemconvFamily = {
readonly current: string;
readonly old: readonly string[];
readonly kind: 'attribute' | 'metric';
// An empty contexts/signals/applyToMetrics array places no constraint on
// that axis.
export type SemconvMember = {
readonly name: string;
readonly contexts: readonly string[];
readonly signals: readonly string[];
readonly applyToMetrics: readonly string[];
};
export type SemconvFamily = {
readonly current: string;
readonly kind: 'attribute' | 'metric';
readonly members: readonly SemconvMember[];
readonly contexts: readonly string[];
readonly signals: readonly string[];
readonly valueMap: Readonly<Record<string, string>>;
};
export const SEMCONV_FAMILIES: readonly SemconvFamily[] = [
{
current: 'db.system.name',
old: ['db.system'],
kind: 'attribute',
current: 'container.cpu.usage',
kind: 'metric',
members: [
{
name: 'container.cpu.utilization',
contexts: [],
signals: [],
applyToMetrics: [],
},
],
contexts: [],
signals: [],
applyToMetrics: [],
valueMap: {},
},
{
current: 'deployment.environment.name',
old: ['deployment.environment'],
kind: 'attribute',
members: [
{
name: 'deployment.environment',
contexts: [],
signals: [],
applyToMetrics: [],
},
],
contexts: ['attribute', 'resource'],
signals: ['logs', 'metrics', 'traces'],
valueMap: {},
},
{
current: 'k8s.node.cpu.usage',
kind: 'metric',
members: [
{
name: 'k8s.node.cpu.utilization',
contexts: [],
signals: [],
applyToMetrics: [],
},
],
contexts: [],
signals: [],
valueMap: {},
},
{
current: 'k8s.pod.cpu.usage',
kind: 'metric',
members: [
{
name: 'k8s.pod.cpu.utilization',
contexts: [],
signals: [],
applyToMetrics: [],
},
],
contexts: [],
signals: [],
applyToMetrics: [],
valueMap: {},
},
] as const;

View File

@@ -0,0 +1,53 @@
import { renderHook } from '@testing-library/react';
import { useBottomStripStore } from 'container/BottomStrip/store/useBottomStripStore';
import { StripItemKind } from 'container/BottomStrip/types';
import { useExceptionsStripInfo } from '../useExceptionsStripInfo';
describe('useExceptionsStripInfo', () => {
beforeEach(() => {
useBottomStripStore.setState({ left: null, ownerId: null });
});
it('shows the rows on the page against the total', () => {
renderHook(() => useExceptionsStripInfo({ shownCount: 25, totalCount: 500 }));
expect(useBottomStripStore.getState().left).toMatchObject([
{ kind: StripItemKind.Text, text: '25 of 500 exceptions' },
]);
});
it('still says n of m when the whole list fits on one page', () => {
renderHook(() => useExceptionsStripInfo({ shownCount: 42, totalCount: 42 }));
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
text: '42 of 42 exceptions',
});
});
it('says exception, not exceptions, when there is one', () => {
renderHook(() => useExceptionsStripInfo({ shownCount: 1, totalCount: 1 }));
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
text: '1 of 1 exception',
});
});
it('shows zero before the counts land', () => {
renderHook(() => useExceptionsStripInfo({ shownCount: 0, totalCount: 0 }));
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
text: '0 of 0 exceptions',
});
});
it('clears the strip when the page unmounts', () => {
const { unmount } = renderHook(() =>
useExceptionsStripInfo({ shownCount: 25, totalCount: 500 }),
);
unmount();
expect(useBottomStripStore.getState().left).toBeNull();
});
});

View File

@@ -38,6 +38,7 @@ import { Exception, PayloadProps } from 'types/api/errors/getAll';
import { GlobalReducer } from 'types/reducer/globalTime';
import { FilterDropdownExtendsProps } from './types';
import { useExceptionsStripInfo } from './useExceptionsStripInfo';
import {
extractFilterValues,
getDefaultFilterValue,
@@ -160,6 +161,11 @@ function AllErrors(): JSX.Element {
},
]);
useExceptionsStripInfo({
shownCount: data?.payload?.length ?? 0,
totalCount: errorCountResponse.data?.payload ?? 0,
});
const isFetching = isErrorsFetching || errorCountResponse.isFetching;
useEffect(() => {
setIsFetching(isFetching);

View File

@@ -0,0 +1,26 @@
import { useMemo } from 'react';
import { useBottomStrip } from 'container/BottomStrip/useBottomStrip';
import { type StripItem, StripItemKind } from 'container/BottomStrip/types';
import { pluralize } from 'utils/pluralize';
interface UseExceptionsStripInfoArgs {
shownCount: number;
totalCount: number;
}
export function useExceptionsStripInfo({
shownCount,
totalCount,
}: UseExceptionsStripInfoArgs): void {
const items = useMemo<StripItem[]>(
() => [
{
kind: StripItemKind.Text,
text: `${shownCount} of ${pluralize(totalCount, 'exception')}`,
},
],
[shownCount, totalCount],
);
useBottomStrip(items);
}

View File

@@ -35,7 +35,6 @@ import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { SubscriptionManagePermissions } from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
import { MIN_ACCOUNT_AGE_FOR_CHANGELOG } from 'constants/changelog';
import { Events } from 'constants/events';
import { FeatureKeys } from 'constants/features';
import { LOCALSTORAGE } from 'constants/localStorage';
import ROUTES from 'constants/routes';
import { GlobalShortcuts } from 'constants/shortcuts/globalShortcuts';
@@ -52,6 +51,7 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { useNotifications } from 'hooks/useNotifications';
import { ChatSupportState, useChatSupport } from 'hooks/useChatSupport';
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
import useTabVisibility from 'hooks/useTabFocus';
import history from 'lib/history';
@@ -103,9 +103,6 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
trialInfo,
activeLicense,
isFetchingActiveLicense,
featureFlags,
isFetchingFeatureFlags,
featureFlagsFetchError,
userPreferences,
isFetchingUserPreferences,
updateChangelog,
@@ -492,42 +489,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
}
}, [isDarkMode]);
const showAddCreditCardModal = useMemo(() => {
if (
!isFetchingFeatureFlags &&
(featureFlags || featureFlagsFetchError) &&
activeLicense &&
trialInfo
) {
let isChatSupportEnabled = false;
let isPremiumSupportEnabled = false;
if (featureFlags && featureFlags.length > 0) {
isChatSupportEnabled =
featureFlags.find((flag) => flag.name === FeatureKeys.CHAT_SUPPORT)
?.active || false;
isPremiumSupportEnabled =
featureFlags.find((flag) => flag.name === FeatureKeys.PREMIUM_SUPPORT)
?.active || false;
}
return (
isLoggedIn &&
!isPremiumSupportEnabled &&
isChatSupportEnabled &&
!trialInfo?.trialConvertedToSubscription &&
isCloudUserVal
);
}
return false;
}, [
featureFlags,
featureFlagsFetchError,
isCloudUserVal,
isFetchingFeatureFlags,
isLoggedIn,
activeLicense,
trialInfo,
]);
const chatSupport = useChatSupport();
// Listen for API warnings
const handleWarning = (
@@ -885,12 +847,16 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
)}
</Flex>
{showAddCreditCardModal && <ChatSupportGateway />}
{chatSupport === ChatSupportState.NeedsCard && !isSavedViewEnabled && (
<ChatSupportGateway />
)}
{showChangelogModal && changelog && (
<ChangelogModal changelog={changelog} onClose={toggleChangelogModal} />
)}
<Toaster />
<Toaster
offset={{ bottom: 'calc(var(--bottom-strip-height, 0px) + 24px)' }}
/>
</Layout>
</TooltipProvider>
);

View File

@@ -0,0 +1,36 @@
.askNoz {
display: flex;
align-items: center;
flex-shrink: 0;
}
.badge {
flex-shrink: 0;
display: inline-flex;
line-height: 0;
color: var(--accent-primary);
}
.pulseDot {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 0;
animation: askNozDotPulse 1.5s ease-in-out infinite;
transform: scale(0.8);
margin-right: -12px;
}
@keyframes askNozDotPulse {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.35;
transform: scale(0.82);
}
}

View File

@@ -0,0 +1,76 @@
import { useLocation } from 'react-router-dom';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import Noz from 'components/Noz/Noz';
import { NOZ_TOOLTIP_TITLE } from 'components/Noz/Noz.constants';
import { selectPendingUserInputStreamCount } from 'container/AIAssistant/store/pendingInputSelectors';
import {
openAIAssistant,
useAIAssistantStore,
} from 'container/AIAssistant/store/useAIAssistantStore';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import ROUTES from 'constants/routes';
import { Dot } from '@signozhq/icons';
import styles from './AskNoz.module.scss';
/**
* Opens the Noz drawer, matching the header entry it replaces. Cmd+K opens the
* modal instead; this is deliberately the drawer.
*
* Carries the header's pending badge: when Noz is blocked on the user
* (`awaiting_approval` / `awaiting_clarification`) a dot pulses. Without it,
* hiding the header button would remove a notification rather than move it.
*/
function AskNoz(): JSX.Element | null {
const { pathname } = useLocation();
const isAIAssistantEnabled = useIsAIAssistantEnabled();
const isDrawerOpen = useAIAssistantStore((state) => state.isDrawerOpen);
const isModalOpen = useAIAssistantStore((state) => state.isModalOpen);
const pendingUserInputCount = useAIAssistantStore(
selectPendingUserInputStreamCount,
);
// Noz is already on screen in the modal, so the "needs you" dot would be noise.
const showPendingBadge = pendingUserInputCount > 0 && !isModalOpen;
// The drawer does not render on the Noz full page, so the button would be inert.
const isAIAssistantPage = pathname.startsWith(ROUTES.AI_ASSISTANT_BASE);
if (!isAIAssistantEnabled || isDrawerOpen || isAIAssistantPage) {
return null;
}
return (
<div className={styles.askNoz} data-testid="bottom-strip-ask-noz">
{showPendingBadge && (
<span className={styles.badge} aria-hidden>
<span className={styles.pulseDot}>
<Dot size={36} />
</span>
</span>
)}
<TooltipSimple title={NOZ_TOOLTIP_TITLE}>
<Button
variant="ghost"
color="secondary"
size="sm"
className="noz-wave"
prefix={<Noz size={16} />}
onClick={(): void => openAIAssistant()}
aria-label={
showPendingBadge
? `Ask Noz, ${pendingUserInputCount} ${
pendingUserInputCount === 1 ? 'action needs' : 'actions need'
} your response`
: 'Ask Noz'
}
>
Ask Noz
</Button>
</TooltipSimple>
</div>
);
}
export default AskNoz;

View File

@@ -0,0 +1,133 @@
import { fireEvent } from '@testing-library/react';
import { openAIAssistant } from 'container/AIAssistant/store/useAIAssistantStore';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { render } from 'tests/test-utils';
import AskNoz from '../AskNoz';
jest.mock('hooks/useIsAIAssistantEnabled');
jest.mock('container/AIAssistant/store/useAIAssistantStore', () => ({
openAIAssistant: jest.fn(),
useAIAssistantStore: jest.fn(),
}));
const mockEnabled = useIsAIAssistantEnabled as jest.MockedFunction<
typeof useIsAIAssistantEnabled
>;
const mockOpen = openAIAssistant as jest.MockedFunction<typeof openAIAssistant>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { useAIAssistantStore } = jest.requireMock(
'container/AIAssistant/store/useAIAssistantStore',
) as { useAIAssistantStore: jest.Mock };
/** The component reads the store through three separate selector calls. */
function mockStore({
isDrawerOpen = false,
isModalOpen = false,
pendingCount = 0,
}: {
isDrawerOpen?: boolean;
isModalOpen?: boolean;
pendingCount?: number;
} = {}): void {
const state = { isDrawerOpen, isModalOpen, streams: {} };
useAIAssistantStore.mockImplementation((selector: (s: unknown) => unknown) => {
const picked = selector(state);
// `selectPendingUserInputStreamCount` walks `streams`, which is empty here,
// so stand in the count we want to assert against.
return typeof picked === 'number' ? pendingCount : picked;
});
}
// `TooltipSimple` replaces its trigger's props, so the testId lives on the
// wrapper and the button itself is reached by role — there is only ever one.
const SLOT = 'bottom-strip-ask-noz';
describe('AskNoz', () => {
beforeEach(() => {
jest.clearAllMocks();
mockEnabled.mockReturnValue(true);
mockStore();
});
describe('visibility', () => {
it('renders when the assistant is enabled', () => {
const { getByRole } = render(<AskNoz />);
expect(getByRole('button')).toBeInTheDocument();
});
it('renders nothing when the assistant is disabled', () => {
mockEnabled.mockReturnValue(false);
const { queryByTestId } = render(<AskNoz />);
expect(queryByTestId(SLOT)).not.toBeInTheDocument();
});
it('renders nothing while the drawer is open', () => {
mockStore({ isDrawerOpen: true });
const { queryByTestId } = render(<AskNoz />);
expect(queryByTestId(SLOT)).not.toBeInTheDocument();
});
it('renders nothing on the Noz full page, where the drawer does not mount', () => {
const { queryByTestId } = render(<AskNoz />, undefined, {
initialRoute: '/ai-assistant/some-conversation-id',
});
expect(queryByTestId(SLOT)).not.toBeInTheDocument();
});
});
describe('what it does', () => {
it('opens the drawer on click', () => {
const { getByRole } = render(<AskNoz />);
fireEvent.click(getByRole('button'));
expect(mockOpen).toHaveBeenCalledTimes(1);
});
});
describe('pending badge', () => {
it('announces the count when Noz is waiting on the user', () => {
mockStore({ pendingCount: 2 });
const { getByRole } = render(<AskNoz />);
expect(getByRole('button')).toHaveAttribute(
'aria-label',
'Ask Noz, 2 actions need your response',
);
});
it('uses the singular for one pending action', () => {
mockStore({ pendingCount: 1 });
const { getByRole } = render(<AskNoz />);
expect(getByRole('button')).toHaveAttribute(
'aria-label',
'Ask Noz, 1 action needs your response',
);
});
it('stays quiet when nothing is pending', () => {
const { getByRole } = render(<AskNoz />);
expect(getByRole('button')).toHaveAttribute('aria-label', 'Ask Noz');
});
it('stays quiet while the modal is open, Noz is already on screen', () => {
mockStore({ isModalOpen: true, pendingCount: 3 });
const { getByRole } = render(<AskNoz />);
expect(getByRole('button')).toHaveAttribute('aria-label', 'Ask Noz');
});
});
});

View File

@@ -11,6 +11,8 @@
background: var(--l2-background);
border-top: 1px solid var(--l2-border);
--button-font-size: 12px;
font-family: var(--font-family-sf-mono, monospace);
// Above page content, below the body-portalled overlays that are meant to
@@ -23,14 +25,21 @@
.right {
display: flex;
align-items: center;
gap: var(--spacing-6);
gap: var(--spacing-4);
min-width: 0;
}
// Temporary placeholder for the left slot. Replaced later.
.version {
color: var(--l2-foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
.left {
cursor: default;
}
// separator b/w strip entities
.left > *:not(:last-child)::after,
.right > *:not(:last-child)::after {
content: '';
flex-shrink: 0;
width: 1px;
height: 14px;
background: var(--l2-border);
margin-left: var(--spacing-4);
}

View File

@@ -0,0 +1,53 @@
import * as Sentry from '@sentry/react';
import { useAppContext } from 'providers/App/App';
import StripKeyValue from '../components/StripKeyValue/StripKeyValue';
import StripTypography from '../components/StripTypography/StripTypography';
import { useBottomStripStore } from '../store/useBottomStripStore';
import { type StripItem, StripItemKind } from '../types';
function renderItem(item: StripItem, index: number): JSX.Element {
if (item.kind === StripItemKind.KeyValue) {
return (
<StripKeyValue
key={index}
label={item.label}
value={item.value}
prefix={item.prefix}
tone={item.tone}
/>
);
}
return (
// eslint-disable-next-line react/no-array-index-key
<StripTypography key={index} prefix={item.prefix}>
{item.text}
</StripTypography>
);
}
function LeftSlot(): JSX.Element | null {
const { versionData } = useAppContext();
const left = useBottomStripStore((state) => state.left);
const ownerId = useBottomStripStore((state) => state.ownerId);
const version = versionData?.version?.trim();
const versionNode = version ? (
<StripTypography>{version}</StripTypography>
) : null;
if (!left?.length) {
return versionNode;
}
return (
// Keyed so a page whose items throw does not leave the boundary latched on
// the fallback for every page after it.
<Sentry.ErrorBoundary key={ownerId} fallback={<>{versionNode}</>}>
{left.map(renderItem)}
</Sentry.ErrorBoundary>
);
}
export default LeftSlot;

View File

@@ -0,0 +1,45 @@
import { useState } from 'react';
import { Button } from '@signozhq/ui/button';
import AddCreditCardModal from 'components/AddCreditCardModal/AddCreditCardModal';
import { ChatSupportState, useChatSupport } from 'hooks/useChatSupport';
import { MessageSquareText } from '@signozhq/icons';
function SupportButton(): JSX.Element | null {
const chatSupport = useChatSupport();
const [isAddCreditCardModalOpen, setIsAddCreditCardModalOpen] =
useState(false);
if (chatSupport === ChatSupportState.Unavailable) {
return null;
}
const handleClick = (): void => {
if (chatSupport === ChatSupportState.NeedsCard) {
setIsAddCreditCardModalOpen(true);
return;
}
window.Pylon?.('show');
};
return (
<>
<Button
variant="ghost"
color="secondary"
size="sm"
prefix={<MessageSquareText size={16} />}
onClick={handleClick}
testId="bottom-strip-support"
>
Support
</Button>
<AddCreditCardModal
open={isAddCreditCardModalOpen}
onClose={(): void => setIsAddCreditCardModalOpen(false)}
/>
</>
);
}
export default SupportButton;

View File

@@ -0,0 +1,81 @@
import { fireEvent } from '@testing-library/react';
import { ChatSupportState, useChatSupport } from 'hooks/useChatSupport';
import { render } from 'tests/test-utils';
import SupportButton from '../SupportButton';
jest.mock('hooks/useChatSupport', () => ({
...jest.requireActual('hooks/useChatSupport'),
useChatSupport: jest.fn(),
}));
const mockChatSupport = useChatSupport as jest.MockedFunction<
typeof useChatSupport
>;
const BUTTON = 'bottom-strip-support';
const MODAL_TITLE = 'Add Credit Card for Chat Support';
describe('SupportButton', () => {
beforeEach(() => {
jest.clearAllMocks();
window.Pylon = jest.fn() as never;
});
describe('when Pylon is available', () => {
beforeEach(() => mockChatSupport.mockReturnValue(ChatSupportState.Pylon));
it('shows the button', () => {
const { getByTestId } = render(<SupportButton />);
expect(getByTestId(BUTTON)).toBeInTheDocument();
});
it('opens the Pylon widget on click', () => {
const { getByTestId } = render(<SupportButton />);
fireEvent.click(getByTestId(BUTTON));
expect(window.Pylon).toHaveBeenCalledWith('show');
});
it('does not open the credit card modal', () => {
const { getByTestId, queryByText } = render(<SupportButton />);
fireEvent.click(getByTestId(BUTTON));
expect(queryByText(MODAL_TITLE)).not.toBeInTheDocument();
});
});
describe('when the user needs a card', () => {
beforeEach(() => mockChatSupport.mockReturnValue(ChatSupportState.NeedsCard));
it('shows the same button', () => {
const { getByTestId } = render(<SupportButton />);
expect(getByTestId(BUTTON)).toBeInTheDocument();
});
it('opens the credit card modal on click, not Pylon', () => {
const { getByTestId, getByText } = render(<SupportButton />);
fireEvent.click(getByTestId(BUTTON));
expect(getByText(MODAL_TITLE)).toBeInTheDocument();
expect(window.Pylon).not.toHaveBeenCalled();
});
});
describe('when support is unavailable', () => {
beforeEach(() =>
mockChatSupport.mockReturnValue(ChatSupportState.Unavailable),
);
it('renders nothing at all', () => {
const { queryByTestId } = render(<SupportButton />);
expect(queryByTestId(BUTTON)).not.toBeInTheDocument();
});
});
});

View File

@@ -1,12 +1,65 @@
import { useMemo, useState } from 'react';
import { fireEvent } from '@testing-library/react';
import { render } from 'tests/test-utils';
import { Info } from 'types/api/v1/version/get';
import BottomStrip, {
BOTTOM_STRIP_HEIGHT,
BOTTOM_STRIP_HEIGHT_VAR,
BOTTOM_STRIP_ON_CLASS,
} from '..';
import { useBottomStripStore } from '../store/useBottomStripStore';
import { useBottomStrip } from '../useBottomStrip';
import { StripItemKind } from '../types';
/** Stands in for a page that puts something on the left of the strip. */
function Page({ text }: { text: string }): null {
useBottomStrip(useMemo(() => [{ kind: StripItemKind.Text, text }], [text]));
return null;
}
/**
* A page whose icon throws when the strip renders it. `prefix` is the one place
* the config still takes a node, so it is the only way a page can break it.
*/
function BoomPage(): null {
useBottomStrip([{ kind: StripItemKind.Text, text: 'boom', prefix: <Boom /> }]);
return null;
}
/** A page whose value changes without needing `rerender`. */
function ChangingPage(): JSX.Element {
const [count, setCount] = useState(600);
useBottomStrip(
useMemo(
() => [{ kind: StripItemKind.Text, text: `${count} traces` }],
[count],
),
);
return (
<button type="button" onClick={(): void => setCount(42)}>
change
</button>
);
}
/** A page node that blows up while the strip renders it. */
function Boom(): JSX.Element {
throw new Error('bad left node');
}
const VERSION = 'v0.134.67';
const versionData: Info = { version: VERSION, ee: 'Y', setupCompleted: true };
const withVersion = { appContextOverrides: { versionData } };
describe('BottomStrip', () => {
// The store is module level, so it outlives each test.
beforeEach(() => {
useBottomStripStore.setState({ left: null, ownerId: null });
});
it('publishes the body class and height property while mounted', () => {
const { unmount } = render(<BottomStrip />);
@@ -28,22 +81,96 @@ describe('BottomStrip', () => {
it.each([['v0.134.67'], ['main-64f1c2a']])(
'renders the build version %p exactly as given',
(version) => {
const { getByTestId } = render(<BottomStrip />, undefined, {
const { getByText } = render(<BottomStrip />, undefined, {
appContextOverrides: {
versionData: { version, ee: 'Y', setupCompleted: true },
},
});
expect(getByTestId('bottom-strip-version')).toHaveTextContent(version);
expect(getByText(version)).toBeInTheDocument();
},
);
it('renders the strip without a version when none is available', () => {
const { getByTestId, queryByTestId } = render(<BottomStrip />, undefined, {
const { getByTestId } = render(<BottomStrip />, undefined, {
appContextOverrides: { versionData: null },
});
expect(getByTestId('bottom-strip')).toBeInTheDocument();
expect(queryByTestId('bottom-strip-version')).not.toBeInTheDocument();
const strip = getByTestId('bottom-strip');
expect(strip).toBeInTheDocument();
expect(strip).toHaveTextContent('');
});
// `tests/test-utils` builds its wrapper around the first `ui`, so `rerender`
// re-renders the original tree. These drive change through state and through
// separate trees instead, which the module-level store lets them share.
describe('left slot', () => {
it('shows what the page put there instead of the version', () => {
render(<Page text="600 traces" />);
const { getByText, queryByText } = render(
<BottomStrip />,
undefined,
withVersion,
);
expect(getByText('600 traces')).toBeInTheDocument();
expect(queryByText(VERSION)).not.toBeInTheDocument();
});
it('falls back to the version once the page is gone', () => {
const page = render(<Page text="600 traces" />);
const { getByText } = render(<BottomStrip />, undefined, withVersion);
page.unmount();
expect(getByText(VERSION)).toBeInTheDocument();
});
it('updates when the page changes what it shows', () => {
render(<ChangingPage />);
const { getByText, getByRole } = render(
<BottomStrip />,
undefined,
withVersion,
);
expect(getByText('600 traces')).toBeInTheDocument();
fireEvent.click(getByRole('button', { name: 'change' }));
expect(getByText('42 traces')).toBeInTheDocument();
});
it('falls back to the version when the page node throws', () => {
// React logs the caught error, which is noise here.
const consoleError = jest
.spyOn(console, 'error')
.mockImplementation(() => {});
render(<BoomPage />);
const { getByTestId, getByText } = render(
<BottomStrip />,
undefined,
withVersion,
);
expect(getByTestId('bottom-strip')).toBeInTheDocument();
expect(getByText(VERSION)).toBeInTheDocument();
consoleError.mockRestore();
});
it('keeps the new page value when the old page unmounts after it', () => {
// Navigation order: the next page mounts before the last one unmounts,
// so without the owner guard the outgoing page wipes the incoming value.
const pageA = render(<Page text="page A" />);
render(<Page text="page B" />);
const { getByText } = render(<BottomStrip />, undefined, withVersion);
pageA.unmount();
expect(getByText('page B')).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,24 @@
// `:` is spaced like a word, per the Figma, so the gap here is a space width
// rather than the wider gap between whole items.
.keyValue {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
color: var(--l2-foreground);
white-space: nowrap;
}
.prefix {
display: inline-flex;
align-items: center;
margin-right: var(--spacing-1);
}
.warning {
color: var(--warning);
}
.error {
color: var(--destructive);
}

View File

@@ -0,0 +1,47 @@
import type { ReactNode } from 'react';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import { StripTone } from '../../types';
import styles from './StripKeyValue.module.scss';
export interface StripKeyValueProps {
label: string;
value: string | number;
/** Leading icon. Same shape as `Button`'s. */
prefix?: ReactNode;
/** Tints the icon; the text stays neutral at every tone. */
tone?: StripTone;
}
/** A labelled number, rendered `Spans : 31`. */
function StripKeyValue({
label,
value,
prefix,
tone,
}: StripKeyValueProps): JSX.Element {
return (
<span className={styles.keyValue}>
{prefix && (
<span className={cx(styles.prefix, tone && styles[tone])} data-tone={tone}>
{prefix}
</span>
)}
<Typography.Text as="span" className={styles.label}>
{label}
</Typography.Text>
<Typography.Text as="span" className={styles.colon}>
:
</Typography.Text>
<Typography.Text as="span" className={styles.value}>
{value}
</Typography.Text>
</span>
);
}
StripKeyValue.defaultProps = { prefix: undefined, tone: StripTone.Default };
export default StripKeyValue;

View File

@@ -0,0 +1,46 @@
import { render } from 'tests/test-utils';
import { StripTone } from '../../../types';
import StripKeyValue from '../StripKeyValue';
describe('StripKeyValue', () => {
it('renders the label and the value around a colon it owns', () => {
const { getByText, container } = render(
<StripKeyValue label="Spans" value={31} />,
);
expect(getByText('Spans')).toBeInTheDocument();
expect(getByText('31')).toBeInTheDocument();
// The consumer never builds the string, so the spacing cannot drift.
expect(container.textContent).toBe('Spans:31');
});
it('tints only the icon when the tone is set', () => {
const { container } = render(
<StripKeyValue
label="Errors"
value={4}
tone={StripTone.Error}
prefix={<svg data-testid="icon" />}
/>,
);
// The tone lands on the icon, never on the text.
expect(container.querySelector('[data-tone]')).toHaveAttribute(
'data-tone',
'error',
);
});
it('leaves the icon untinted by default', () => {
const { container } = render(
<StripKeyValue label="Errors" value={0} prefix={<svg />} />,
);
expect(container.querySelector('[data-tone]')).toHaveAttribute(
'data-tone',
'default',
);
});
});

View File

@@ -0,0 +1,10 @@
.stripTypography {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--l2-foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

View File

@@ -0,0 +1,28 @@
import type { ReactNode } from 'react';
import { Typography } from '@signozhq/ui/typography';
import styles from './StripTypography.module.scss';
interface StripTypographyProps {
children: ReactNode;
/** Leading icon. Same shape as `Button`'s. */
prefix?: ReactNode;
}
/** A single phrase. A labelled number belongs in `StripKeyValue`. */
function StripTypography({
children,
prefix,
}: StripTypographyProps): JSX.Element {
return (
<span className={styles.stripTypography}>
{prefix}
<Typography.Text as="span">{children}</Typography.Text>
</span>
);
}
StripTypography.defaultProps = { prefix: undefined };
export type { StripTypographyProps };
export default StripTypography;

View File

@@ -1,5 +1,8 @@
import { useLayoutEffect } from 'react';
import { useAppContext } from 'providers/App/App';
import AskNoz from './AskNoz/AskNoz';
import LeftSlot from './LeftSlot/LeftSlot';
import SupportButton from './SupportButton/SupportButton';
import styles from './BottomStrip.module.scss';
@@ -9,9 +12,6 @@ export const BOTTOM_STRIP_ON_CLASS = 'bottom-strip-on';
export const BOTTOM_STRIP_HEIGHT_VAR = '--bottom-strip-height';
function BottomStrip(): JSX.Element {
const { versionData } = useAppContext();
const version = versionData?.version?.trim();
useLayoutEffect(() => {
document.body.classList.add(BOTTOM_STRIP_ON_CLASS);
document.body.style.setProperty(
@@ -28,13 +28,12 @@ function BottomStrip(): JSX.Element {
return (
<div className={styles.strip} data-testid="bottom-strip">
<div className={styles.left}>
{version && (
<span className={styles.version} data-testid="bottom-strip-version">
{version}
</span>
)}
<LeftSlot />
</div>
<div className={styles.right}>
<AskNoz />
<SupportButton />
</div>
<div className={styles.right} />
</div>
);
}

View File

@@ -0,0 +1,27 @@
import { create } from 'zustand';
import type { StripItem } from '../types';
interface BottomStripState {
/** What the strip shows on the left, or null to fall back to the version. */
left: StripItem[] | null;
/** Which page owns the current value — see `clearLeft`. */
ownerId: string | null;
setLeft: (ownerId: string, left: StripItem[] | null) => void;
clearLeft: (ownerId: string) => void;
}
export const useBottomStripStore = create<BottomStripState>()((set, get) => ({
left: null,
ownerId: null,
setLeft: (ownerId, left): void => set({ left, ownerId }),
// Only the current owner may clear. On a plain route swap React runs the old
// page's cleanup before the new page's effect, so this is moot, but two
// consumers can be mounted at once (a page under a drawer): the one that set
// last owns the slot, and the other unmounting must not wipe it.
clearLeft: (ownerId): void => {
if (get().ownerId === ownerId) {
set({ left: null, ownerId: null });
}
},
}));

View File

@@ -0,0 +1,31 @@
import type { ReactNode } from 'react';
export enum StripTone {
Default = 'default',
Warning = 'warning',
Error = 'error',
}
export enum StripItemKind {
/** A phrase in the page's own words, e.g. "18 services". */
Text = 'text',
/** A labelled number, rendered `label : value`. */
KeyValue = 'keyValue',
}
/** What a page may put on the left of the strip. */
export type StripItem =
| {
kind: StripItemKind.Text;
text: string;
prefix?: ReactNode;
}
| {
kind: StripItemKind.KeyValue;
/** Without a colon; the strip renders the separator. */
label: string;
value: string | number;
prefix?: ReactNode;
/** Tints the icon; the text stays neutral at every tone. */
tone?: StripTone;
};

View File

@@ -0,0 +1,25 @@
import { useEffect, useId } from 'react';
import { useBottomStripStore } from './store/useBottomStripStore';
import type { StripItem } from './types';
/**
* Puts `items` in the bottom strip for as long as the calling page is mounted.
* Pass null to show nothing and let the version through. The left is the only
* side a page contributes to; the right is the strip's own.
*
* There is no refresh API by design: a page that refetches re-renders, which
* produces new items, which re-runs this effect. Memoise the array where it is
* built, or the store is written on every render.
*/
export function useBottomStrip(items: StripItem[] | null): void {
const ownerId = useId();
const setLeft = useBottomStripStore((state) => state.setLeft);
const clearLeft = useBottomStripStore((state) => state.clearLeft);
useEffect(() => {
setLeft(ownerId, items);
return (): void => clearLeft(ownerId);
}, [items, ownerId, setLeft, clearLeft]);
}

View File

@@ -26,6 +26,7 @@ import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import ROUTES from 'constants/routes';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
import { useHomeStripInfo } from 'container/Home/useHomeStripInfo';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
@@ -64,6 +65,8 @@ const homeInterval = 30 * 60 * 1000;
// eslint-disable-next-line sonarjs/cognitive-complexity
export default function Home(): JSX.Element {
useHomeStripInfo();
const { user } = useAppContext();
const { safeNavigate } = useSafeNavigate();
const isDarkMode = useIsDarkMode();

View File

@@ -0,0 +1,58 @@
import { renderHook } from '@testing-library/react';
import { useGetAlerts } from 'api/generated/services/alerts';
import { useBottomStripStore } from 'container/BottomStrip/store/useBottomStripStore';
import { StripItemKind } from 'container/BottomStrip/types';
import { useHomeStripInfo } from '../useHomeStripInfo';
jest.mock('api/generated/services/alerts', () => ({
useGetAlerts: jest.fn(),
}));
const mockUseGetAlerts = useGetAlerts as jest.Mock;
describe('useHomeStripInfo', () => {
beforeEach(() => {
useBottomStripStore.setState({ left: null, ownerId: null });
});
it('counts the firing alert instances', () => {
mockUseGetAlerts.mockReturnValue({ data: { data: [{}, {}, {}] } });
renderHook(() => useHomeStripInfo());
expect(useBottomStripStore.getState().left).toMatchObject([
{ kind: StripItemKind.Text, text: '3 alerts firing' },
]);
});
it('says alert, not alerts, when only one is firing', () => {
mockUseGetAlerts.mockReturnValue({ data: { data: [{}] } });
renderHook(() => useHomeStripInfo());
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
text: '1 alert firing',
});
});
it('shows zero before the response lands', () => {
mockUseGetAlerts.mockReturnValue({ data: undefined });
renderHook(() => useHomeStripInfo());
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
text: '0 alerts firing',
});
});
it('clears the strip when the page unmounts', () => {
mockUseGetAlerts.mockReturnValue({ data: { data: [{}, {}, {}] } });
const { unmount } = renderHook(() => useHomeStripInfo());
unmount();
expect(useBottomStripStore.getState().left).toBeNull();
});
});

View File

@@ -0,0 +1,21 @@
import { useMemo } from 'react';
import { useGetAlerts } from 'api/generated/services/alerts';
import { useBottomStrip } from 'container/BottomStrip/useBottomStrip';
import { type StripItem, StripItemKind } from 'container/BottomStrip/types';
import { pluralize } from 'utils/pluralize';
export function useHomeStripInfo(): void {
// Firing instances, not rules, matching the triggered alerts page.
const { data } = useGetAlerts();
const count = data?.data?.length ?? 0;
const items = useMemo<StripItem[]>(
() => [
{ kind: StripItemKind.Text, text: `${pluralize(count, 'alert')} firing` },
],
[count],
);
useBottomStrip(items);
}

View File

@@ -0,0 +1,53 @@
import { renderHook } from '@testing-library/react';
import { useBottomStripStore } from 'container/BottomStrip/store/useBottomStripStore';
import { StripItemKind } from 'container/BottomStrip/types';
import { useAlertRulesStripInfo } from '../useAlertRulesStripInfo';
describe('useAlertRulesStripInfo', () => {
beforeEach(() => {
useBottomStripStore.setState({ left: null, ownerId: null });
});
it('shows the rows on the page against the total', () => {
renderHook(() => useAlertRulesStripInfo({ shownCount: 15, totalCount: 17 }));
expect(useBottomStripStore.getState().left).toMatchObject([
{ kind: StripItemKind.Text, text: '15 of 17 rules' },
]);
});
it('still says n of m when the whole list fits on one page', () => {
renderHook(() => useAlertRulesStripInfo({ shownCount: 17, totalCount: 17 }));
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
text: '17 of 17 rules',
});
});
it('says rule, not rules, when there is only one', () => {
renderHook(() => useAlertRulesStripInfo({ shownCount: 1, totalCount: 1 }));
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
text: '1 of 1 rule',
});
});
it('shows zero when nothing matched', () => {
renderHook(() => useAlertRulesStripInfo({ shownCount: 0, totalCount: 12 }));
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
text: '0 of 12 rules',
});
});
it('clears the strip when the page unmounts', () => {
const { unmount } = renderHook(() =>
useAlertRulesStripInfo({ shownCount: 15, totalCount: 17 }),
);
unmount();
expect(useBottomStripStore.getState().left).toBeNull();
});
});

View File

@@ -20,6 +20,7 @@ import { ALERT_RULES_PARAMS, useAlertRulesFilters } from './hooks';
import styles from './ListAlertRules.module.scss';
import { getAlertRuleColumns } from './table.config';
import type { AlertRule } from './types';
import { useAlertRulesStripInfo } from './useAlertRulesStripInfo';
import { useAlertRulesData } from './useAlertRulesData';
import { useAlertRulesHandlers } from './useAlertRulesHandlers';
@@ -87,6 +88,11 @@ function ListAlertRules(): JSX.Element {
return filteredRules.slice(start, start + limit);
}, [filteredRules, page, limit]);
useAlertRulesStripInfo({
shownCount: paginatedRules.length,
totalCount: filteredRules.length,
});
const columnsWithActions = useMemo(() => {
if (!action) {
return columns;

View File

@@ -0,0 +1,27 @@
import { useMemo } from 'react';
import { useBottomStrip } from 'container/BottomStrip/useBottomStrip';
import { type StripItem, StripItemKind } from 'container/BottomStrip/types';
import { pluralize } from 'utils/pluralize';
interface UseAlertRulesStripInfoArgs {
/** Rows on the current page, matching the table's own footer. */
shownCount: number;
totalCount: number;
}
export function useAlertRulesStripInfo({
shownCount,
totalCount,
}: UseAlertRulesStripInfoArgs): void {
const items = useMemo<StripItem[]>(
() => [
{
kind: StripItemKind.Text,
text: `${shownCount} of ${pluralize(totalCount, 'rule')}`,
},
],
[shownCount, totalCount],
);
useBottomStrip(items);
}

View File

@@ -21,6 +21,7 @@ import { getTotalRPS } from 'utils/services';
import { getColumns } from '../Columns/ServiceColumn';
import { ServiceMetricsTableProps } from '../types';
import { useServicesStripInfo } from '../useServicesStripInfo';
import { getServiceListFromQuery } from '../utils';
function ServiceMetricTable({
@@ -67,6 +68,8 @@ function ServiceMetricTable({
[isLoading, queries, topLevelOperations],
);
useServicesStripInfo(services.length);
const { search } = useLocation();
const tableColumns = useMemo(() => getColumns(search, true), [search]);
const [RPS, setRPS] = useState(0);

View File

@@ -18,6 +18,7 @@ import { GlobalReducer } from 'types/reducer/globalTime';
import { Tags } from 'hooks/useResourceAttribute/types';
import SkipOnBoardingModal from '../SkipOnBoardModal';
import { useServicesStripInfo } from '../useServicesStripInfo';
import ServiceTraceTable from './ServiceTracesTable';
function ServiceTraces(): JSX.Element {
@@ -42,6 +43,8 @@ function ServiceTraces(): JSX.Element {
const services = data || [];
useServicesStripInfo(services.length);
const [skipOnboarding, setSkipOnboarding] = useState(
localStorageGet(SKIP_ONBOARDING) === 'true',
);

View File

@@ -0,0 +1,43 @@
import { renderHook } from '@testing-library/react';
import { useBottomStripStore } from 'container/BottomStrip/store/useBottomStripStore';
import { StripItemKind } from 'container/BottomStrip/types';
import { useServicesStripInfo } from '../useServicesStripInfo';
describe('useServicesStripInfo', () => {
beforeEach(() => {
useBottomStripStore.setState({ left: null, ownerId: null });
});
it('shows how many services are listed', () => {
renderHook(() => useServicesStripInfo(18));
expect(useBottomStripStore.getState().left).toMatchObject([
{ kind: StripItemKind.Text, text: '18 services' },
]);
});
it('says service, not services, when there is one', () => {
renderHook(() => useServicesStripInfo(1));
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
text: '1 service',
});
});
it('shows zero when there are none', () => {
renderHook(() => useServicesStripInfo(0));
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
text: '0 services',
});
});
it('clears the strip when the page unmounts', () => {
const { unmount } = renderHook(() => useServicesStripInfo(18));
unmount();
expect(useBottomStripStore.getState().left).toBeNull();
});
});

View File

@@ -0,0 +1,13 @@
import { useMemo } from 'react';
import { useBottomStrip } from 'container/BottomStrip/useBottomStrip';
import { type StripItem, StripItemKind } from 'container/BottomStrip/types';
import { pluralize } from 'utils/pluralize';
export function useServicesStripInfo(count: number): void {
const items = useMemo<StripItem[]>(
() => [{ kind: StripItemKind.Text, text: pluralize(count, 'service') }],
[count],
);
useBottomStrip(items);
}

View File

@@ -0,0 +1,189 @@
import { renderHook } from '@testing-library/react';
import { FeatureKeys } from 'constants/features';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { ChatSupportState, useChatSupport } from 'hooks/useChatSupport';
import { useAppContext } from 'providers/App/App';
jest.mock('providers/App/App');
jest.mock('hooks/useGetTenantLicense');
const mockAppContext = useAppContext as jest.MockedFunction<
typeof useAppContext
>;
const mockLicense = useGetTenantLicense as jest.MockedFunction<
typeof useGetTenantLicense
>;
const flag = (name: FeatureKeys, active: boolean): Record<string, unknown> => ({
name,
active,
usage: 0,
usage_limit: -1,
route: '',
});
function setup({
chatSupport = true,
premiumSupport = false,
trialConverted = false,
isCloudUser = true,
isEnterpriseSelfHostedUser = false,
isLoggedIn = true,
pylonEnabled = true,
isFetchingFeatureFlags = false,
featureFlags = undefined as unknown,
featureFlagsFetchError = null as unknown,
activeLicense = {} as unknown,
trialInfo = {} as unknown,
} = {}): void {
window.signozBootData = {
settings: { pylon: { enabled: pylonEnabled } },
} as never;
mockAppContext.mockReturnValue({
featureFlags:
featureFlags === undefined
? [
flag(FeatureKeys.CHAT_SUPPORT, chatSupport),
flag(FeatureKeys.PREMIUM_SUPPORT, premiumSupport),
]
: featureFlags,
isFetchingFeatureFlags,
featureFlagsFetchError,
trialInfo: trialInfo && { trialConvertedToSubscription: trialConverted },
isLoggedIn,
activeLicense,
} as never);
mockLicense.mockReturnValue({
isCloudUser,
isEnterpriseSelfHostedUser,
} as never);
}
const state = (): ChatSupportState =>
renderHook(() => useChatSupport()).result.current;
describe('useChatSupport', () => {
beforeEach(() => jest.clearAllMocks());
describe('pylon', () => {
it('hands off to Pylon for a cloud user past trial', () => {
setup({ trialConverted: true });
expect(state()).toBe(ChatSupportState.Pylon);
});
it('hands off to Pylon for enterprise self-hosted', () => {
setup({
trialConverted: true,
isCloudUser: false,
isEnterpriseSelfHostedUser: true,
});
expect(state()).toBe(ChatSupportState.Pylon);
});
it('hands off to Pylon when premium support is on, card or not', () => {
setup({ premiumSupport: true, trialConverted: false });
expect(state()).toBe(ChatSupportState.Pylon);
});
it('offers nothing when Pylon is not configured server side', () => {
setup({ trialConverted: true, pylonEnabled: false });
expect(state()).toBe(ChatSupportState.Unavailable);
});
});
describe('needsCard', () => {
it('offers the card flow to a cloud user still on trial', () => {
setup({ trialConverted: false, premiumSupport: false });
expect(state()).toBe(ChatSupportState.NeedsCard);
});
it('offers nothing to a non-cloud user needing a card', () => {
setup({
trialConverted: false,
isCloudUser: false,
isEnterpriseSelfHostedUser: true,
});
expect(state()).toBe(ChatSupportState.Unavailable);
});
});
describe('unavailable', () => {
it('offers nothing without the chat support flag', () => {
setup({ chatSupport: false });
expect(state()).toBe(ChatSupportState.Unavailable);
});
it('offers nothing when logged out', () => {
setup({ isLoggedIn: false });
expect(state()).toBe(ChatSupportState.Unavailable);
});
it('offers nothing while the flags are still loading', () => {
setup({ isFetchingFeatureFlags: true });
expect(state()).toBe(ChatSupportState.Unavailable);
});
it('offers nothing before the licence has loaded', () => {
setup({ activeLicense: null });
expect(state()).toBe(ChatSupportState.Unavailable);
});
it('offers nothing on a tenant that is neither cloud nor enterprise', () => {
setup({
trialConverted: true,
isCloudUser: false,
isEnterpriseSelfHostedUser: false,
});
expect(state()).toBe(ChatSupportState.Unavailable);
});
it('offers nothing before trial info has loaded', () => {
setup({ trialInfo: null });
expect(state()).toBe(ChatSupportState.Unavailable);
});
});
describe('once the flags answer', () => {
it('reads a fetch error as an answer, not as still loading', () => {
setup({
featureFlags: null,
featureFlagsFetchError: new Error('flags unavailable'),
});
// Nothing is granted without flags, so the chat support flag is absent.
expect(state()).toBe(ChatSupportState.Unavailable);
});
it('settles once the flags finish loading', () => {
setup({ isFetchingFeatureFlags: true });
const { result, rerender } = renderHook(() => useChatSupport());
expect(result.current).toBe(ChatSupportState.Unavailable);
setup({ isFetchingFeatureFlags: false });
rerender();
expect(result.current).toBe(ChatSupportState.NeedsCard);
});
it('offers the card flow even when Pylon is not configured', () => {
setup({ trialConverted: false, pylonEnabled: false });
expect(state()).toBe(ChatSupportState.NeedsCard);
});
});
});

View File

@@ -0,0 +1,71 @@
import { FeatureKeys } from 'constants/features';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useAppContext } from 'providers/App/App';
import { useMemo } from 'react';
export enum ChatSupportState {
/** Pylon is configured for this user — hand off to the widget. */
Pylon = 'pylon',
/** Trial without a card — offer the Add Credit Card flow instead. */
NeedsCard = 'needsCard',
/** No support entry at all. */
Unavailable = 'unavailable',
}
export function useChatSupport(): ChatSupportState {
const {
featureFlags,
isFetchingFeatureFlags,
featureFlagsFetchError,
trialInfo,
isLoggedIn,
activeLicense,
} = useAppContext();
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
return useMemo(() => {
const isReady =
!isFetchingFeatureFlags &&
(featureFlags || featureFlagsFetchError) &&
activeLicense &&
trialInfo;
if (!isReady || !isLoggedIn) {
return ChatSupportState.Unavailable;
}
const flag = (name: FeatureKeys): boolean =>
featureFlags?.find((f) => f.name === name)?.active || false;
if (!flag(FeatureKeys.CHAT_SUPPORT)) {
return ChatSupportState.Unavailable;
}
const needsCard =
!flag(FeatureKeys.PREMIUM_SUPPORT) &&
!trialInfo?.trialConvertedToSubscription;
if (needsCard) {
// The credit card flow is cloud-only.
// TODO: offered even where Pylon is not configured, which is probably wrong.
return isCloudUser
? ChatSupportState.NeedsCard
: ChatSupportState.Unavailable;
}
const pylonConfigured = Boolean(
window.signozBootData?.settings?.pylon?.enabled,
);
return (isCloudUser || isEnterpriseSelfHostedUser) && pylonConfigured
? ChatSupportState.Pylon
: ChatSupportState.Unavailable;
}, [
activeLicense,
featureFlags,
featureFlagsFetchError,
isCloudUser,
isEnterpriseSelfHostedUser,
isFetchingFeatureFlags,
isLoggedIn,
trialInfo,
]);
}

View File

@@ -11,6 +11,8 @@ import { useAIAssistantStore } from 'container/AIAssistant/store/useAIAssistantS
import { VariantContext } from 'container/AIAssistant/VariantContext';
import Noz from 'components/Noz/Noz';
import { useAIAssistantStripInfo } from './useAIAssistantStripInfo';
import styles from './AIAssistantPage.module.scss';
import ConversationsList from 'container/AIAssistant/components/ConversationsList';
@@ -41,6 +43,8 @@ export default function AIAssistantPage(): JSX.Element {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useAIAssistantStripInfo();
const conversations = useAIAssistantStore((s) => s.conversations);
const activeConversationId = useAIAssistantStore(
(s) => s.activeConversationId,

View File

@@ -0,0 +1,60 @@
import { renderHook } from '@testing-library/react';
import { useAIAssistantStore } from 'container/AIAssistant/store/useAIAssistantStore';
import { useBottomStripStore } from 'container/BottomStrip/store/useBottomStripStore';
import { StripItemKind } from 'container/BottomStrip/types';
import { useAIAssistantStripInfo } from '../useAIAssistantStripInfo';
function seed(conversations: Record<string, unknown>): void {
useAIAssistantStore.setState({ conversations } as never);
}
describe('useAIAssistantStripInfo', () => {
beforeEach(() => {
useBottomStripStore.setState({ left: null, ownerId: null });
});
it('counts only the conversations that are not archived', () => {
seed({
a: { id: 'a' },
b: { id: 'b' },
c: { id: 'c', archived: true },
});
renderHook(() => useAIAssistantStripInfo());
expect(useBottomStripStore.getState().left).toMatchObject([
{ kind: StripItemKind.Text, text: '2 conversations' },
]);
});
it('says one conversation, not 1 conversations', () => {
seed({ a: { id: 'a' } });
renderHook(() => useAIAssistantStripInfo());
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
text: '1 conversation',
});
});
it('shows zero when there are none', () => {
seed({});
renderHook(() => useAIAssistantStripInfo());
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
text: '0 conversations',
});
});
it('clears the strip when the page unmounts', () => {
seed({ a: { id: 'a' } });
const { unmount } = renderHook(() => useAIAssistantStripInfo());
unmount();
expect(useBottomStripStore.getState().left).toBeNull();
});
});

View File

@@ -243,3 +243,11 @@ export const TooltipsInApprovalDiff: Story = {
args: { tooltipsOpen: true, agent: 'awaiting-approval', contents: BRIEF },
play: openApprovalDiff,
};
/**
* The conversation count in the bottom strip, in place of the build version.
* Archived threads are left out of it.
*/
export const BottomStrip: Story = {
args: { bottomStrip: true },
};

View File

@@ -0,0 +1,20 @@
import { useMemo } from 'react';
import { useAIAssistantStore } from 'container/AIAssistant/store/useAIAssistantStore';
import { useBottomStrip } from 'container/BottomStrip/useBottomStrip';
import { type StripItem, StripItemKind } from 'container/BottomStrip/types';
import { pluralize } from 'utils/pluralize';
export function useAIAssistantStripInfo(): void {
const conversations = useAIAssistantStore((state) => state.conversations);
const count = Object.values(conversations).filter(
(conversation) => !conversation.archived,
).length;
const items = useMemo<StripItem[]>(
() => [{ kind: StripItemKind.Text, text: pluralize(count, 'conversation') }],
[count],
);
useBottomStrip(items);
}

View File

@@ -133,3 +133,19 @@ export const ColumnPicker: Story = {
export const Tooltips: Story = {
args: { tooltipsOpen: true },
};
/**
* The rule count in the bottom strip, in place of the build version: the rows on
* the page against the total, the same pair the table's own footer prints.
*/
export const BottomStrip: Story = {
args: { bottomStrip: true },
};
/** The same count on a second page, where the two numbers come apart. */
export const BottomStripPaginated: Story = {
args: { bottomStrip: true, rules: RULE_MAX },
parameters: {
signoz: { route: '/alerts?tab=AlertRules&page=2&limit=10' },
},
};

View File

@@ -125,3 +125,11 @@ export const QuickFiltersSettingsWithBanner: Story = {
args: { banner: 'trial-expiry' },
play: dirtyQuickFiltersSettings,
};
/**
* The exception count in the bottom strip, in place of the build version: the
* rows on the page against the total the count query returns.
*/
export const BottomStrip: Story = {
args: { bottomStrip: true },
};

View File

@@ -225,17 +225,21 @@ export const dashboardMocks = defineStoryMocks({
}),
),
rest.post(
'http://localhost/api/v2/variables/query',
response.json(() => ({
status: 'success',
data: { variableValues: serviceVariableValues(values.variableValues) },
})),
// The variable bar resolves before the panels and stays laid out while
// they load or fail, so its two endpoints answer on their own rather
// than through the Data control.
rest.post('http://localhost/api/v2/variables/query', (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
status: 'success',
data: { variableValues: serviceVariableValues(values.variableValues) },
}),
),
),
rest.get(
'http://localhost/api/v1/fields/values',
response.json(() => fieldValuesResponse(NAMESPACE_VALUES)),
rest.get('http://localhost/api/v1/fields/values', (_req, res, ctx) =>
res(ctx.status(200), ctx.json(fieldValuesResponse(NAMESPACE_VALUES))),
),
// The header reads the public link on every load, so it answers even while

View File

@@ -97,3 +97,11 @@ export const NavSettingsMenu: Story = {
await screen.findByRole('menu');
},
};
/**
* The firing alert count in the bottom strip, in place of the build version. It
* counts firing instances, so it does not match the alert rules widget above.
*/
export const BottomStrip: Story = {
args: { bottomStrip: true },
};

View File

@@ -61,3 +61,13 @@ export const Pagination: Story = {
export const OverTrialLimit: Story = {
args: { traffic: 'over-trial-limit', banner: 'trial-expiry' },
};
/** The service count in the bottom strip, in place of the build version. */
export const BottomStrip: Story = {
args: { bottomStrip: true },
};
/** The same count on the span metrics table, which is the page's other path. */
export const BottomStripSpanMetrics: Story = {
args: { bottomStrip: true, mode: 'span-metrics' },
};

View File

@@ -0,0 +1,57 @@
import { renderHook } from '@testing-library/react';
import { useBottomStripStore } from 'container/BottomStrip/store/useBottomStripStore';
import { StripItemKind, StripTone } from 'container/BottomStrip/types';
import { useTraceDetailsStripInfo } from '../useTraceDetailsStripInfo';
describe('useTraceDetailsStripInfo', () => {
beforeEach(() => {
useBottomStripStore.setState({ left: null, ownerId: null });
});
it('puts the span and error counts in the strip', () => {
renderHook(() =>
useTraceDetailsStripInfo({
totalSpansCount: 600,
totalErrorSpansCount: 4,
}),
);
expect(useBottomStripStore.getState().left).toMatchObject([
{ kind: StripItemKind.KeyValue, label: 'Spans', value: 600 },
{
kind: StripItemKind.KeyValue,
label: 'Errors',
value: 4,
tone: StripTone.Error,
},
]);
});
it('leaves the error item untoned when nothing failed', () => {
renderHook(() =>
useTraceDetailsStripInfo({
totalSpansCount: 600,
totalErrorSpansCount: 0,
}),
);
expect(useBottomStripStore.getState().left?.[1]).toMatchObject({
value: 0,
tone: StripTone.Default,
});
});
it('clears the strip when the page unmounts', () => {
const { unmount } = renderHook(() =>
useTraceDetailsStripInfo({
totalSpansCount: 600,
totalErrorSpansCount: 4,
}),
);
unmount();
expect(useBottomStripStore.getState().left).toBeNull();
});
});

View File

@@ -15,6 +15,7 @@ import { useDetailsPanel } from 'components/DetailsPanel';
import WarningPopover from 'components/WarningPopover/WarningPopover';
import { LOCALSTORAGE } from 'constants/localStorage';
import useGetTraceV4 from 'hooks/trace/useGetTraceV4';
import { useTraceDetailsStripInfo } from './useTraceDetailsStripInfo';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { ResizableBox } from 'periscope/components/ResizableBox';
@@ -144,6 +145,10 @@ function TraceDetailsV3(): JSX.Element {
const allSpans = traceData?.payload?.spans || [];
const totalSpansCount = traceData?.payload?.totalSpansCount || 0;
const totalErrorSpansCount = traceData?.payload?.totalErrorSpansCount || 0;
useTraceDetailsStripInfo({ totalSpansCount, totalErrorSpansCount });
const isFullDataLoaded =
totalSpansCount > 0 && totalSpansCount <= allSpans.length;
@@ -441,7 +446,10 @@ function TraceDetailsV3(): JSX.Element {
})}
>
<TriangleAlert size={13} />
Errors: {traceData.payload.totalErrorSpansCount ?? 0}
Errors:{' '}
{traceData.payload.totalErrorSpansCount ?? (
<span className="translate-safe">{0}</span>
)}
</span>
</span>
) : null}

View File

@@ -65,6 +65,24 @@ export const Loading: Story = {
args: { dataState: 'loading' },
};
/**
* The trace's span and error counts in the bottom strip, in place of the build
* version, the error icon red while any span failed.
*/
export const BottomStrip: Story = {
args: { bottomStrip: true },
};
/** The strip over a trace with no failing span: the error icon stays neutral. */
export const BottomStripNoErrors: Story = {
args: { bottomStrip: true, errors: false },
};
/** The strip mid-fetch, whose counts read zero until the trace resolves. */
export const BottomStripLoading: Story = {
args: { bottomStrip: true, dataState: 'loading' },
};
/** The waterfall renders once the trace resolves, which outlasts the 1s default. */
const untilLoaded = { timeout: 15_000 };

View File

@@ -0,0 +1,43 @@
import { useMemo } from 'react';
import { ChartNoAxesGantt, TriangleAlert } from '@signozhq/icons';
import { useBottomStrip } from 'container/BottomStrip/useBottomStrip';
import {
type StripItem,
StripItemKind,
StripTone,
} from 'container/BottomStrip/types';
interface UseTraceDetailsStripInfoArgs {
totalSpansCount: number;
totalErrorSpansCount: number;
}
/**
* Takes the counts rather than fetching them: the trace query key includes the
* selected span, so a fetch here would fire on every span click.
*/
export function useTraceDetailsStripInfo({
totalSpansCount,
totalErrorSpansCount,
}: UseTraceDetailsStripInfoArgs): void {
const items = useMemo<StripItem[]>(
() => [
{
kind: StripItemKind.KeyValue,
label: 'Spans',
value: totalSpansCount,
prefix: <ChartNoAxesGantt size={13} />,
},
{
kind: StripItemKind.KeyValue,
label: 'Errors',
value: totalErrorSpansCount,
prefix: <TriangleAlert size={13} />,
tone: totalErrorSpansCount > 0 ? StripTone.Error : StripTone.Default,
},
],
[totalSpansCount, totalErrorSpansCount],
);
useBottomStrip(items);
}

View File

@@ -0,0 +1,132 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import ROUTES from 'constants/routes';
import { toast } from '@signozhq/ui/sonner';
import { screen, userEvent, waitFor } from 'storybook/test';
import type { GlobalMockArgs } from '../globals';
/** A page that puts nothing in the strip, so the shell is all there is. */
function EmptyPage(): JSX.Element {
return <div />;
}
/** The shell renders its entry points after the global config and the license settle. */
const untilLoaded = { timeout: 10_000 };
/**
* The strip along the foot of the shell, behind the `SAVED_VIEW_ENABLED` flag:
* what the page puts on the left, the build version when it puts nothing, and
* the shell's own Ask Noz and Support on the right. Support follows the chat
* support the workspace has, the way the floating bubble it replaces did.
*
* Route: every page inside the app shell; these stories use `/home`.
*/
const meta = {
title: 'Foundations/Bottom Strip',
tags: ['play'],
component: EmptyPage,
args: { bottomStrip: true },
parameters: { signoz: { route: ROUTES.HOME, layout: 'app' } },
} satisfies Meta<GlobalMockArgs>;
export default meta;
type Story = StoryObj<GlobalMockArgs>;
/**
* The build version on the left. With Noz off and no chat support configured,
* the right side is empty.
*/
export const Default: Story = {};
/** Ask Noz and Support on the right, Support handing off to Pylon. */
export const Actions: Story = {
args: { noz: true, support: 'pylon' },
};
/**
* A cloud trial without a card, whose Support offers the Add Credit Card modal
* rather than Pylon. The floating bubble that used to offer it is gone.
*/
export const AddCard: Story = {
args: { noz: true, support: 'add-card' },
};
/** The Add Credit Card modal, opened from the strip's Support. */
export const AddCardModal: Story = {
args: { noz: true, support: 'add-card' },
play: async (): Promise<void> => {
await userEvent.click(
await screen.findByTestId('bottom-strip-support', undefined, untilLoaded),
);
await screen.findByRole('dialog', {
name: /Add Credit Card for Chat Support/i,
});
},
};
/**
* The pulsing dot beside Ask Noz while conversations wait on an approval. The
* top nav's Noz entry carries the same count.
*/
export const NozAwaitingYou: Story = {
args: { noz: true, nozAwaiting: 2, support: 'pylon' },
};
/** Every tooltip the strip carries, held open. */
export const Tooltips: Story = {
args: { noz: true, support: 'pylon', tooltipsOpen: true },
};
/**
* The flag off, on a cloud trial without a card: no strip, and chat support is
* the floating bubble in the corner.
*/
export const WithoutStrip: Story = {
args: { bottomStrip: false, noz: true, support: 'add-card' },
};
/** The Add Credit Card modal, opened from the floating bubble with the flag off. */
export const WithoutStripAddCardModal: Story = {
args: { bottomStrip: false, noz: true, support: 'add-card' },
play: async (): Promise<void> => {
// The bubble is an icon with no label or test id of its own.
const bubble = await waitFor(() => {
const found = document.querySelector<HTMLElement>(
'.chat-support-gateway-btn',
);
if (!found) {
throw new Error('the floating support bubble never rendered');
}
return found;
}, untilLoaded);
await userEvent.click(bubble);
await screen.findByRole('dialog', {
name: /Add Credit Card for Chat Support/i,
});
},
};
/** Raises a toast and waits for it, so the shot is taken with it on screen. */
const raiseToast: NonNullable<Story['play']> = async () => {
toast.success('Service account deleted');
await screen.findByText('Service account deleted', undefined, untilLoaded);
};
/**
* A toast with the strip on. Sonner pins itself to the foot of the viewport, so
* without an offset it lands on top of Ask Noz and Support.
*/
export const WithToast: Story = {
args: { noz: true, support: 'pylon' },
play: raiseToast,
};
/** The same toast with the flag off, back at sonner's own inset from the viewport. */
export const WithoutStripToast: Story = {
args: { bottomStrip: false, noz: true, support: 'pylon' },
play: raiseToast,
};

View File

@@ -1,8 +1,6 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import type { ComponentProps } from 'react';
import { nozGlobalConfigHandler } from '@/storybook/msw/appShellHandlers';
import type { GlobalMockArgs } from '../globals';
import TooltipsFixture from './TooltipsFixture';
@@ -50,14 +48,8 @@ export const ExportDownload: Story = {
/**
* The label on the Noz entry point in the top nav. The AI assistant is off until
* the backend ships a URL for it, so the story answers the global config with
* one.
* the backend ships a URL for it, so the story turns the Noz control on.
*/
export const NozEntryPoint: Story = {
args: { site: 'noz' },
parameters: {
msw: {
handlers: [nozGlobalConfigHandler],
},
},
args: { site: 'noz', noz: true },
};

View File

@@ -1,5 +1,8 @@
import { StatusCodes } from 'http-status-codes';
import remove from 'api/browser/localstorage/remove';
import set from 'api/browser/localstorage/set';
import { FeatureKeys } from 'constants/features';
import { LOCALSTORAGE } from 'constants/localStorage';
import { USER_PREFERENCES } from 'constants/userPreferences';
import type { IAppContext } from 'providers/App/types';
import { createAppContextMock } from 'tests/fixtures/appContextMock';
@@ -15,7 +18,7 @@ import type { UserPreference } from 'types/api/preferences/preference';
import { USER_ROLES } from 'types/roles';
import { setNoAuthMode } from 'utils/noAuthMode';
import { choiceControl } from '../controls/controls';
import { choiceControl, toggleControl } from '../controls/controls';
import { defineStoryMocks } from '../controls/defineStoryMocks';
import type { StoryMockArgs } from '../controls/types';
import { RESPONSE_STATES, type ResponseState } from '../runtime/responseState';
@@ -50,6 +53,10 @@ const SIDENAV_STATES = ['pinned', 'collapsed'] as const;
type SidenavState = (typeof SIDENAV_STATES)[number];
const SUPPORT_STATES = ['none', 'pylon', 'add-card'] as const;
type SupportState = (typeof SUPPORT_STATES)[number];
const {
activeLicense: baseLicense,
trialInfo: baseTrialInfo,
@@ -202,6 +209,25 @@ const bannerContext = (
}
};
/**
* The Add Credit Card offer is what a cloud user with chat support but without
* premium support gets, so `add-card` only turns premium off. A deployment with
* no chat support flag, such as `community`, stays without any entry.
*/
const supportFeatureFlags = (
support: SupportState,
featureFlags: FeatureFlagProps[] | null,
): Partial<IAppContext> =>
support === 'add-card'
? {
featureFlags: (featureFlags ?? []).map((flag) =>
flag.name === FeatureKeys.PREMIUM_SUPPORT
? { ...flag, active: false }
: flag,
),
}
: {};
/**
* `AppLayout` lays the shell out from the context rather than the API, so the
* side nav only matches the real app when this is seeded.
@@ -240,6 +266,19 @@ export const appShellMocks = defineStoryMocks({
options: SIDENAV_STATES,
value: 'pinned',
}),
bottomStrip: toggleControl('Bottom strip', {
group: APP_SHELL,
description:
'The `SAVED_VIEW_ENABLED` localStorage flag the bottom strip ships behind.',
value: false,
}),
support: choiceControl<SupportState>('Support', {
group: APP_SHELL,
description:
'The chat support entry the shell offers. `none` is premium support with Pylon not configured; `pylon` configures it in the boot data; `add-card` turns premium support off, which offers a cloud trial the Add Credit Card modal instead. The Pylon widget itself never loads.',
options: SUPPORT_STATES,
value: 'none',
}),
dataState: choiceControl<ResponseState>('State', {
group: DATA,
description: 'How the endpoints the page owns answer.',
@@ -248,19 +287,32 @@ export const appShellMocks = defineStoryMocks({
}),
},
responseState: ({ dataState }) => dataState,
config: ({ license, banner, sidenav }) => {
config: ({ license, banner, sidenav, support }) => {
const tenant = licenseContext(license);
return {
appContext: {
...tenant,
...bannerContext(banner, tenant.activeLicense ?? null),
...supportFeatureFlags(support, tenant.featureFlags ?? baseFeatureFlags),
userPreferences: sidenavPreferences(sidenav === 'pinned'),
},
};
},
effect: ({ banner }) => {
effect: ({ banner, bottomStrip, support }) => {
setNoAuthMode(banner === 'no-auth');
if (bottomStrip) {
set(LOCALSTORAGE.SAVED_VIEW_ENABLED, 'true');
} else {
remove(LOCALSTORAGE.SAVED_VIEW_ENABLED);
}
const pylon = window.signozBootData?.settings?.pylon;
if (pylon) {
pylon.enabled = support === 'pylon';
}
},
});

View File

@@ -1,6 +1,7 @@
import { composeStoryMocks } from '../controls/composeStoryMocks';
import { appShellMocks } from './appShellMocks';
import { authzMocks } from './authzMocks';
import { nozMocks } from './nozMocks';
import { tooltipMocks } from './tooltipMocks';
/**
@@ -11,6 +12,7 @@ import { tooltipMocks } from './tooltipMocks';
export const globalMocks = composeStoryMocks(
authzMocks,
appShellMocks,
nozMocks,
tooltipMocks,
);

View File

@@ -0,0 +1,79 @@
import { rest } from 'msw';
import { AI_API_PATH } from 'api/AIAPIInstance';
import type { ThreadListResponse } from 'api/ai-assistant/chat';
import { useAIAssistantStore } from 'container/AIAssistant/store/useAIAssistantStore';
import type { ConversationStreamState } from 'container/AIAssistant/types';
import { countControl, toggleControl } from '../controls/controls';
import { defineStoryMocks } from '../controls/defineStoryMocks';
import type { StoryMockArgs } from '../controls/types';
import { nozGlobalConfigResponse } from '../msw/__story_mockdata__/appShell';
import { nozGlobalConfigHandler } from '../msw/appShellHandlers';
const NOZ = 'Noz';
/** The streams this module seeds, so it only ever removes its own. */
const AWAITING_PREFIX = 'storybook-awaiting-';
const noThreads: ThreadListResponse = { threads: [], hasMore: false };
const awaitingStream = (): ConversationStreamState => ({
isStreaming: false,
streamingContent: '',
streamingStatus: 'awaiting_approval',
streamingEvents: [],
streamingMessageId: null,
streamingActions: null,
pendingApproval: null,
pendingClarification: null,
});
/**
* The AI assistant's entry points across the shell. A page that answers the
* global config itself, such as the Noz page, wins over `noz`.
*/
export const nozMocks = defineStoryMocks({
controls: {
noz: toggleControl('Noz', {
group: NOZ,
description:
'Answers the global config with an `ai_assistant_url`, which is what turns the assistant on.',
value: false,
}),
nozAwaiting: countControl('Awaiting you', {
group: NOZ,
description:
'Conversations blocked on an approval, which is what the pending badge on the Noz entry points counts.',
value: 0,
max: 3,
}),
},
handlers: ({ noz }) =>
noz
? [
nozGlobalConfigHandler,
// The drawer loads the thread list on mount, while it is still closed.
rest.get(
`${nozGlobalConfigResponse.data.ai_assistant_url}${AI_API_PATH}/threads`,
(_req, res, ctx) => res(ctx.status(200), ctx.json(noThreads)),
),
]
: [],
effect: ({ nozAwaiting }) => {
// The store is a singleton another story's mocks may have seeded, so
// only this module's own streams are replaced.
useAIAssistantStore.setState((state) => {
Object.keys(state.streams)
.filter((id) => id.startsWith(AWAITING_PREFIX))
.forEach((id) => {
delete state.streams[id];
});
for (let index = 0; index < nozAwaiting; index += 1) {
state.streams[`${AWAITING_PREFIX}${index}`] = awaitingStream();
}
});
},
});
export type NozArgs = StoryMockArgs<typeof nozMocks>;

View File

@@ -71,10 +71,9 @@ export const appShellHandlers = [
];
/**
* A story's own `parameters.msw.handlers` are resolved ahead of
* `appShellHandlers`, so the `Foundations/Tooltips` Noz story wires this in to
* override the global config's `ai_assistant_url` without the default set
* doing so for every other story.
* Control-driven handlers are resolved ahead of `appShellHandlers`, so the Noz
* control wires this in to override the global config's `ai_assistant_url`
* without the default set doing so for every other story.
*/
export const nozGlobalConfigHandler = rest.get(
'http://localhost/api/v1/global/config',

View File

@@ -813,6 +813,18 @@ body.ai-assistant-panel-open {
}
}
body.bottom-strip-on {
// Pylon re-shows its own launcher when the chat window opens, so
// `hideChatBubble` cannot hold it down.
.PylonChat-bubbleFrameContainer {
display: none !important;
}
.PylonChat-chatWindowFrameContainer {
bottom: calc(var(--bottom-strip-height, 0px) + 12px) !important;
}
}
[role='tab'] {
color: var(--foreground) !important;
}

View File

@@ -85,6 +85,7 @@ type provider struct {
querierHandler querier.Handler
serviceAccountHandler serviceaccount.Handler
serviceAccountGetter serviceaccount.Getter
userGetter user.Getter
factoryHandler factory.Handler
cloudIntegrationHandler cloudintegration.Handler
ruleStateHistoryHandler rulestatehistory.Handler
@@ -129,6 +130,7 @@ func NewFactory(
querierHandler querier.Handler,
serviceAccountHandler serviceaccount.Handler,
serviceAccountGetter serviceaccount.Getter,
userGetter user.Getter,
factoryHandler factory.Handler,
cloudIntegrationHandler cloudintegration.Handler,
ruleStateHistoryHandler rulestatehistory.Handler,
@@ -181,6 +183,7 @@ func NewFactory(
querierHandler,
serviceAccountHandler,
serviceAccountGetter,
userGetter,
factoryHandler,
cloudIntegrationHandler,
ruleStateHistoryHandler,
@@ -235,6 +238,7 @@ func newProvider(
querierHandler querier.Handler,
serviceAccountHandler serviceaccount.Handler,
serviceAccountGetter serviceaccount.Getter,
userGetter user.Getter,
factoryHandler factory.Handler,
cloudIntegrationHandler cloudintegration.Handler,
ruleStateHistoryHandler rulestatehistory.Handler,
@@ -289,6 +293,7 @@ func newProvider(
querierHandler: querierHandler,
serviceAccountHandler: serviceAccountHandler,
serviceAccountGetter: serviceAccountGetter,
userGetter: userGetter,
factoryHandler: factoryHandler,
cloudIntegrationHandler: cloudIntegrationHandler,
ruleStateHistoryHandler: ruleStateHistoryHandler,

View File

@@ -6,24 +6,35 @@ import (
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
)
func (provider *provider) addUserRoutes(router *mux.Router) error {
if err := router.Handle("/api/v2/users", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.ListUsers), handler.OpenAPIDef{
ID: "ListUsers",
Tags: []string{"users"},
Summary: "List users v2",
Description: "This endpoint lists all users for the organization",
Request: nil,
RequestContentType: "",
Response: make([]*types.User, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v2/users", handler.New(
provider.authzMiddleware.CheckResources(provider.userHandler.ListUsers, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "ListUsers",
Tags: []string{"users"},
Summary: "List users v2",
Description: "This endpoint lists all users for the organization",
Request: nil,
RequestContentType: "",
Response: make([]*types.User, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceUser.Scope(coretypes.VerbList)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceUser,
Verb: coretypes.VerbList,
Category: coretypes.ActionCategoryAccessControl,
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
@@ -61,20 +72,46 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/users", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.CreateUser), handler.OpenAPIDef{
ID: "CreateUser",
Tags: []string{"users"},
Summary: "Create user",
Description: "This endpoint creates a user for the organization",
Request: new(authtypes.PostableUser),
RequestContentType: "application/json",
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPost).GetError(); err != nil {
if err := router.Handle("/api/v2/users", handler.New(
provider.authzMiddleware.CheckResources(provider.userHandler.CreateUser, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "CreateUser",
Tags: []string{"users"},
Summary: "Create user",
Description: "This endpoint creates a user for the organization",
Request: new(authtypes.PostableUser),
RequestContentType: "application/json",
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{
coretypes.ResourceUser.Scope(coretypes.VerbCreate),
coretypes.ResourceUser.Scope(coretypes.VerbAttach),
coretypes.ResourceRole.Scope(coretypes.VerbAttach),
}),
},
handler.WithResourceDefs(
handler.BasicResourceDef{
Resource: coretypes.ResourceUser,
Verb: coretypes.VerbCreate,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.ResponseJSONPath("data.id"),
Selector: coretypes.WildcardSelector,
},
handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryAccessControl,
SourceResource: coretypes.ResourceUser,
SourceIDs: coretypes.OneID(coretypes.ResponseJSONPath("data.id")),
SourceSelector: coretypes.WildcardSelector,
TargetResource: coretypes.ResourceRole,
TargetIDs: coretypes.BodyJSONArray("userRoles.#.id"),
TargetSelector: provider.roleSelector,
},
),
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
@@ -95,88 +132,152 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/users/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.GetUser), handler.OpenAPIDef{
ID: "GetUser",
Tags: []string{"users"},
Summary: "Get user by user id",
Description: "This endpoint returns the user by id",
Request: nil,
RequestContentType: "",
Response: new(authtypes.UserWithRoles),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v2/users/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.userHandler.GetUser, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "GetUser",
Tags: []string{"users"},
Summary: "Get user by user id",
Description: "This endpoint returns the user by id",
Request: nil,
RequestContentType: "",
Response: new(authtypes.UserWithRoles),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceUser.Scope(coretypes.VerbRead)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceUser,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/users/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.UpdateUser), handler.OpenAPIDef{
ID: "UpdateUser",
Tags: []string{"users"},
Summary: "Update user v2",
Description: "This endpoint updates the user by id",
Request: new(types.UpdatableUser),
RequestContentType: "application/json",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPut).GetError(); err != nil {
if err := router.Handle("/api/v2/users/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.userHandler.UpdateUser, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "UpdateUser",
Tags: []string{"users"},
Summary: "Update user v2",
Description: "This endpoint updates the user by id",
Request: new(types.UpdatableUser),
RequestContentType: "application/json",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceUser.Scope(coretypes.VerbUpdate)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceUser,
Verb: coretypes.VerbUpdate,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/users/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.DeleteUser), handler.OpenAPIDef{
ID: "DeleteUser",
Tags: []string{"users"},
Summary: "Delete user",
Description: "This endpoint deletes the user by id",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodDelete).GetError(); err != nil {
if err := router.Handle("/api/v2/users/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.userHandler.DeleteUser, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "DeleteUser",
Tags: []string{"users"},
Summary: "Delete user",
Description: "This endpoint deletes the user by id",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceUser.Scope(coretypes.VerbDelete)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceUser,
Verb: coretypes.VerbDelete,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/users/{id}/reset_password_tokens", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.GetResetPasswordToken), handler.OpenAPIDef{
ID: "GetResetPasswordToken",
Tags: []string{"users"},
Summary: "Get reset password token for a user",
Description: "This endpoint returns the existing reset password token for a user.",
Request: nil,
RequestContentType: "",
Response: new(types.ResetPasswordToken),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v2/users/{id}/reset_password_tokens", handler.New(
provider.authzMiddleware.CheckResources(provider.userHandler.GetResetPasswordToken, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "GetResetPasswordToken",
Tags: []string{"users"},
Summary: "Get reset password token for a user",
Description: "This endpoint returns the existing reset password token for a user.",
Request: nil,
RequestContentType: "",
Response: new(types.ResetPasswordToken),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceFactorPassword.Scope(coretypes.VerbList)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceFactorPassword,
Verb: coretypes.VerbList,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.ResponseJSONPath("data.id"),
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/users/{id}/reset_password_tokens", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.CreateResetPasswordToken), handler.OpenAPIDef{
ID: "CreateResetPasswordToken",
Tags: []string{"users"},
Summary: "Create or regenerate reset password token for a user",
Description: "This endpoint creates or regenerates a reset password token for a user. If a valid token exists, it is returned. If expired, a new one is created.",
Request: nil,
RequestContentType: "",
Response: new(types.ResetPasswordToken),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPut).GetError(); err != nil {
if err := router.Handle("/api/v2/users/{id}/reset_password_tokens", handler.New(
provider.authzMiddleware.CheckResources(provider.userHandler.CreateResetPasswordToken, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "CreateResetPasswordToken",
Tags: []string{"users"},
Summary: "Create or regenerate reset password token for a user",
Description: "This endpoint creates or regenerates a reset password token for a user. If a valid token exists, it is returned. If expired, a new one is created.",
Request: nil,
RequestContentType: "",
Response: new(types.ResetPasswordToken),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{
coretypes.ResourceMetaResourceFactorPassword.Scope(coretypes.VerbCreate),
coretypes.ResourceUser.Scope(coretypes.VerbAttach),
}),
},
handler.WithResourceDefs(
handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceFactorPassword,
Verb: coretypes.VerbCreate,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.ResponseJSONPath("data.id"),
Selector: coretypes.WildcardSelector,
},
handler.AttachDetachParentChildResourceDef{
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryAccessControl,
ParentResource: coretypes.ResourceUser,
ParentID: coretypes.PathParam("id"),
ParentSelector: coretypes.IDSelector,
ChildResource: coretypes.ResourceMetaResourceFactorPassword,
ChildIDs: coretypes.OneID(coretypes.ResponseJSONPath("data.id")),
},
),
)).Methods(http.MethodPut).GetError(); err != nil {
return err
}
@@ -209,7 +310,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
SecuritySchemes: []handler.OpenAPISecurityScheme{{Name: authtypes.IdentNProviderTokenizer.StringValue()}},
})).Methods(http.MethodPut).GetError(); err != nil {
return err
}
@@ -248,90 +349,190 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/users/{id}/roles", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.GetRolesByUserID), handler.OpenAPIDef{
ID: "GetRolesByUserID",
Tags: []string{"users"},
Summary: "Get user roles",
Description: "This endpoint returns the user roles by user id",
Request: nil,
RequestContentType: "",
Response: make([]*authtypes.Role, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v2/users/{id}/roles", handler.New(
provider.authzMiddleware.CheckResources(provider.userHandler.GetRolesByUserID, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "GetRolesByUserID",
Tags: []string{"users"},
Summary: "Get user roles",
Description: "This endpoint returns the user roles by user id",
Request: nil,
RequestContentType: "",
Response: make([]*authtypes.Role, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceUser.Scope(coretypes.VerbRead)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceUser,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/roles/{id}/users", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.GetUsersByRoleID), handler.OpenAPIDef{
ID: "GetUsersByRoleID",
Tags: []string{"users"},
Summary: "Get users by role id",
Description: "This endpoint returns the users having the role by role id",
Request: nil,
RequestContentType: "",
Response: make([]*types.User, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v2/roles/{id}/users", handler.New(
provider.authzMiddleware.CheckResources(provider.userHandler.GetUsersByRoleID, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "GetUsersByRoleID",
Tags: []string{"users"},
Summary: "Get users by role id",
Description: "This endpoint returns the users having the role by role id",
Request: nil,
RequestContentType: "",
Response: make([]*types.User, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbRead)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceRole,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: provider.roleSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/user_roles", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.CreateUserRole), handler.OpenAPIDef{
ID: "CreateUserRole",
Tags: []string{"users"},
Summary: "Create user role",
Description: "This endpoint assigns a role to a user",
Request: new(authtypes.PostableUserRole),
RequestContentType: "",
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPost).GetError(); err != nil {
if err := router.Handle("/api/v2/user_roles", handler.New(
provider.authzMiddleware.CheckResources(provider.userHandler.CreateUserRole, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "CreateUserRole",
Tags: []string{"users"},
Summary: "Create user role",
Description: "This endpoint assigns a role to a user",
Request: new(authtypes.PostableUserRole),
RequestContentType: "",
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceUser.Scope(coretypes.VerbAttach), coretypes.ResourceRole.Scope(coretypes.VerbAttach)}),
},
handler.WithResourceDefs(handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryAccessControl,
SourceResource: coretypes.ResourceUser,
SourceIDs: coretypes.OneID(coretypes.BodyJSONPath("userId")),
SourceSelector: coretypes.IDSelector,
TargetResource: coretypes.ResourceRole,
TargetIDs: coretypes.OneID(coretypes.BodyJSONPath("roleId")),
TargetSelector: provider.roleSelector,
}),
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/user_roles/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.GetUserRole), handler.OpenAPIDef{
ID: "GetUserRole",
Tags: []string{"users"},
Summary: "Get user role",
Description: "This endpoint gets an existing user role",
Request: nil,
RequestContentType: "",
Response: new(authtypes.UserRole),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v2/user_roles/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.userHandler.GetUserRole, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "GetUserRole",
Tags: []string{"users"},
Summary: "Get user role",
Description: "This endpoint gets an existing user role",
Request: nil,
RequestContentType: "",
Response: new(authtypes.UserRole),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceUser.Scope(coretypes.VerbRead)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceUser,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryAccessControl,
ID: provider.userRoleUserIDExtractor(),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/user_roles/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.DeleteUserRole), handler.OpenAPIDef{
ID: "DeleteUserRole",
Tags: []string{"users"},
Summary: "Delete user role",
Description: "This endpoint revokes a role from a user",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodDelete).GetError(); err != nil {
if err := router.Handle("/api/v2/user_roles/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.userHandler.DeleteUserRole, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "DeleteUserRole",
Tags: []string{"users"},
Summary: "Delete user role",
Description: "This endpoint revokes a role from a user",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceUser.Scope(coretypes.VerbDetach), coretypes.ResourceRole.Scope(coretypes.VerbDetach)}),
},
handler.WithResourceDefs(handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbDetach,
Category: coretypes.ActionCategoryAccessControl,
SourceResource: coretypes.ResourceUser,
SourceIDs: coretypes.OneID(provider.userRoleUserIDExtractor()),
SourceSelector: coretypes.IDSelector,
TargetResource: coretypes.ResourceRole,
TargetIDs: coretypes.OneID(provider.userRoleRoleIDExtractor()),
TargetSelector: provider.roleSelector,
}),
)).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
return nil
}
func (provider *provider) userRoleUserIDExtractor() coretypes.ResourceIDExtractor {
return coretypes.NewResourceIDExtractor(coretypes.PhaseRequest, func(ec coretypes.ExtractorContext) (string, error) {
if ec.Request == nil {
return "", nil
}
userRole, err := provider.userRoleFromRequest(ec.Request)
if err != nil {
return "", err
}
return userRole.UserID.String(), nil
})
}
func (provider *provider) userRoleRoleIDExtractor() coretypes.ResourceIDExtractor {
return coretypes.NewResourceIDExtractor(coretypes.PhaseRequest, func(ec coretypes.ExtractorContext) (string, error) {
if ec.Request == nil {
return "", nil
}
userRole, err := provider.userRoleFromRequest(ec.Request)
if err != nil {
return "", err
}
return userRole.RoleID.String(), nil
})
}
func (provider *provider) userRoleFromRequest(req *http.Request) (*authtypes.UserRole, error) {
claims, err := authtypes.ClaimsFromContext(req.Context())
if err != nil {
return nil, err
}
userRoleID, err := valuer.NewUUID(mux.Vars(req)["id"])
if err != nil {
return nil, err
}
return provider.userGetter.GetUserRoleByOrgIDAndID(req.Context(), valuer.MustNewUUID(claims.OrgID), userRoleID)
}

View File

@@ -9,8 +9,10 @@ var (
FeaturePutMetersInZeus = featuretypes.MustNewName("put_meters_in_zeus")
FeatureUseMeterReporter = featuretypes.MustNewName("use_meter_reporter")
FeatureUseJSONBody = featuretypes.MustNewName("use_json_body")
FeatureJSONBodyDualIngestion = featuretypes.MustNewName("json_body_dual_ingestion")
FeatureEnableMetricsReduction = featuretypes.MustNewName("enable_metrics_reduction")
FeatureResolveSemconvFamilies = featuretypes.MustNewName("resolve_semconv_families")
FeatureUseTraceAttributesJSON = featuretypes.MustNewName("use_trace_attributes_json")
)
func MustNewRegistry() featuretypes.Registry {
@@ -63,6 +65,14 @@ func MustNewRegistry() featuretypes.Registry {
DefaultVariant: featuretypes.MustNewName("disabled"),
Variants: featuretypes.NewBooleanVariants(),
},
&featuretypes.Feature{
Name: FeatureJSONBodyDualIngestion,
Kind: featuretypes.KindBoolean,
Stage: featuretypes.StageExperimental,
Description: "Controls whether the collector's normalize operator keeps the original log body so it is ingested into both the legacy body and the JSON body columns",
DefaultVariant: featuretypes.MustNewName("disabled"),
Variants: featuretypes.NewBooleanVariants(),
},
&featuretypes.Feature{
Name: FeatureEnableMetricsReduction,
Kind: featuretypes.KindBoolean,
@@ -75,7 +85,15 @@ func MustNewRegistry() featuretypes.Registry {
Name: FeatureResolveSemconvFamilies,
Kind: featuretypes.KindBoolean,
Stage: featuretypes.StageExperimental,
Description: "Controls whether trace queries resolve a semantic-convention name to all the spellings of its family",
Description: "Controls whether trace, log, and metric queries resolve a semantic-convention name to all the spellings of its family",
DefaultVariant: featuretypes.MustNewName("disabled"),
Variants: featuretypes.NewBooleanVariants(),
},
&featuretypes.Feature{
Name: FeatureUseTraceAttributesJSON,
Kind: featuretypes.KindBoolean,
Stage: featuretypes.StageExperimental,
Description: "Controls whether trace queries read span attributes from the JSON columns",
DefaultVariant: featuretypes.MustNewName("disabled"),
Variants: featuretypes.NewBooleanVariants(),
},

View File

@@ -56,20 +56,23 @@ type AttachDetachSiblingResourceDef struct {
}
func (def AttachDetachSiblingResourceDef) resolveRequest(ec coretypes.ExtractorContext) []coretypes.ResolvedResource {
return []coretypes.ResolvedResource{
coretypes.NewResolvedResourceWithTarget(
def.Verb,
def.Category,
def.SourceResource,
def.SourceIDs,
def.SourceSelector,
def.TargetResource,
def.TargetIDs,
def.TargetSelector,
false,
ec,
),
resolved := coretypes.NewResolvedResourceWithTarget(
def.Verb,
def.Category,
def.SourceResource,
def.SourceIDs,
def.SourceSelector,
def.TargetResource,
def.TargetIDs,
def.TargetSelector,
false,
ec,
)
if resolved.HasNoLinks() {
return nil
}
return []coretypes.ResolvedResource{resolved}
}
// AttachDetachParentChildResourceDef authz-checks only the parent; the child

View File

@@ -0,0 +1,71 @@
package handler
import (
"testing"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAttachDetachSiblingResourceDefResolvesNothingWithoutLinks(t *testing.T) {
def := AttachDetachSiblingResourceDef{
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryAccessControl,
SourceResource: coretypes.ResourceUser,
SourceIDs: coretypes.OneID(coretypes.ResponseJSONPath("data.id")),
SourceSelector: coretypes.WildcardSelector,
TargetResource: coretypes.ResourceRole,
TargetIDs: coretypes.BodyJSONArray("userRoles.#.id"),
TargetSelector: coretypes.IDSelector,
}
testCases := []struct {
name string
body string
expectedResolved int
expectedTargetIDs []string
}{
{name: "NoRolesKey_ResolvesNothing", body: `{"email":"jane@example.com"}`, expectedResolved: 0},
{name: "EmptyRoles_ResolvesNothing", body: `{"userRoles":[]}`, expectedResolved: 0},
{name: "OneRole_ResolvesOne", body: `{"userRoles":[{"id":"signoz-viewer"}]}`, expectedResolved: 1, expectedTargetIDs: []string{"signoz-viewer"}},
{name: "TwoRoles_ResolvesOneWithBothTargets", body: `{"userRoles":[{"id":"signoz-viewer"},{"id":"signoz-editor"}]}`, expectedResolved: 1, expectedTargetIDs: []string{"signoz-viewer", "signoz-editor"}},
{name: "EmptyRoleID_KeepsFailingClosed", body: `{"userRoles":[{"id":""}]}`, expectedResolved: 1, expectedTargetIDs: []string{""}},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
resolved := ResolveRequest([]ResourceDef{def}, coretypes.ExtractorContext{RequestBody: []byte(testCase.body)})
require.Len(t, resolved, testCase.expectedResolved)
if testCase.expectedResolved == 0 {
return
}
withTarget, ok := resolved[0].(coretypes.ResolvedResourceWithTargetResource)
require.True(t, ok)
assert.NoError(t, withTarget.Err())
assert.Equal(t, []string{""}, withTarget.SourceIDs())
assert.Equal(t, testCase.expectedTargetIDs, withTarget.TargetIDs())
})
}
}
func TestAttachDetachSiblingResourceDefKeepsEmptySingleID(t *testing.T) {
def := AttachDetachSiblingResourceDef{
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryAccessControl,
SourceResource: coretypes.ResourceUser,
SourceIDs: coretypes.OneID(coretypes.ResponseJSONPath("data.id")),
SourceSelector: coretypes.WildcardSelector,
TargetResource: coretypes.ResourceRole,
TargetIDs: coretypes.OneID(coretypes.BodyJSONPath("roleId")),
TargetSelector: coretypes.IDSelector,
}
resolved := ResolveRequest([]ResourceDef{def}, coretypes.ExtractorContext{RequestBody: []byte(`{"userId":"u1"}`)})
require.Len(t, resolved, 1)
withTarget, ok := resolved[0].(coretypes.ResolvedResourceWithTargetResource)
require.True(t, ok)
assert.Equal(t, []string{""}, withTarget.TargetIDs())
}

View File

@@ -435,6 +435,7 @@ func (m *module) buildFilterClause(ctx context.Context, orgID valuer.UUID, filte
whereClauseSelectors[idx].SelectorMatchType = telemetrytypes.FieldSelectorMatchTypeExact
}
whereClauseSelectors = querybuilder.ExpandKeySelectorsForFamilies(ctx, orgID, m.fl, whereClauseSelectors)
keys, _, err := m.telemetryMetadataStore.GetKeysMulti(ctx, orgID, whereClauseSelectors)
if err != nil {
return nil, err

View File

@@ -936,6 +936,7 @@ func (m *module) buildFilterClause(ctx context.Context, orgID valuer.UUID, filte
// whereClauseSelectors[idx].Source = query.Source
}
whereClauseSelectors = querybuilder.ExpandKeySelectorsForFamilies(ctx, orgID, m.fl, whereClauseSelectors)
keys, _, err := m.telemetryMetadataStore.GetKeysMulti(ctx, orgID, whereClauseSelectors)
if err != nil {
return nil, err

View File

@@ -184,11 +184,6 @@ func (handler *handler) UpdateUser(w http.ResponseWriter, r *http.Request) {
return
}
if userID == claims.UserID {
render.Error(w, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "users cannot call this api on self"))
return
}
updatableUser := new(types.UpdatableUser)
if err := json.NewDecoder(r.Body).Decode(&updatableUser); err != nil {
render.Error(w, err)
@@ -431,11 +426,6 @@ func (handler *handler) CreateUserRole(w http.ResponseWriter, r *http.Request) {
return
}
if req.UserID.String() == claims.UserID {
render.Error(w, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "users cannot call this api on self"))
return
}
userRole, err := handler.setter.AddUserRoleByRoleID(ctx, valuer.MustNewUUID(claims.OrgID), req.UserID, req.RoleID)
if err != nil {
render.Error(w, err)
@@ -492,11 +482,6 @@ func (handler *handler) DeleteUserRole(w http.ResponseWriter, r *http.Request) {
return
}
if userRole.UserID.String() == claims.UserID {
render.Error(w, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "users cannot call this api on self"))
return
}
if err := handler.setter.RemoveUserRole(ctx, valuer.MustNewUUID(claims.OrgID), userRole.UserID, userRole.RoleID); err != nil {
render.Error(w, err)
return

View File

@@ -0,0 +1,102 @@
package querier
import (
"context"
"testing"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/types/metrictypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes/telemetrytypestest"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// The metric metadata of a query on one name of a metric-name family comes
// from every name of the family: the temporality is Multiple when the names
// differ, and the reduced flag is set when any name has reduced data.
func TestResolveMetricMetadataReadsTheFamily(t *testing.T) {
testCases := []struct {
name string
temporalities map[string]metrictypes.Temporality
reduced map[string]bool
expectedTemporality metrictypes.Temporality
expectedReduced bool
}{
{
name: "SameTemporality_KeepsIt",
temporalities: map[string]metrictypes.Temporality{
"k8s.pod.cpu.usage": metrictypes.Cumulative,
"k8s.pod.cpu.utilization": metrictypes.Cumulative,
},
expectedTemporality: metrictypes.Cumulative,
},
{
name: "DifferentTemporalities_ReadAsMultiple",
temporalities: map[string]metrictypes.Temporality{
"k8s.pod.cpu.usage": metrictypes.Delta,
"k8s.pod.cpu.utilization": metrictypes.Cumulative,
},
expectedTemporality: metrictypes.Multiple,
},
{
name: "OnlyOldNameKnown_TakesItsTemporality",
temporalities: map[string]metrictypes.Temporality{
"k8s.pod.cpu.utilization": metrictypes.Delta,
},
expectedTemporality: metrictypes.Delta,
},
{
name: "OldNameReduced_MarksTheAggregationReduced",
temporalities: map[string]metrictypes.Temporality{
"k8s.pod.cpu.usage": metrictypes.Cumulative,
},
reduced: map[string]bool{"k8s.pod.cpu.utilization": true},
expectedTemporality: metrictypes.Cumulative,
expectedReduced: true,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
metadataStore := telemetrytypestest.NewMockMetadataStore()
metadataStore.TemporalityMap = testCase.temporalities
metadataStore.TypeMap = map[string]metrictypes.Type{
"k8s.pod.cpu.usage": metrictypes.GaugeType,
"k8s.pod.cpu.utilization": metrictypes.GaugeType,
}
metadataStore.ReducedMap = testCase.reduced
q := &querier{
logger: instrumentationtest.New().Logger(),
metadataStore: metadataStore,
fl: flaggertest.WithBooleanFlags(t, map[string]bool{
flagger.FeatureResolveSemconvFamilies.String(): true,
}),
}
queries := []qbtypes.QueryEnvelope{{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "A",
Signal: telemetrytypes.SignalMetrics,
Aggregations: []qbtypes.MetricAggregation{{
MetricName: "k8s.pod.cpu.usage",
TimeAggregation: metrictypes.TimeAggregationAvg,
SpaceAggregation: metrictypes.SpaceAggregationAvg,
}},
},
}}
missing, warnings, err := q.resolveMetricMetadata(context.Background(), valuer.UUID{}, queries, 0, 0, qbtypes.RequestTypeTimeSeries)
require.NoError(t, err)
assert.Empty(t, missing)
assert.Empty(t, warnings)
spec := queries[0].Spec.(qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation])
assert.Equal(t, testCase.expectedTemporality, spec.Aggregations[0].Temporality)
assert.Equal(t, testCase.expectedReduced, spec.Aggregations[0].Reduced)
})
}
}

View File

@@ -369,6 +369,8 @@ func (q *querier) populateQBEvent(event *qbtypes.QBEvent, queries []qbtypes.Quer
// resolved: never-seen metrics and dormant metrics (seen but no data in
// the query window).
// - err: Internal when a metadata fetch fails.
//
// Metric metadata resolves through every name of a metric-name family.
func (q *querier) resolveMetricMetadata(ctx context.Context, orgID valuer.UUID, queries []qbtypes.QueryEnvelope, start, end uint64, requestType qbtypes.RequestType) (missingMetricQueries []string, metricWarnings []string, err error) {
metricNames := make([]string, 0)
for idx := range queries {
@@ -381,7 +383,7 @@ func (q *querier) resolveMetricMetadata(ctx context.Context, orgID valuer.UUID,
}
for _, agg := range spec.Aggregations {
if agg.MetricName != "" {
metricNames = append(metricNames, agg.MetricName)
metricNames = append(metricNames, querybuilder.FamilyMetricNames(ctx, orgID, q.fl, agg.MetricName)...)
}
}
}
@@ -409,14 +411,16 @@ func (q *querier) resolveMetricMetadata(ctx context.Context, orgID valuer.UUID,
presentAggregations := make([]qbtypes.MetricAggregation, 0, len(spec.Aggregations))
for i := range spec.Aggregations {
familyNames := querybuilder.FamilyMetricNames(ctx, orgID, q.fl, spec.Aggregations[i].MetricName)
if spec.Aggregations[i].MetricName != "" && spec.Aggregations[i].Temporality == metrictypes.Unknown {
if temp, ok := metricTemporality[spec.Aggregations[i].MetricName]; ok && temp != metrictypes.Unknown {
spec.Aggregations[i].Temporality = temp
}
spec.Aggregations[i].Temporality = familyTemporality(metricTemporality, familyNames)
}
if spec.Aggregations[i].MetricName != "" && spec.Aggregations[i].Type == metrictypes.UnspecifiedType {
if foundMetricType, ok := metricTypes[spec.Aggregations[i].MetricName]; ok && foundMetricType != metrictypes.UnspecifiedType {
spec.Aggregations[i].Type = foundMetricType
for _, member := range familyNames {
if foundMetricType, ok := metricTypes[member]; ok && foundMetricType != metrictypes.UnspecifiedType {
spec.Aggregations[i].Type = foundMetricType
break
}
}
}
if spec.Aggregations[i].Type == metrictypes.UnspecifiedType {
@@ -434,8 +438,11 @@ func (q *querier) resolveMetricMetadata(ctx context.Context, orgID valuer.UUID,
return nil, nil, err
}
}
if reducedMetricsSet[spec.Aggregations[i].MetricName] {
spec.Aggregations[i].Reduced = true
for _, member := range familyNames {
if reducedMetricsSet[member] {
spec.Aggregations[i].Reduced = true
break
}
}
presentAggregations = append(presentAggregations, spec.Aggregations[i])
}
@@ -505,6 +512,26 @@ func (q *querier) resolveMetricMetadata(ctx context.Context, orgID valuer.UUID,
return missingMetricQueries, warnings, nil
}
// familyTemporality is the temporality the family names share, or Multiple
// when they differ.
func familyTemporality(temporalities map[string]metrictypes.Temporality, names []string) metrictypes.Temporality {
found := metrictypes.Unknown
for _, name := range names {
temporality, ok := temporalities[name]
if !ok || temporality == metrictypes.Unknown {
continue
}
if found == metrictypes.Unknown {
found = temporality
continue
}
if found != temporality {
return metrictypes.Multiple
}
}
return found
}
func (q *querier) QueryRawStream(ctx context.Context, orgID valuer.UUID, req *qbtypes.QueryRangeRequest, client *qbtypes.RawStream) {
// Coerce the window to epoch milliseconds up front (End may be 0 for the

View File

@@ -52,10 +52,10 @@ import (
"github.com/SigNoz/signoz/pkg/query-service/constants"
chErrors "github.com/SigNoz/signoz/pkg/query-service/errors"
"github.com/SigNoz/signoz/pkg/query-service/metrics"
"github.com/SigNoz/signoz/pkg/query-service/model"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/query-service/utils"
"github.com/SigNoz/signoz/pkg/semconv"
)
const (
@@ -3202,7 +3202,14 @@ func (r *ClickHouseReader) GetMetricAttributeValues(ctx context.Context, orgID v
query = query + fmt.Sprintf(" LIMIT %d;", req.Limit)
}
names := []string{req.AggregateAttribute}
names = append(names, metrics.GetTransitionedMetric(req.AggregateAttribute))
current := semconv.Current(semconv.KindMetric, telemetrytypes.FieldKeySelector{
Name: req.AggregateAttribute,
Signal: telemetrytypes.SignalMetrics,
FieldContext: telemetrytypes.FieldContextMetric,
})
if current != req.AggregateAttribute {
names = append(names, current)
}
rows, err = r.db.Query(ctx, query, req.FilterAttributeKey, names, req.FilterAttributeKey, fmt.Sprintf("%%%s%%", req.SearchText), common.PastDayRoundOff())

View File

@@ -220,7 +220,26 @@ func (ic *LogParsingPipelineController) ValidatePipelines(ctx context.Context,
return err
}
func (ic *LogParsingPipelineController) getNormalizePipeline() pipelinetypes.GettablePipeline {
// withNormalizePipeline places normalize where the read path dictates. Ahead of user pipelines
// when queries run on body_v2 (use_json_body), so operators see the body the explorer shows.
// After them when dual ingestion alone writes body_v2, so operators keep seeing the raw body
// users still query. Absent when neither flag is on.
func (ic *LogParsingPipelineController) withNormalizePipeline(ctx context.Context, orgID valuer.UUID, pipelines []pipelinetypes.GettablePipeline) []pipelinetypes.GettablePipeline {
evalCtx := featuretypes.NewFlaggerEvaluationContext(orgID)
dualIngestion := ic.fl.BooleanOrEmpty(ctx, flagger.FeatureJSONBodyDualIngestion, evalCtx)
switch {
case ic.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, evalCtx):
return append([]pipelinetypes.GettablePipeline{getNormalizePipeline(dualIngestion)}, pipelines...)
case dualIngestion:
return append(slices.Clone(pipelines), getNormalizePipeline(true))
default:
return pipelines
}
}
// stashOriginalBody makes normalize carry the pre-normalization body in an internal attribute
// for the exporter to restore into the legacy body column.
func getNormalizePipeline(stashOriginalBody bool) pipelinetypes.GettablePipeline {
return pipelinetypes.GettablePipeline{
StoreablePipeline: pipelinetypes.StoreablePipeline{
Name: "Default Pipeline - PreProcessing Body",
@@ -239,10 +258,11 @@ func (ic *LogParsingPipelineController) getNormalizePipeline() pipelinetypes.Get
},
Config: []pipelinetypes.PipelineOperator{
{
ID: uuid.NewString(),
Type: "normalize",
Enabled: true,
If: "body != nil",
ID: uuid.NewString(),
Type: "normalize",
Enabled: true,
If: "body != nil",
JSONBodyDualIngestion: stashOriginalBody,
},
},
}
@@ -351,8 +371,11 @@ func (ic *LogParsingPipelineController) PreviewLogsPipelines(
}
// The collector gets the same pipeline prepended over opamp; see RecommendAgentConfig.
// Under dual ingestion alone it runs after user operators and only feeds body_v2, which
// the explorer does not show yet, so the preview leaves it out. The original-body stash
// is left off: the preview has no exporter to restore and strip it.
if ic.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(orgID)) {
pipelines = append([]pipelinetypes.GettablePipeline{ic.getNormalizePipeline()}, pipelines...)
pipelines = append([]pipelinetypes.GettablePipeline{getNormalizePipeline(false)}, pipelines...)
}
result, collectorLogs, err := SimulatePipelinesProcessing(ctx, pipelines, request.Logs)
@@ -373,16 +396,16 @@ func (pc *LogParsingPipelineController) AgentFeatureType() agentConf.AgentFeatur
// Implements agentConf.AgentFeature interface.
// RecommendAgentConfig generates the collector config to be sent to agents.
// The normalize pipeline (when use_json_body feature flag is on) is injected here, after
// rawPipelineData is serialized. So it is only present in the config sent to
// The normalize pipeline (when use_json_body or json_body_dual_ingestion is on) is placed
// here, after rawPipelineData is serialized. So it is only present in the config sent to
// the collector and never persisted to the database as part of the user's pipeline list.
//
// NOTE: The configId sent to agents is derived from the pipeline version number
// (e.g. "LogPipelines:5"), not the YAML content. If server-side logic changes
// the generated YAML without bumping the version (e.g. toggling the use_json_body
// flag or updating operator IfExpressions), agents that already applied that version will
// not re-apply the new config. In such cases, users must save a new pipeline version
// via the API to force agents to pick up the change.
// the generated YAML without bumping the version (e.g. toggling the use_json_body or
// json_body_dual_ingestion flags or updating operator IfExpressions), agents that already
// applied that version will not re-apply the new config. In such cases, users must save a
// new pipeline version via the API to force agents to pick up the change.
func (pc *LogParsingPipelineController) RecommendAgentConfig(
orgId valuer.UUID,
currentConfYaml []byte,
@@ -408,10 +431,8 @@ func (pc *LogParsingPipelineController) RecommendAgentConfig(
return nil, "", err
}
if pc.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(orgId)) {
// add default normalize pipeline at the beginning, only for sending to collector
enrichedPipelines = append([]pipelinetypes.GettablePipeline{pc.getNormalizePipeline()}, enrichedPipelines...)
}
// normalize is only for sending to the collector, never persisted
enrichedPipelines = pc.withNormalizePipeline(ctx, orgId, enrichedPipelines)
updatedConf, err := GenerateCollectorConfigWithPipelines(currentConfYaml, enrichedPipelines)
if err != nil {

View File

@@ -1,14 +0,0 @@
package metrics
var MetricsUnderTransition = map[string]string{
"k8s.pod.cpu.utilization": "k8s.pod.cpu.usage",
"k8s.node.cpu.utilization": "k8s.node.cpu.usage",
"container.cpu.utilization": "container.cpu.usage",
}
func GetTransitionedMetric(metric string) string {
if transitionedMetric, ok := MetricsUnderTransition[metric]; ok {
return transitionedMetric
}
return metric
}

View File

@@ -10,8 +10,9 @@ import (
"log/slog"
"github.com/SigNoz/signoz/pkg/query-service/constants"
"github.com/SigNoz/signoz/pkg/query-service/metrics"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/semconv"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
// ValidateAndCastValue validates and casts the value of a key to the corresponding data type of the key
@@ -234,12 +235,12 @@ func ClickHouseFormattedValue(v interface{}) string {
func ClickHouseFormattedMetricNames(v interface{}) string {
if name, ok := v.(string); ok {
transitionedMetrics := metrics.GetTransitionedMetric(name)
if transitionedMetrics != name {
return ClickHouseFormattedValue([]interface{}{transitionedMetrics})
} else {
return ClickHouseFormattedValue([]interface{}{name})
}
current := semconv.Current(semconv.KindMetric, telemetrytypes.FieldKeySelector{
Name: name,
Signal: telemetrytypes.SignalMetrics,
FieldContext: telemetrytypes.FieldContextMetric,
})
return ClickHouseFormattedValue([]interface{}{current})
}
return ClickHouseFormattedValue(v)

View File

@@ -1,6 +1,7 @@
package utils
import (
"github.com/stretchr/testify/assert"
"reflect"
"testing"
@@ -483,3 +484,21 @@ func TestGetEpochNanoSecs(t *testing.T) {
})
}
}
// The legacy readers redirect an old metric name to its current name.
func TestClickHouseFormattedMetricNames(t *testing.T) {
testCases := []struct {
name string
metric string
expected string
}{
{name: "OldName_RedirectsToCurrent", metric: "k8s.pod.cpu.utilization", expected: "['k8s.pod.cpu.usage']"},
{name: "CurrentName_Unchanged", metric: "k8s.pod.cpu.usage", expected: "['k8s.pod.cpu.usage']"},
{name: "OutsideFamily_Unchanged", metric: "http.server.duration", expected: "['http.server.duration']"},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.expected, ClickHouseFormattedMetricNames(testCase.metric))
})
}
}

View File

@@ -4,57 +4,61 @@ import (
"context"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/semconv"
"github.com/SigNoz/signoz/pkg/types/featuretypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
// semconvFamiliesEnabled evaluates the resolve_semconv_families flag for the
// SemconvFamiliesEnabled evaluates the resolve_semconv_families flag for the
// org. A nil flagger means off, so a caller without family support stays
// literal by default.
func semconvFamiliesEnabled(ctx context.Context, orgID valuer.UUID, fl flagger.Flagger) bool {
func SemconvFamiliesEnabled(ctx context.Context, orgID valuer.UUID, fl flagger.Flagger) bool {
if fl == nil {
return false
}
return fl.BooleanOrEmpty(ctx, flagger.FeatureResolveSemconvFamilies, featuretypes.NewFlaggerEvaluationContext(orgID))
}
// ExpandKeySelectorsForFamilies adds selectors for the other members of each
// semantic-convention family that a selector names. The metadata fetched for
// a query then contains each spelling that MatchingLogicalFields can group.
// This function is the prefetch of the resolution layer: statement builders
// call it after they derive the selectors, and the metadata store stays
// family-blind (autocomplete responses keep the literal spelling that the
// user typed). It does nothing when the resolve_semconv_families flag is off
// for the org. Only trace selectors expand today, because that matches the
// family support. Fuzzy (search-style) selectors never expand.
// ExpandKeySelectorsForFamilies adds a selector for each other spelling of
// the family a selector names, so the fetched metadata holds every member.
// Off, or for a fuzzy selector, it returns the selectors as they are. A call
// site without the prefetch stays literal and never merges wrong.
func ExpandKeySelectorsForFamilies(ctx context.Context, orgID valuer.UUID, fl flagger.Flagger, selectors []*telemetrytypes.FieldKeySelector) []*telemetrytypes.FieldKeySelector {
if !semconvFamiliesEnabled(ctx, orgID, fl) {
if !SemconvFamiliesEnabled(ctx, orgID, fl) {
return selectors
}
// The same name under another context, signal, or data type needs its
// own siblings. The metric context is not part of the key: metric callers
// duplicate the selectors per metric name after this expansion.
type identity struct {
signal telemetrytypes.Signal
fieldContext telemetrytypes.FieldContext
fieldDataType telemetrytypes.FieldDataType
name string
}
out := selectors
seen := make(map[string]bool, len(selectors))
seen := make(map[identity]bool, len(selectors))
for _, selector := range selectors {
seen[selector.Name] = true
seen[identity{selector.Signal, selector.FieldContext, selector.FieldDataType, selector.Name}] = true
}
for _, selector := range selectors {
if selector.Signal != telemetrytypes.SignalTraces ||
selector.SelectorMatchType == telemetrytypes.FieldSelectorMatchTypeFuzzy {
if selector.SelectorMatchType == telemetrytypes.FieldSelectorMatchTypeFuzzy {
continue
}
members := semconv.Members(semconv.KindAttribute, telemetrytypes.FieldKeySelector{
Name: selector.Name,
Signal: selector.Signal,
FieldContext: selector.FieldContext,
members := familySpellings(telemetrytypes.FieldKeySelector{
Name: selector.Name,
Signal: selector.Signal,
FieldContext: selector.FieldContext,
MetricContext: selector.MetricContext,
})
for _, member := range members {
if seen[member] {
id := identity{selector.Signal, selector.FieldContext, selector.FieldDataType, member}
if seen[id] {
continue
}
seen[member] = true
seen[id] = true
expanded := *selector
expanded.Name = member
out = append(out, &expanded)

View File

@@ -20,8 +20,9 @@ import (
// member reads, current member first. It is present when any member is
// present, and absent when no member is present. A row without any member
// reads what the tail of the merge reads: the sentinel for a string family,
// NULL for the others. A member with a value map reads in the current
// vocabulary.
// NULL for the others. When every member reads its sentinel as a value, so
// does the family, and the keyless contract of the signal survives the
// merge. A member with a value map reads in the current vocabulary.
func LogicalRead(ctx context.Context, q qbtypes.QueryInfo, storage qbtypes.Storage, logical *telemetrytypes.LogicalField) (qbtypes.Read, error) {
if !logical.IsFamily() {
return memberRead(ctx, q, storage, logical, 0)
@@ -35,7 +36,7 @@ func LogicalRead(ctx context.Context, q qbtypes.QueryInfo, storage qbtypes.Stora
reads = append(reads, read)
}
merged := qbtypes.Read{WhenAbsent: familyAbsence(logical)}
merged := qbtypes.Read{WhenAbsent: familyAbsence(logical, reads)}
guards := make([]string, 0, len(reads))
for _, read := range reads {
guards = append(guards, read.Presence)
@@ -97,10 +98,16 @@ func clickHouseStringArray(values []string) string {
}
// familyAbsence is what the merged read yields for a row without any
// member: the sentinel tail of a string family, NULL for the others.
func familyAbsence(logical *telemetrytypes.LogicalField) qbtypes.Absent {
if logical.FieldDataType == telemetrytypes.FieldDataTypeString {
return qbtypes.AbsentIsSentinel
// member: the sentinel tail of a string family, NULL for the others. When
// every member's sentinel is a value, the tail is one too.
func familyAbsence(logical *telemetrytypes.LogicalField, reads []qbtypes.Read) qbtypes.Absent {
if logical.FieldDataType != telemetrytypes.FieldDataTypeString {
return qbtypes.AbsentIsNull
}
return qbtypes.AbsentIsNull
for _, read := range reads {
if read.WhenAbsent != qbtypes.AbsentIsValue {
return qbtypes.AbsentIsSentinel
}
}
return qbtypes.AbsentIsValue
}

View File

@@ -48,7 +48,7 @@ func TestFamiliesOffByDefault(t *testing.T) {
}},
}
fields := matchingLogicalFields(false, telemetrytypes.SignalUnspecified, &telemetrytypes.TelemetryFieldKey{Name: "deployment.environment.name"}, fieldKeys)
fields := matchingLogicalFields(false, telemetrytypes.SignalUnspecified, nil, &telemetrytypes.TelemetryFieldKey{Name: "deployment.environment.name"}, fieldKeys)
require.Len(t, fields, 1)
assert.False(t, fields[0].IsFamily())
assert.Equal(t, []string{"deployment.environment.name"}, memberNames(fields[0]))
@@ -76,7 +76,7 @@ func TestMatchingLogicalFieldsGroupsFamilyMembers(t *testing.T) {
}
for _, requested := range []string{"deployment.environment.name", "deployment.environment"} {
fields := matchingLogicalFields(true, telemetrytypes.SignalUnspecified, &telemetrytypes.TelemetryFieldKey{Name: requested}, fieldKeys)
fields := matchingLogicalFields(true, telemetrytypes.SignalUnspecified, nil, &telemetrytypes.TelemetryFieldKey{Name: requested}, fieldKeys)
require.Len(t, fields, 1, "a family is one logical field, requested via %s", requested)
logical := fields[0]
assert.Equal(t, requested, logical.Name, "response identity is the requested spelling")
@@ -106,7 +106,7 @@ func TestMatchingLogicalFieldsOrdersMembersByFamilyRank(t *testing.T) {
}},
}
fields := matchingLogicalFields(true, telemetrytypes.SignalUnspecified, &telemetrytypes.TelemetryFieldKey{
fields := matchingLogicalFields(true, telemetrytypes.SignalUnspecified, nil, &telemetrytypes.TelemetryFieldKey{
Name: "deployment.environment.name",
FieldContext: telemetrytypes.FieldContextResource,
}, fieldKeys)
@@ -115,26 +115,92 @@ func TestMatchingLogicalFieldsOrdersMembersByFamilyRank(t *testing.T) {
assert.Equal(t, []string{"resource.deployment.environment.name", "deployment.environment"}, memberNames(fields[0]))
}
// Non-trace signals have no family support: the requested spelling stays
// literal, and a family member name never pulls in its siblings.
func TestMatchingLogicalFieldsKeepsLogsLiteral(t *testing.T) {
logsKey := func(name string) *telemetrytypes.TelemetryFieldKey {
return &telemetrytypes.TelemetryFieldKey{
Name: name,
// Log entries group into families exactly like trace entries.
func TestMatchingLogicalFieldsGroupsLogEntries(t *testing.T) {
fieldKeys := map[string][]*telemetrytypes.TelemetryFieldKey{
"deployment.environment.name": {{
Name: "deployment.environment.name",
Signal: telemetrytypes.SignalLogs,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
}
}
fieldKeys := map[string][]*telemetrytypes.TelemetryFieldKey{
"deployment.environment.name": {logsKey("deployment.environment.name")},
"deployment.environment": {logsKey("deployment.environment")},
}},
"deployment.environment": {{
Name: "deployment.environment",
Signal: telemetrytypes.SignalLogs,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
}},
}
fields := matchingLogicalFields(true, telemetrytypes.SignalUnspecified, &telemetrytypes.TelemetryFieldKey{Name: "deployment.environment.name"}, fieldKeys)
fields := matchingLogicalFields(true, telemetrytypes.SignalLogs, nil, &telemetrytypes.TelemetryFieldKey{Name: "deployment.environment.name"}, fieldKeys)
require.Len(t, fields, 1)
assert.False(t, fields[0].IsFamily())
assert.Equal(t, []string{"deployment.environment.name"}, memberNames(fields[0]))
assert.True(t, fields[0].IsFamily())
assert.Equal(t, []string{"deployment.environment.name", "deployment.environment"}, memberNames(fields[0]))
}
// Metric entries of a span-metrics metric group across the plain and the
// resource_ spellings of the family, in member-major order: every spelling
// of the current name precedes the first spelling of the old one.
func TestMatchingLogicalFieldsGroupsMetricSpellings(t *testing.T) {
fieldKeys := map[string][]*telemetrytypes.TelemetryFieldKey{
"deployment.environment.name": {{
Name: "deployment.environment.name",
Signal: telemetrytypes.SignalMetrics,
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeString,
}},
"resource_deployment.environment.name": {{
Name: "resource_deployment.environment.name",
Signal: telemetrytypes.SignalMetrics,
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeString,
}},
"deployment.environment": {{
Name: "deployment.environment",
Signal: telemetrytypes.SignalMetrics,
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeString,
}},
"resource_deployment.environment": {{
Name: "resource_deployment.environment",
Signal: telemetrytypes.SignalMetrics,
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeString,
}},
}
fields := matchingLogicalFields(true, telemetrytypes.SignalMetrics, &telemetrytypes.MetricContext{MetricName: "signoz_calls_total"}, &telemetrytypes.TelemetryFieldKey{Name: "deployment.environment"}, fieldKeys)
require.Len(t, fields, 1)
assert.True(t, fields[0].IsFamily())
assert.Equal(t, []string{
"deployment.environment.name", "resource_deployment.environment.name",
"deployment.environment", "resource_deployment.environment",
}, memberNames(fields[0]))
}
// A non-string entry never joins a family: the merged read has no common
// ClickHouse type across the storages.
func TestMatchingLogicalFieldsKeepsNumberEntriesSingle(t *testing.T) {
fieldKeys := map[string][]*telemetrytypes.TelemetryFieldKey{
"deployment.environment.name": {{
Name: "deployment.environment.name",
Signal: telemetrytypes.SignalMetrics,
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeNumber,
}},
"deployment.environment": {{
Name: "deployment.environment",
Signal: telemetrytypes.SignalMetrics,
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeNumber,
}},
}
fields := matchingLogicalFields(true, telemetrytypes.SignalMetrics, nil, &telemetrytypes.TelemetryFieldKey{Name: "deployment.environment"}, fieldKeys)
require.Len(t, fields, 2)
for _, logical := range fields {
assert.False(t, logical.IsFamily())
}
}
// A family and a genuine same-name collision stack cleanly: the family stays
@@ -165,7 +231,7 @@ func TestResolveLogicalFieldsKeepsFamilyThroughAmbiguity(t *testing.T) {
}
requested := &telemetrytypes.TelemetryFieldKey{Name: "deployment.environment.name"}
fields := matchingLogicalFields(true, telemetrytypes.SignalUnspecified, requested, fieldKeys)
fields := matchingLogicalFields(true, telemetrytypes.SignalUnspecified, nil, requested, fieldKeys)
require.Len(t, fields, 2, "resource family + attribute collision")
resolved, warning := ResolveLogicalFields(requested, fields)
@@ -229,7 +295,7 @@ func TestMatchingLogicalFieldsNeverMergesAcrossDataTypes(t *testing.T) {
}},
}
fields := matchingLogicalFields(true, telemetrytypes.SignalUnspecified, &telemetrytypes.TelemetryFieldKey{Name: "deployment.environment.name"}, fieldKeys)
fields := matchingLogicalFields(true, telemetrytypes.SignalUnspecified, nil, &telemetrytypes.TelemetryFieldKey{Name: "deployment.environment.name"}, fieldKeys)
require.Len(t, fields, 2)
for _, logical := range fields {
assert.False(t, logical.IsFamily())
@@ -254,11 +320,15 @@ func TestExpandKeySelectorsForFamilies(t *testing.T) {
"service.name",
"deployment.environment.name",
"deployment.environment",
}, names, "one sibling selector for the trace family member; logs and non-family names untouched")
"deployment.environment",
}, names, "each selector identity gets its own sibling, and a non-family name stays untouched")
sibling := expanded[len(expanded)-1]
assert.Equal(t, telemetrytypes.SignalTraces, sibling.Signal)
assert.Equal(t, telemetrytypes.FieldSelectorMatchTypeExact, sibling.SelectorMatchType)
tracesSibling := expanded[len(expanded)-2]
assert.Equal(t, telemetrytypes.SignalTraces, tracesSibling.Signal)
assert.Equal(t, telemetrytypes.FieldSelectorMatchTypeExact, tracesSibling.SelectorMatchType)
logsSibling := expanded[len(expanded)-1]
assert.Equal(t, telemetrytypes.SignalLogs, logsSibling.Signal,
"a same-named selector under another signal must not take the sibling")
}
func TestExpandKeySelectorsForFamiliesDeduplicatesAndSkipsFuzzy(t *testing.T) {

View File

@@ -0,0 +1,64 @@
package querybuilder
import (
"context"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/semconv"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
// The span-metrics processor in signoz-otel-collector writes resource
// attributes as labels with a resource_ prefix on these metrics only. The
// signoz_latency histogram is stored as its dotted sub-metrics.
const spanMetricsResourcePrefix = "resource_"
var spanMetrics = map[string]struct{}{
"signoz_calls_total": {},
"signoz_latency": {},
"signoz_latency.bucket": {},
"signoz_latency.sum": {},
"signoz_latency.count": {},
"signoz_latency.min": {},
"signoz_latency.max": {},
"signoz_db_latency_sum": {},
"signoz_db_latency_count": {},
"signoz_external_call_latency_sum": {},
"signoz_external_call_latency_count": {},
}
// MetricLabelSpellings returns the family members of selector.Name, current
// first, and on a span-metrics metric each member with the resource_ prefix
// too. A name outside a family, or one the selector leaves ambiguous, is
// returned as it is.
func MetricLabelSpellings(selector telemetrytypes.FieldKeySelector) []string {
members := semconv.Members(semconv.KindAttribute, selector)
if len(members) <= 1 {
return []string{selector.Name}
}
if selector.MetricContext == nil {
return members
}
if _, ok := spanMetrics[selector.MetricContext.MetricName]; !ok {
return members
}
spellings := make([]string, 0, len(members)*2)
for _, member := range members {
spellings = append(spellings, member, spanMetricsResourcePrefix+member)
}
return spellings
}
// FamilyMetricNames returns the metric-name family of metricName when the
// flag is on for the org, else the name alone.
func FamilyMetricNames(ctx context.Context, orgID valuer.UUID, fl flagger.Flagger, metricName string) []string {
if !SemconvFamiliesEnabled(ctx, orgID, fl) {
return []string{metricName}
}
return semconv.Members(semconv.KindMetric, telemetrytypes.FieldKeySelector{
Name: metricName,
Signal: telemetrytypes.SignalMetrics,
FieldContext: telemetrytypes.FieldContextMetric,
})
}

View File

@@ -0,0 +1,100 @@
package querybuilder
import (
"context"
"testing"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
)
func TestMetricLabelSpellingsReturnsTheFamilyMembers(t *testing.T) {
selector := telemetrytypes.FieldKeySelector{
Name: "deployment.environment",
Signal: telemetrytypes.SignalMetrics,
MetricContext: &telemetrytypes.MetricContext{MetricName: "k8s.pod.cpu.usage"},
}
assert.Equal(t, []string{"deployment.environment.name", "deployment.environment"}, MetricLabelSpellings(selector))
}
func TestMetricLabelSpellingsAddsTheResourcePrefixForSpanMetrics(t *testing.T) {
testCases := []struct {
name string
metric string
expected []string
}{
{
name: "SpanMetric_ReadsPlainAndResourceSpellings",
metric: "signoz_calls_total",
expected: []string{
"deployment.environment.name", "resource_deployment.environment.name",
"deployment.environment", "resource_deployment.environment",
},
},
{
name: "LatencyHistogramSubMetric_ReadsPlainAndResourceSpellings",
metric: "signoz_latency.bucket",
expected: []string{
"deployment.environment.name", "resource_deployment.environment.name",
"deployment.environment", "resource_deployment.environment",
},
},
{
name: "OtherSignozMetric_ReadsPlainSpellings",
metric: "signoz_other_metric",
expected: []string{"deployment.environment.name", "deployment.environment"},
},
{
name: "UnderscoreLatencySubMetric_ReadsPlainSpellings",
metric: "signoz_latency_bucket",
expected: []string{"deployment.environment.name", "deployment.environment"},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
selector := telemetrytypes.FieldKeySelector{
Name: "deployment.environment",
Signal: telemetrytypes.SignalMetrics,
MetricContext: &telemetrytypes.MetricContext{MetricName: testCase.metric},
}
assert.Equal(t, testCase.expected, MetricLabelSpellings(selector))
})
}
}
// A requested name is never rewritten: a resource_ spelling that is not a
// family member stays literal, on a span metric too.
func TestMetricLabelSpellingsKeepsThePrefixedRequestLiteral(t *testing.T) {
selector := telemetrytypes.FieldKeySelector{
Name: "resource_deployment.environment",
Signal: telemetrytypes.SignalMetrics,
MetricContext: &telemetrytypes.MetricContext{MetricName: "signoz_calls_total"},
}
assert.Equal(t, []string{"resource_deployment.environment"}, MetricLabelSpellings(selector))
}
func TestMetricLabelSpellingsStaysLiteralOutsideTheVocabulary(t *testing.T) {
selector := telemetrytypes.FieldKeySelector{
Name: "http.route",
Signal: telemetrytypes.SignalMetrics,
}
assert.Equal(t, []string{"http.route"}, MetricLabelSpellings(selector))
}
func TestFamilyMetricNames(t *testing.T) {
on := flaggertest.WithBooleanFlags(t, map[string]bool{
flagger.FeatureResolveSemconvFamilies.String(): true,
})
assert.Equal(t, []string{"k8s.pod.cpu.usage", "k8s.pod.cpu.utilization"}, FamilyMetricNames(context.Background(), valuer.UUID{}, on, "k8s.pod.cpu.utilization"))
assert.Equal(t, []string{"k8s.pod.cpu.usage", "k8s.pod.cpu.utilization"}, FamilyMetricNames(context.Background(), valuer.UUID{}, on, "k8s.pod.cpu.usage"))
assert.Equal(t, []string{"http.server.duration"}, FamilyMetricNames(context.Background(), valuer.UUID{}, on, "http.server.duration"))
off := flaggertest.WithBooleanFlags(t, map[string]bool{})
assert.Equal(t, []string{"k8s.pod.cpu.utilization"}, FamilyMetricNames(context.Background(), valuer.UUID{}, off, "k8s.pod.cpu.utilization"))
}

View File

@@ -21,10 +21,12 @@ func NewQueryInfo(ctx context.Context, orgID valuer.UUID, fl flagger.Flagger, si
EndNs: endNs,
Signal: signal,
Metric: metric,
FamiliesOn: semconvFamiliesEnabled(ctx, orgID, fl),
FamiliesOn: SemconvFamiliesEnabled(ctx, orgID, fl),
}
if fl != nil {
q.BodyJSONOn = fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(orgID))
evalCtx := featuretypes.NewFlaggerEvaluationContext(orgID)
q.BodyJSONOn = fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, evalCtx)
q.TraceAttrsJSONOn = fl.BooleanOrEmpty(ctx, flagger.FeatureUseTraceAttributesJSON, evalCtx)
}
return q
}
@@ -59,7 +61,7 @@ func Resolve(
traits := storage.Traits()
lookup := key
matches := matchingLogicalFields(q.FamiliesOn, q.Signal, key, fieldKeys)
matches := matchingLogicalFields(q.FamiliesOn, q.Signal, q.Metric, key, fieldKeys)
if len(matches) == 0 && slices.Contains(traits.OwnContexts, key.FieldContext) {
// a column the storage knows under the key's own context is the key
// as written, and only a miss corrects to the bare spelling
@@ -69,7 +71,7 @@ func Resolve(
}
}
lookup = telemetrytypes.NewTelemetryFieldKey(key.Name, telemetrytypes.FieldContextUnspecified, key.FieldDataType)
matches = matchingLogicalFields(q.FamiliesOn, q.Signal, lookup, fieldKeys)
matches = matchingLogicalFields(q.FamiliesOn, q.Signal, q.Metric, lookup, fieldKeys)
}
resolved := qbtypes.Resolved{Key: key, Ambiguous: len(matches) > 1}

View File

@@ -1012,43 +1012,36 @@ func assignIfEmpty(s *string, value string) {
}
}
// familyMemberNames returns the physical spellings to look up for the
// referenced key: the semantic-convention family members (current-first) when
// families are on and the query can resolve to traces, else just the requested
// name. Only the traces storage understands families today. Logs and
// metrics keep the requested spelling until theirs land.
func familyMemberNames(familiesOn bool, signal telemetrytypes.Signal, field *telemetrytypes.TelemetryFieldKey) []string {
if !familiesOn {
return []string{field.Name}
// familySpellings returns the storage spellings for the selector. Metrics
// add the span-metrics label layout.
func familySpellings(selector telemetrytypes.FieldKeySelector) []string {
if selector.Signal == telemetrytypes.SignalMetrics {
return MetricLabelSpellings(selector)
}
if signal != telemetrytypes.SignalUnspecified && signal != telemetrytypes.SignalTraces {
return []string{field.Name}
}
return semconv.Members(semconv.KindAttribute, telemetrytypes.FieldKeySelector{
Name: field.Name,
Signal: telemetrytypes.SignalTraces,
FieldContext: field.FieldContext,
})
return semconv.Members(semconv.KindAttribute, selector)
}
// matchingLogicalFields resolves the referenced key against the metadata map
// into logical fields, honoring any context/data type the user specified.
//
// Physical keys that are members of one semantic-convention family (traces
// only today) group into one logical field per (signal, context, data type)
// identity, members ordered current-first. Every other matching key becomes
// its own single-member logical field. Ambiguity is the length of the
// returned slice: one family is one element and is never ambiguous with
// itself, but the slice can hold several logical fields, including several
// family fields, one per identity, when the family exists under more than
// one context or data type. Members alias the metadata map entries; nothing
// is copied or mutated.
//
// Family grouping only happens when families are on for the query. Off,
// every match stays a single-member logical field.
func matchingLogicalFields(familiesOn bool, signal telemetrytypes.Signal, field *telemetrytypes.TelemetryFieldKey, fieldKeys map[string][]*telemetrytypes.TelemetryFieldKey) []*telemetrytypes.LogicalField {
members := familyMemberNames(familiesOn, signal, field)
matches := collectMemberMatches(field, members, fieldKeys)
// matchingLogicalFields resolves the key against the metadata map. Members
// of one family group into one logical field per (signal, context, data
// type) identity, current first. Every other match is its own single-member
// field. The length of the result is the ambiguity: a family is never
// ambiguous with itself. Members alias the map entries. With families off,
// only the requested name is looked up. The key's own signal wins over the
// query's signal.
func matchingLogicalFields(familiesOn bool, signal telemetrytypes.Signal, metric *telemetrytypes.MetricContext, field *telemetrytypes.TelemetryFieldKey, fieldKeys map[string][]*telemetrytypes.TelemetryFieldKey) []*telemetrytypes.LogicalField {
members := []string{field.Name}
if familiesOn {
if field.Signal != telemetrytypes.SignalUnspecified {
signal = field.Signal
}
members = familySpellings(telemetrytypes.FieldKeySelector{
Name: field.Name,
Signal: signal,
FieldContext: field.FieldContext,
MetricContext: metric,
})
}
matches := collectMemberMatches(field, members, metric, fieldKeys)
return groupIntoLogicalFields(field.Name, len(members) > 1, matches)
}
@@ -1060,48 +1053,35 @@ type memberMatch struct {
rank int
}
// matchesRequestedIdentity reports whether the entry fits the context and data
// type that the request specified; unspecified matches any. A context-prefixed
// lookup already matched the context through the lookup key itself.
func matchesRequestedIdentity(field, item *telemetrytypes.TelemetryFieldKey, contextMatched bool) bool {
if !contextMatched && field.FieldContext != telemetrytypes.FieldContextUnspecified && field.FieldContext != item.FieldContext {
return false
}
if field.FieldDataType != telemetrytypes.FieldDataTypeUnspecified && field.FieldDataType != item.FieldDataType {
return false
}
return true
}
// inFamilyScope reports whether a match found under a sibling member name is
// legitimate: the entry must be trace metadata, and the member must be in the
// family of the requested name for the entry's context. A member lookup can
// otherwise find a same-named field in a scope where the family does not
// apply.
func inFamilyScope(field, item *telemetrytypes.TelemetryFieldKey, memberName string) bool {
if item.Signal != telemetrytypes.SignalTraces {
return false
}
return slices.Contains(semconv.Members(semconv.KindAttribute, telemetrytypes.FieldKeySelector{
Name: field.Name,
Signal: telemetrytypes.SignalTraces,
FieldContext: item.FieldContext,
}), memberName)
}
// collectMemberMatches finds the metadata entries for every member spelling:
// first under the member names, then under their context-prefixed spellings
// (a context can be a legitimate part of a stored name, e.g. `attribute.key`).
func collectMemberMatches(field *telemetrytypes.TelemetryFieldKey, members []string, fieldKeys map[string][]*telemetrytypes.TelemetryFieldKey) []memberMatch {
// collectMemberMatches finds the metadata entries for every member spelling,
// under the member name and under its context-prefixed spelling, because a
// context can be part of a stored name. An unspecified context or data type
// matches any.
func collectMemberMatches(field *telemetrytypes.TelemetryFieldKey, members []string, metric *telemetrytypes.MetricContext, fieldKeys map[string][]*telemetrytypes.TelemetryFieldKey) []memberMatch {
matches := make([]memberMatch, 0)
collect := func(lookupName string, rank int, memberName string, contextMatched bool) {
for _, item := range fieldKeys[lookupName] {
if !matchesRequestedIdentity(field, item, contextMatched) {
// A context-prefixed lookup matched the context through the key.
if !contextMatched && field.FieldContext != telemetrytypes.FieldContextUnspecified && field.FieldContext != item.FieldContext {
continue
}
if memberName != field.Name && !inFamilyScope(field, item, memberName) {
if field.FieldDataType != telemetrytypes.FieldDataTypeUnspecified && field.FieldDataType != item.FieldDataType {
continue
}
if memberName != field.Name {
// A sibling can match a same-named field where the family does
// not apply, so the member must be a spelling of the requested
// name for the entry's own signal and context.
spellings := familySpellings(telemetrytypes.FieldKeySelector{
Name: field.Name,
Signal: item.Signal,
FieldContext: item.FieldContext,
MetricContext: metric,
})
if !slices.Contains(spellings, memberName) {
continue
}
}
matches = append(matches, memberMatch{key: item, rank: rank})
}
}
@@ -1117,18 +1097,22 @@ func collectMemberMatches(field *telemetrytypes.TelemetryFieldKey, members []str
return matches
}
// groupIntoLogicalFields turns matches into logical fields. Trace entries in
// family mode group by their (signal, context, data type) identity; every
// other entry becomes its own single-member field. Members sort by family
// rank at the end: precedence is a property of the family, not of the order
// in which the lookups found the members.
// groupIntoLogicalFields groups a string entry of a family signal under the
// resource or attribute context by its (signal, context, data type)
// identity. Every other entry is its own single-member field. Members sort
// by family rank, not by lookup order.
func groupIntoLogicalFields(requestedName string, familyMode bool, matches []memberMatch) []*telemetrytypes.LogicalField {
fields := make([]*telemetrytypes.LogicalField, 0, len(matches))
groups := make(map[string]*telemetrytypes.LogicalField)
ranks := make(map[*telemetrytypes.TelemetryFieldKey]int)
for _, match := range matches {
if !familyMode || match.key.Signal != telemetrytypes.SignalTraces {
familySignal := match.key.Signal == telemetrytypes.SignalTraces ||
match.key.Signal == telemetrytypes.SignalLogs ||
match.key.Signal == telemetrytypes.SignalMetrics
familyContext := match.key.FieldContext == telemetrytypes.FieldContextResource ||
match.key.FieldContext == telemetrytypes.FieldContextAttribute
if !familyMode || !familySignal || !familyContext || match.key.FieldDataType != telemetrytypes.FieldDataTypeString {
fields = append(fields, telemetrytypes.SingleLogicalField(requestedName, match.key))
continue
}
@@ -1145,7 +1129,10 @@ func groupIntoLogicalFields(requestedName string, familyMode bool, matches []mem
groups[identity] = group
fields = append(fields, group)
}
if groupHasMemberNamed(group, match.key.Name) {
alreadyMember := slices.ContainsFunc(group.Members, func(member *telemetrytypes.TelemetryFieldKey) bool {
return member.Name == match.key.Name
})
if alreadyMember {
continue
}
ranks[match.key] = match.rank
@@ -1159,12 +1146,3 @@ func groupIntoLogicalFields(requestedName string, familyMode bool, matches []mem
}
return fields
}
func groupHasMemberNamed(group *telemetrytypes.LogicalField, name string) bool {
for _, member := range group.Members {
if member.Name == name {
return true
}
}
return false
}

View File

@@ -589,7 +589,7 @@ func TestVisitKey(t *testing.T) {
// and decides not-found handling. Replay that here against the generic
// builder behavior (error unless the key is ignored). The test maps carry
// no signal, so every logical field is single-member and flattens losslessly.
matching := matchingLogicalFields(false, telemetrytypes.SignalUnspecified, key, tt.fieldKeys)
matching := matchingLogicalFields(false, telemetrytypes.SignalUnspecified, nil, key, tt.fieldKeys)
resolved, warning := ResolveLogicalFields(key, matching)
keys := make([]*telemetrytypes.TelemetryFieldKey, 0, len(resolved))
for _, logical := range resolved {

View File

@@ -2,21 +2,37 @@
package semconv
import "github.com/SigNoz/signoz/pkg/types/telemetrytypes"
var families = []Family{
{
Current: "db.system.name",
Old: []string{"db.system"},
Kind: KindAttribute,
Contexts: nil,
Signals: nil,
ApplyToMetrics: nil,
current: "container.cpu.usage",
kind: KindMetric,
members: []Member{
{name: "container.cpu.utilization"},
},
},
{
Current: "deployment.environment.name",
Old: []string{"deployment.environment"},
Kind: KindAttribute,
Contexts: nil,
Signals: nil,
ApplyToMetrics: nil,
current: "deployment.environment.name",
kind: KindAttribute,
members: []Member{
{name: "deployment.environment"},
},
contexts: []telemetrytypes.FieldContext{telemetrytypes.FieldContextAttribute, telemetrytypes.FieldContextResource},
signals: []telemetrytypes.Signal{telemetrytypes.SignalLogs, telemetrytypes.SignalMetrics, telemetrytypes.SignalTraces},
},
{
current: "k8s.node.cpu.usage",
kind: KindMetric,
members: []Member{
{name: "k8s.node.cpu.utilization"},
},
},
{
current: "k8s.pod.cpu.usage",
kind: KindMetric,
members: []Member{
{name: "k8s.pod.cpu.utilization"},
},
},
}

View File

@@ -1,6 +1,7 @@
package semconv
import (
"iter"
"slices"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
@@ -14,16 +15,47 @@ type Kind struct {
valuer.String
}
// Family is one logical telemetry field. Old is ordered from the most recent
// predecessor to the oldest one and therefore also defines fallback order.
// Member is one historical spelling with the scope of its rename edges. A
// nil axis is unconstrained.
type Member struct {
name string
contexts []telemetrytypes.FieldContext
signals []telemetrytypes.Signal
applyToMetrics []string
}
func (m Member) Name() string {
return m.name
}
// Family is one logical telemetry field. Members run from the most recent
// predecessor to the oldest, which is the fallback order. The family-level
// contexts and signals come from the overlay and gate where the family
// resolves. The member scopes come from the schema edges and gate which
// members apply.
type Family struct {
Current string
Old []string
Kind Kind
Contexts []telemetrytypes.FieldContext
Signals []telemetrytypes.Signal
ApplyToMetrics []string
ValueMap map[string]string
current string
kind Kind
members []Member
contexts []telemetrytypes.FieldContext
signals []telemetrytypes.Signal
}
func (f Family) Current() string {
return f.current
}
func (f Family) Kind() Kind {
return f.kind
}
// Old returns the historical spellings in fallback order.
func (f Family) Old() []string {
names := make([]string, len(f.members))
for i, member := range f.members {
names[i] = member.name
}
return names
}
var (
@@ -38,90 +70,143 @@ func (Kind) Enum() []any {
return []any{KindAttribute, KindMetric}
}
// Lookup returns the enabled family containing selector.Name for kind. The
// returned family must not be modified.
func Lookup(kind Kind, selector telemetrytypes.FieldKeySelector) (Family, bool) {
idx, ok := lookupIndex(kind, selector)
if !ok {
return Family{}, false
}
return families[idx], true
}
// Members returns the current name first, followed by historical names in
// fallback order. A name outside an enabled family is returned unchanged. The
// returned slice must not be modified.
// Members returns the current name first, followed by the historical spellings
// admitted for the selector, in fallback order. A name outside an enabled
// family is returned unchanged. So is a name the selector leaves ambiguous.
// The returned slice must not be modified.
func Members(kind Kind, selector telemetrytypes.FieldKeySelector) []string {
idx, ok := lookupIndex(kind, selector)
if !ok {
return []string{selector.Name}
}
return familyMembers[idx]
return admittedMembers(idx, selector)
}
func admittedMembers(idx int, selector telemetrytypes.FieldKeySelector) []string {
admitted := 0
for _, member := range families[idx].members {
if memberAdmits(member, selector) {
admitted++
}
}
if admitted == len(families[idx].members) {
return familyMembers[idx]
}
names := make([]string, 0, admitted+1)
names = append(names, families[idx].current)
for _, member := range families[idx].members {
if memberAdmits(member, selector) {
names = append(names, member.name)
}
}
return names
}
// Current returns the current name for selector.Name, or the input name when
// it does not belong to an enabled family.
// it does not resolve to a family.
func Current(kind Kind, selector telemetrytypes.FieldKeySelector) string {
idx, ok := lookupIndex(kind, selector)
if !ok {
return selector.Name
}
return families[idx].Current
return families[idx].current
}
// All returns every enabled family. The returned slice and families must not be
// modified.
func All() []Family {
return families
func All() iter.Seq[Family] {
return func(yield func(Family) bool) {
for _, family := range families {
if !yield(family) {
return
}
}
}
}
func buildIndexes() (map[string][]int, [][]string) {
index := make(map[string][]int)
members := make([][]string, len(families))
add := func(name string, i int) {
if !slices.Contains(index[name], i) {
index[name] = append(index[name], i)
}
}
for i, family := range families {
members[i] = make([]string, 0, len(family.Old)+1)
members[i] = append(members[i], family.Current)
members[i] = append(members[i], family.Old...)
index[family.Current] = append(index[family.Current], i)
for _, old := range family.Old {
index[old] = append(index[old], i)
members[i] = make([]string, 0, len(family.members)+1)
members[i] = append(members[i], family.current)
add(family.current, i)
for _, member := range family.members {
members[i] = append(members[i], member.name)
add(member.name, i)
}
}
return index, members
}
// lookupIndex returns the one family that admits selector.Name for kind.
// An axis the selector leaves empty constrains nothing. When more than one
// family is admitted, the name stays literal.
func lookupIndex(kind Kind, selector telemetrytypes.FieldKeySelector) (int, bool) {
found, foundIdx := 0, 0
for _, idx := range memberToFamilies[selector.Name] {
if matchesSelector(families[idx], kind, selector) {
return idx, true
if familyAdmits(families[idx], kind, selector) {
found++
foundIdx = idx
}
}
return 0, false
if found != 1 {
return 0, false
}
return foundIdx, true
}
func matchesSelector(family Family, kind Kind, selector telemetrytypes.FieldKeySelector) bool {
if family.Kind != kind {
// familyAdmits reports whether the family gate admits the selector and the
// name is the current name or an admitted member.
func familyAdmits(family Family, kind Kind, selector telemetrytypes.FieldKeySelector) bool {
if family.kind != kind {
return false
}
if selector.Signal != telemetrytypes.SignalUnspecified && len(family.Signals) > 0 {
if !slices.Contains(family.Signals, selector.Signal) {
return false
if !axisAdmits(family.signals, selector.Signal, telemetrytypes.SignalUnspecified) {
return false
}
if !axisAdmits(family.contexts, selector.FieldContext, telemetrytypes.FieldContextUnspecified) {
return false
}
if selector.Name == family.current {
for _, member := range family.members {
if memberAdmits(member, selector) {
return true
}
}
return false
}
for _, member := range family.members {
if member.name == selector.Name && memberAdmits(member, selector) {
return true
}
}
return false
}
if selector.FieldContext != telemetrytypes.FieldContextUnspecified && len(family.Contexts) > 0 {
if !slices.Contains(family.Contexts, selector.FieldContext) {
return false
}
// memberAdmits reports whether the member applies for the selector. An empty
// axis on either side admits.
func memberAdmits(member Member, selector telemetrytypes.FieldKeySelector) bool {
if !axisAdmits(member.signals, selector.Signal, telemetrytypes.SignalUnspecified) {
return false
}
if selector.Signal == telemetrytypes.SignalMetrics && len(family.ApplyToMetrics) > 0 {
if selector.MetricContext == nil {
return false
}
return slices.Contains(family.ApplyToMetrics, selector.MetricContext.MetricName)
if !axisAdmits(member.contexts, selector.FieldContext, telemetrytypes.FieldContextUnspecified) {
return false
}
if len(member.applyToMetrics) > 0 &&
selector.MetricContext != nil && selector.MetricContext.MetricName != "" &&
!slices.Contains(member.applyToMetrics, selector.MetricContext.MetricName) {
return false
}
return true
}
func axisAdmits[T comparable](scope []T, value T, unspecified T) bool {
if len(scope) == 0 || value == unspecified {
return true
}
return slices.Contains(scope, value)
}

View File

@@ -78,3 +78,116 @@ func TestMembersReturnsInputWhenKindDoesNotMatch(t *testing.T) {
"an attribute family must not match a metric-name lookup",
)
}
func TestFamilySignalsGateResolution(t *testing.T) {
swapFamilies(t, []Family{{
current: "gated.current",
kind: KindAttribute,
members: []Member{{name: "gated.old"}},
signals: []telemetrytypes.Signal{telemetrytypes.SignalLogs, telemetrytypes.SignalTraces},
}})
metrics := telemetrytypes.FieldKeySelector{Name: "gated.old", Signal: telemetrytypes.SignalMetrics}
logs := telemetrytypes.FieldKeySelector{Name: "gated.old", Signal: telemetrytypes.SignalLogs}
assert.Equal(t, []string{"gated.old"}, Members(KindAttribute, metrics),
"a family gated to traces and logs must stay literal for metrics")
assert.Equal(t, []string{"gated.current", "gated.old"}, Members(KindAttribute, logs),
"the gate admits the signals it lists")
}
func TestMetricNameFamilyResolves(t *testing.T) {
selector := telemetrytypes.FieldKeySelector{Name: "k8s.pod.cpu.utilization", Signal: telemetrytypes.SignalMetrics}
assert.Equal(t, []string{"k8s.pod.cpu.usage", "k8s.pod.cpu.utilization"}, Members(KindMetric, selector))
assert.Equal(t, "k8s.pod.cpu.usage", Current(KindMetric, selector))
assert.Equal(t, []string{"k8s.pod.cpu.utilization"}, Members(KindAttribute, selector),
"a metric-name family must not match an attribute lookup")
}
func TestMembersReturnsSharedSliceForUnscopedFamily(t *testing.T) {
selector := telemetrytypes.FieldKeySelector{Name: "deployment.environment", Signal: telemetrytypes.SignalTraces}
first := Members(KindAttribute, selector)
second := Members(KindAttribute, selector)
assert.Equal(t, &first[0], &second[0],
"a family whose members all admit must return the precomputed slice, not a copy")
}
func TestAllIteratesEnabledFamilies(t *testing.T) {
currents := []string{}
for family := range All() {
currents = append(currents, family.Current())
}
assert.Contains(t, currents, "deployment.environment.name")
assert.Contains(t, currents, "k8s.pod.cpu.usage")
}
// swapFamilies replaces the generated table for one test so scoped-member and
// fan-out behavior can be pinned without enabling such families for real.
func swapFamilies(t *testing.T, replacement []Family) {
t.Helper()
prevFamilies, prevIndex, prevMembers := families, memberToFamilies, familyMembers
families = replacement
memberToFamilies, familyMembers = buildIndexes()
t.Cleanup(func() {
families, memberToFamilies, familyMembers = prevFamilies, prevIndex, prevMembers
})
}
func TestFanOutResolvesOnlyWithEnoughInformation(t *testing.T) {
swapFamilies(t, []Family{
{
current: "cpu.mode",
kind: KindAttribute,
members: []Member{{name: "state", applyToMetrics: []string{"system.cpu.time"}}},
},
{
current: "db.client.connection.state",
kind: KindAttribute,
members: []Member{{name: "state", applyToMetrics: []string{"db.client.connections.usage"}}},
},
})
ambiguous := telemetrytypes.FieldKeySelector{Name: "state", Signal: telemetrytypes.SignalMetrics}
assert.Equal(t, []string{"state"}, Members(KindAttribute, ambiguous),
"without a metric name, a fanned-out member admits several families and must stay literal")
pinned := ambiguous
pinned.MetricContext = &telemetrytypes.MetricContext{MetricName: "system.cpu.time"}
assert.Equal(t, []string{"cpu.mode", "state"}, Members(KindAttribute, pinned),
"the metric name disambiguates the fan-out")
outside := ambiguous
outside.MetricContext = &telemetrytypes.MetricContext{MetricName: "http.server.duration"}
assert.Equal(t, []string{"state"}, Members(KindAttribute, outside),
"a metric outside every apply_to_metrics list resolves no family")
}
func TestMemberScopesFilterMembers(t *testing.T) {
swapFamilies(t, []Family{{
current: "user_agent.original",
kind: KindAttribute,
members: []Member{
{name: "http.user_agent", contexts: []telemetrytypes.FieldContext{telemetrytypes.FieldContextAttribute}, signals: []telemetrytypes.Signal{telemetrytypes.SignalTraces}},
{name: "browser.user_agent", contexts: []telemetrytypes.FieldContext{telemetrytypes.FieldContextResource}},
},
}})
resource := telemetrytypes.FieldKeySelector{
Name: "user_agent.original",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextResource,
}
assert.Equal(t, []string{"user_agent.original", "browser.user_agent"}, Members(KindAttribute, resource),
"a strict resource lookup must not include the span-only member")
attribute := resource
attribute.FieldContext = telemetrytypes.FieldContextAttribute
assert.Equal(t, []string{"user_agent.original", "http.user_agent"}, Members(KindAttribute, attribute),
"a strict attribute lookup must not include the resource-only member")
strictResourceOldSpan := resource
strictResourceOldSpan.Name = "http.user_agent"
assert.Equal(t, []string{"http.user_agent"}, Members(KindAttribute, strictResourceOldSpan),
"an old spelling outside its own scope stays literal")
}

View File

@@ -94,6 +94,7 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta
struct{ querier.Handler }{},
struct{ serviceaccount.Handler }{},
struct{ serviceaccount.Getter }{},
struct{ user.Getter }{},
struct{ factory.Handler }{},
struct{ cloudintegration.Handler }{},
struct{ rulestatehistory.Handler }{},

View File

@@ -258,6 +258,7 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewAddNotificationChannelTuplesFactory(sqlstore),
sqlmigration.NewAddAIObservabilityQuickFiltersFactory(sqlstore),
sqlmigration.NewAddChannelSpecFactory(sqlschema),
sqlmigration.NewAddUserTuplesFactory(sqlstore),
)
}
@@ -353,6 +354,7 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
handlers.QuerierHandler,
handlers.ServiceAccountHandler,
modules.ServiceAccountGetter,
modules.UserGetter,
handlers.RegistryHandler,
handlers.CloudIntegrationHandler,
handlers.RuleStateHistory,

View File

@@ -0,0 +1,141 @@
package sqlmigration
import (
"context"
"database/sql"
"time"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/oklog/ulid/v2"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect"
"github.com/uptrace/bun/migrate"
)
func NewAddUserTuplesFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("add_user_tuples"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addUserTuples{sqlstore: sqlstore}, nil
})
}
type addUserTuples struct {
sqlstore sqlstore.SQLStore
}
func (migration *addUserTuples) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *addUserTuples) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
var storeID string
err = tx.QueryRowContext(ctx, `SELECT id FROM store WHERE name = ? LIMIT 1`, "signoz").Scan(&storeID)
if err != nil {
return err
}
var orgIDs []string
err = tx.NewSelect().
Table("organizations").
Column("id").
Scan(ctx, &orgIDs)
if err != nil && err != sql.ErrNoRows {
return err
}
isPG := migration.sqlstore.BunDB().Dialect().Name() == dialect.PG
// user and factor-password moved from the legacy AdminAccess gate to
// CheckResources. Existing organizations never had these tuples written;
// only new organizations receive them from the managed-role registry at bootstrap.
tuples := []migrationTuple{
{authtypes.SigNozAdminRoleName, "user", "user", "create"},
{authtypes.SigNozAdminRoleName, "user", "user", "list"},
{authtypes.SigNozAdminRoleName, "user", "user", "read"},
{authtypes.SigNozAdminRoleName, "user", "user", "update"},
{authtypes.SigNozAdminRoleName, "user", "user", "delete"},
{authtypes.SigNozAdminRoleName, "user", "user", "attach"},
{authtypes.SigNozAdminRoleName, "user", "user", "detach"},
{authtypes.SigNozAdminRoleName, "metaresource", "factor-password", "read"},
{authtypes.SigNozAdminRoleName, "metaresource", "factor-password", "create"},
{authtypes.SigNozAdminRoleName, "metaresource", "factor-password", "list"},
}
for _, orgID := range orgIDs {
for _, tuple := range tuples {
entropy := ulid.DefaultEntropy()
now := time.Now().UTC()
tupleID := ulid.MustNew(ulid.Timestamp(now), entropy).String()
objectID := "organization/" + orgID + "/" + tuple.objectName + "/*"
roleSubject := "organization/" + orgID + "/role/" + tuple.roleName
if isPG {
user := "role:" + roleSubject + "#assignee"
result, err := tx.ExecContext(ctx, `
INSERT INTO tuple (store, object_type, object_id, relation, _user, user_type, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, object_type, object_id, relation, _user) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, user, "userset", tupleID, now,
)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
continue
}
_, err = tx.ExecContext(ctx, `
INSERT INTO changelog (store, object_type, object_id, relation, _user, operation, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, user, 0, tupleID, now,
)
if err != nil {
return err
}
} else {
result, err := tx.ExecContext(ctx, `
INSERT INTO tuple (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, user_type, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", "userset", tupleID, now,
)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
continue
}
_, err = tx.ExecContext(ctx, `
INSERT INTO changelog (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, operation, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", 0, tupleID, now,
)
if err != nil {
return err
}
}
}
}
return tx.Commit()
}
func (migration *addUserTuples) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -0,0 +1,256 @@
package logsstatementbuilder
import (
"context"
"testing"
"time"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/statementbuilder"
"github.com/SigNoz/signoz/pkg/telemetryschema/logstelemetryschema"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes/telemetrytypestest"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/require"
)
// A filter on either spelling of an enabled family compiles to one merged
// condition over the log resource maps. The flag default keeps it literal.
func TestStatementBuilderResolvesLogFamilies(t *testing.T) {
releaseTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)
releaseTimeNano := uint64(releaseTime.UnixNano())
testCases := []struct {
name string
familyOn bool
expected string
}{
{
name: "FamiliesOn",
familyOn: true,
expected: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (COALESCE(NULLIF(simpleJSONExtractString(labels, 'deployment.environment.name'), ''), NULLIF(simpleJSONExtractString(labels, 'deployment.environment'), ''), '') = ? AND (labels LIKE ? OR labels LIKE ?) AND (labels LIKE ? OR labels LIKE ?)) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? ORDER BY __result_0 DESC",
},
{
name: "FamiliesOff",
familyOn: false,
expected: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (simpleJSONExtractString(labels, 'deployment.environment') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? ORDER BY __result_0 DESC",
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
fl := flaggertest.WithBooleanFlags(t, map[string]bool{
flagger.FeatureResolveSemconvFamilies.String(): testCase.familyOn,
})
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
keys := logstelemetryschema.BuildCompleteFieldKeyMap(releaseTime)
for _, name := range []string{"deployment.environment.name", "deployment.environment"} {
keys[name] = []*telemetrytypes.TelemetryFieldKey{{
Name: name,
Signal: telemetrytypes.SignalLogs,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
}}
}
mockMetadataStore.KeysMap = keys
storage := logstelemetryschema.NewStorage()
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalLogs)
statementBuilder := NewLogQueryStatementBuilder(
instrumentationtest.New().ToProviderSettings(),
mockMetadataStore, storage, aggExprRewriter,
logstelemetryschema.DefaultFullTextColumn, fl, nil,
statementbuilder.Config{SkipResourceFingerprint: statementbuilder.SkipResourceFingerprint{Enabled: false, Threshold: 100000}},
)
query := qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Aggregations: []qbtypes.LogAggregation{{Expression: "count()"}},
Filter: &qbtypes.Filter{Expression: "resource.deployment.environment = 'production'"},
}
q, err := statementBuilder.Build(context.Background(), valuer.UUID{},
releaseTimeNano+uint64(24*time.Hour.Nanoseconds()),
releaseTimeNano+uint64(48*time.Hour.Nanoseconds()),
qbtypes.RequestTypeScalar, query, nil)
require.NoError(t, err)
require.Equal(t, testCase.expected, q.Query)
})
}
}
// The predicate of a filtered aggregation resolves the family exactly like
// the main WHERE clause.
func TestStatementBuilderResolvesLogFamilyFilteredAggregation(t *testing.T) {
releaseTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)
releaseTimeNano := uint64(releaseTime.UnixNano())
testCases := []struct {
name string
familyOn bool
expected string
}{
{name: "FamiliesOn", familyOn: true, expected: "SELECT countIf((COALESCE(NULLIF(multiIf(resource.`deployment.environment.name` IS NOT NULL, resource.`deployment.environment.name`::String, mapContains(resources_string, 'deployment.environment.name'), resources_string['deployment.environment.name'], NULL), ''), NULLIF(multiIf(resource.`deployment.environment` IS NOT NULL, resource.`deployment.environment`::String, mapContains(resources_string, 'deployment.environment'), resources_string['deployment.environment'], NULL), ''), '') = ? AND (multiIf(resource.`deployment.environment.name` IS NOT NULL, resource.`deployment.environment.name`::String, mapContains(resources_string, 'deployment.environment.name'), resources_string['deployment.environment.name'], NULL) IS NOT NULL OR multiIf(resource.`deployment.environment` IS NOT NULL, resource.`deployment.environment`::String, mapContains(resources_string, 'deployment.environment'), resources_string['deployment.environment'], NULL) IS NOT NULL))) AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? ORDER BY __result_0 DESC"},
{name: "FamiliesOff", familyOn: false, expected: "SELECT countIf(multiIf(resource.`deployment.environment.name` IS NOT NULL, resource.`deployment.environment.name`::String, mapContains(resources_string, 'deployment.environment.name'), resources_string['deployment.environment.name'], NULL) = ?) AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? ORDER BY __result_0 DESC"},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
fl := flaggertest.WithBooleanFlags(t, map[string]bool{
flagger.FeatureResolveSemconvFamilies.String(): testCase.familyOn,
})
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
keys := logstelemetryschema.BuildCompleteFieldKeyMap(releaseTime)
for _, name := range []string{"deployment.environment.name", "deployment.environment"} {
keys[name] = []*telemetrytypes.TelemetryFieldKey{{
Name: name,
Signal: telemetrytypes.SignalLogs,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
}}
}
mockMetadataStore.KeysMap = keys
storage := logstelemetryschema.NewStorage()
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalLogs)
statementBuilder := NewLogQueryStatementBuilder(
instrumentationtest.New().ToProviderSettings(),
mockMetadataStore, storage, aggExprRewriter,
logstelemetryschema.DefaultFullTextColumn, fl, nil,
statementbuilder.Config{SkipResourceFingerprint: statementbuilder.SkipResourceFingerprint{Enabled: false, Threshold: 100000}},
)
query := qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Aggregations: []qbtypes.LogAggregation{{Expression: "countIf(deployment.environment.name = 'production')"}},
}
q, err := statementBuilder.Build(context.Background(), valuer.UUID{},
releaseTimeNano+uint64(24*time.Hour.Nanoseconds()),
releaseTimeNano+uint64(48*time.Hour.Nanoseconds()),
qbtypes.RequestTypeScalar, query, nil)
require.NoError(t, err)
require.Equal(t, testCase.expected, q.Query)
})
}
}
// The mid-migration state: metadata holds one spelling of the family, and
// the query names the other. The filter and the group by both read the one
// stored spelling.
func TestStatementBuilderResolvesSingleSpellingAcrossNames(t *testing.T) {
releaseTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)
releaseTimeNano := uint64(releaseTime.UnixNano())
testCases := []struct {
name string
stored string
queried string
expected string
}{
{name: "OldDataQueriedByCurrentName", stored: "deployment.environment", queried: "deployment.environment.name", expected: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (simpleJSONExtractString(labels, 'deployment.environment') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT toString(multiIf(resource.`deployment.environment` IS NOT NULL, resource.`deployment.environment`::String, mapContains(resources_string, 'deployment.environment'), resources_string['deployment.environment'], NULL)) AS `__GROUP_BY_KEY_0_deployment.environment.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY `__GROUP_BY_KEY_0_deployment.environment.name` ORDER BY __result_0 DESC"},
{name: "CurrentDataQueriedByOldName", stored: "deployment.environment.name", queried: "deployment.environment", expected: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (simpleJSONExtractString(labels, 'deployment.environment.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT toString(multiIf(resource.`deployment.environment.name` IS NOT NULL, resource.`deployment.environment.name`::String, mapContains(resources_string, 'deployment.environment.name'), resources_string['deployment.environment.name'], NULL)) AS `__GROUP_BY_KEY_0_deployment.environment`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY `__GROUP_BY_KEY_0_deployment.environment` ORDER BY __result_0 DESC"},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
fl := flaggertest.WithBooleanFlags(t, map[string]bool{
flagger.FeatureResolveSemconvFamilies.String(): true,
})
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
keys := logstelemetryschema.BuildCompleteFieldKeyMap(releaseTime)
keys[testCase.stored] = []*telemetrytypes.TelemetryFieldKey{{
Name: testCase.stored,
Signal: telemetrytypes.SignalLogs,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
}}
mockMetadataStore.KeysMap = keys
storage := logstelemetryschema.NewStorage()
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalLogs)
statementBuilder := NewLogQueryStatementBuilder(
instrumentationtest.New().ToProviderSettings(),
mockMetadataStore, storage, aggExprRewriter,
logstelemetryschema.DefaultFullTextColumn, fl, nil,
statementbuilder.Config{SkipResourceFingerprint: statementbuilder.SkipResourceFingerprint{Enabled: false, Threshold: 100000}},
)
query := qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Aggregations: []qbtypes.LogAggregation{{Expression: "count()"}},
Filter: &qbtypes.Filter{Expression: testCase.queried + " = 'production'"},
GroupBy: []qbtypes.GroupByKey{
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: testCase.queried}},
},
}
q, err := statementBuilder.Build(context.Background(), valuer.UUID{},
releaseTimeNano+uint64(24*time.Hour.Nanoseconds()),
releaseTimeNano+uint64(48*time.Hour.Nanoseconds()),
qbtypes.RequestTypeScalar, query, nil)
require.NoError(t, err)
require.Equal(t, testCase.expected, q.Query)
})
}
}
// Group by resolves the family exactly like the filter. The merged column
// reads the spellings current-first with empty falling through, and a row
// with no member keeps the NULL group of a single key.
func TestStatementBuilderResolvesLogFamilyGroupBy(t *testing.T) {
releaseTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)
releaseTimeNano := uint64(releaseTime.UnixNano())
testCases := []struct {
name string
familyOn bool
expected string
}{
{name: "FamiliesOn", familyOn: true, expected: "SELECT toString(multiIf((multiIf(resource.`deployment.environment.name` IS NOT NULL, resource.`deployment.environment.name`::String, mapContains(resources_string, 'deployment.environment.name'), resources_string['deployment.environment.name'], NULL) IS NOT NULL OR multiIf(resource.`deployment.environment` IS NOT NULL, resource.`deployment.environment`::String, mapContains(resources_string, 'deployment.environment'), resources_string['deployment.environment'], NULL) IS NOT NULL), COALESCE(NULLIF(multiIf(resource.`deployment.environment.name` IS NOT NULL, resource.`deployment.environment.name`::String, mapContains(resources_string, 'deployment.environment.name'), resources_string['deployment.environment.name'], NULL), ''), NULLIF(multiIf(resource.`deployment.environment` IS NOT NULL, resource.`deployment.environment`::String, mapContains(resources_string, 'deployment.environment'), resources_string['deployment.environment'], NULL), ''), ''), NULL)) AS `__GROUP_BY_KEY_0_deployment.environment.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY `__GROUP_BY_KEY_0_deployment.environment.name` ORDER BY __result_0 DESC"},
{name: "FamiliesOff", familyOn: false, expected: "SELECT toString(multiIf(resource.`deployment.environment.name` IS NOT NULL, resource.`deployment.environment.name`::String, mapContains(resources_string, 'deployment.environment.name'), resources_string['deployment.environment.name'], NULL)) AS `__GROUP_BY_KEY_0_deployment.environment.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY `__GROUP_BY_KEY_0_deployment.environment.name` ORDER BY __result_0 DESC"},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
fl := flaggertest.WithBooleanFlags(t, map[string]bool{
flagger.FeatureResolveSemconvFamilies.String(): testCase.familyOn,
})
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
keys := logstelemetryschema.BuildCompleteFieldKeyMap(releaseTime)
for _, name := range []string{"deployment.environment.name", "deployment.environment"} {
keys[name] = []*telemetrytypes.TelemetryFieldKey{{
Name: name,
Signal: telemetrytypes.SignalLogs,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
}}
}
mockMetadataStore.KeysMap = keys
storage := logstelemetryschema.NewStorage()
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalLogs)
statementBuilder := NewLogQueryStatementBuilder(
instrumentationtest.New().ToProviderSettings(),
mockMetadataStore, storage, aggExprRewriter,
logstelemetryschema.DefaultFullTextColumn, fl, nil,
statementbuilder.Config{SkipResourceFingerprint: statementbuilder.SkipResourceFingerprint{Enabled: false, Threshold: 100000}},
)
query := qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Aggregations: []qbtypes.LogAggregation{{Expression: "count()"}},
GroupBy: []qbtypes.GroupByKey{
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "deployment.environment.name"}},
},
}
q, err := statementBuilder.Build(context.Background(), valuer.UUID{},
releaseTimeNano+uint64(24*time.Hour.Nanoseconds()),
releaseTimeNano+uint64(48*time.Hour.Nanoseconds()),
qbtypes.RequestTypeScalar, query, nil)
require.NoError(t, err)
require.Equal(t, testCase.expected, q.Query)
})
}
}

View File

@@ -125,6 +125,7 @@ func (b *logQueryStatementBuilder) Build(
bodyJSONEnabled := b.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(orgID))
keySelectors, warnings := getKeySelectors(query, bodyJSONEnabled)
keySelectors = querybuilder.ExpandKeySelectorsForFamilies(ctx, orgID, b.fl, keySelectors)
keys, _, err := b.metadataStore.GetKeysMulti(ctx, orgID, keySelectors)
if err != nil {
return nil, err

View File

@@ -0,0 +1,175 @@
package metricsstatementbuilder
import (
"context"
"testing"
"time"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/telemetryschema/metricstelemetryschema"
"github.com/SigNoz/signoz/pkg/types/metrictypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes/telemetrytypestest"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/require"
)
// The flag merges the label spellings of the family and unions the storage
// names of the metric-name family, in the filter, the group by column, and
// every metric_name filter.
func TestStatementBuilderResolvesFamilies(t *testing.T) {
fl := flaggertest.WithBooleanFlags(t, map[string]bool{
flagger.FeatureResolveSemconvFamilies.String(): true,
})
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
mockMetadataStore.KeysMap = map[string][]*telemetrytypes.TelemetryFieldKey{}
for _, name := range []string{"deployment.environment.name", "deployment.environment"} {
mockMetadataStore.KeysMap[name] = []*telemetrytypes.TelemetryFieldKey{{
Name: name,
Signal: telemetrytypes.SignalMetrics,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
}}
}
statementBuilder := NewMetricQueryStatementBuilder(
instrumentationtest.New().ToProviderSettings(),
mockMetadataStore,
metricstelemetryschema.NewStorage(),
fl,
)
query := qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Signal: telemetrytypes.SignalMetrics,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "k8s.pod.cpu.utilization",
Type: metrictypes.GaugeType,
Temporality: metrictypes.Unspecified,
TimeAggregation: metrictypes.TimeAggregationAvg,
SpaceAggregation: metrictypes.SpaceAggregationAvg,
},
},
Filter: &qbtypes.Filter{
Expression: "deployment.environment = 'production'",
},
GroupBy: []qbtypes.GroupByKey{
{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: "deployment.environment.name",
},
},
},
}
q, err := statementBuilder.Build(context.Background(), valuer.UUID{}, 1747947419000, 1747983448000, qbtypes.RequestTypeTimeSeries, query, nil)
require.NoError(t, err)
require.Equal(t, "WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(30)) AS ts, `__GROUP_BY_KEY_0_deployment.environment.name`, avg(value) AS per_series_value FROM signoz_metrics.distributed_samples_v4 AS points INNER JOIN (SELECT fingerprint, COALESCE(NULLIF(JSONExtractString(labels, 'deployment.environment.name'), ''), NULLIF(JSONExtractString(labels, 'deployment.environment'), ''), '') AS `__GROUP_BY_KEY_0_deployment.environment.name` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?, ?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND COALESCE(NULLIF(JSONExtractString(labels, 'deployment.environment.name'), ''), NULLIF(JSONExtractString(labels, 'deployment.environment'), ''), '') = ? GROUP BY fingerprint, `__GROUP_BY_KEY_0_deployment.environment.name`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?, ?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `__GROUP_BY_KEY_0_deployment.environment.name` ORDER BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_deployment.environment.name`, avg(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `__GROUP_BY_KEY_0_deployment.environment.name`) SELECT * FROM __spatial_aggregation_cte ORDER BY `__GROUP_BY_KEY_0_deployment.environment.name`, ts", q.Query)
require.Equal(t, []any{"k8s.pod.cpu.usage", "k8s.pod.cpu.utilization", uint64(1747936800000), uint64(1747983420000), "unspecified", "production", "k8s.pod.cpu.usage", "k8s.pod.cpu.utilization", uint64(1747947390000), uint64(1747983420000), 0}, q.Args)
}
// The mid-migration state: metadata holds only the old label spelling, and
// the query names the current one. The filter and the group by both read
// the one stored label.
func TestStatementBuilderResolvesSingleSpellingAcrossNames(t *testing.T) {
fl := flaggertest.WithBooleanFlags(t, map[string]bool{
flagger.FeatureResolveSemconvFamilies.String(): true,
})
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
mockMetadataStore.KeysMap = map[string][]*telemetrytypes.TelemetryFieldKey{}
for _, name := range []string{"deployment.environment"} {
mockMetadataStore.KeysMap[name] = []*telemetrytypes.TelemetryFieldKey{{
Name: name,
Signal: telemetrytypes.SignalMetrics,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
}}
}
statementBuilder := NewMetricQueryStatementBuilder(
instrumentationtest.New().ToProviderSettings(),
mockMetadataStore,
metricstelemetryschema.NewStorage(),
fl,
)
query := qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Signal: telemetrytypes.SignalMetrics,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "k8s.pod.cpu.utilization",
Type: metrictypes.GaugeType,
Temporality: metrictypes.Unspecified,
TimeAggregation: metrictypes.TimeAggregationAvg,
SpaceAggregation: metrictypes.SpaceAggregationAvg,
},
},
Filter: &qbtypes.Filter{
Expression: "deployment.environment.name = 'production'",
},
GroupBy: []qbtypes.GroupByKey{
{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: "deployment.environment.name",
},
},
},
}
q, err := statementBuilder.Build(context.Background(), valuer.UUID{}, 1747947419000, 1747983448000, qbtypes.RequestTypeTimeSeries, query, nil)
require.NoError(t, err)
require.Equal(t, "WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(30)) AS ts, `__GROUP_BY_KEY_0_deployment.environment.name`, avg(value) AS per_series_value FROM signoz_metrics.distributed_samples_v4 AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'deployment.environment') AS `__GROUP_BY_KEY_0_deployment.environment.name` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?, ?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND JSONExtractString(labels, 'deployment.environment') = ? GROUP BY fingerprint, `__GROUP_BY_KEY_0_deployment.environment.name`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?, ?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `__GROUP_BY_KEY_0_deployment.environment.name` ORDER BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_deployment.environment.name`, avg(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `__GROUP_BY_KEY_0_deployment.environment.name`) SELECT * FROM __spatial_aggregation_cte ORDER BY `__GROUP_BY_KEY_0_deployment.environment.name`, ts", q.Query)
}
// With the flag at its default, both the labels and the metric name stay
// literal.
func TestStatementBuilderKeepsFamiliesLiteralByDefault(t *testing.T) {
fl := flaggertest.WithBooleanFlags(t, map[string]bool{
flagger.FeatureResolveSemconvFamilies.String(): false,
})
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
mockMetadataStore.KeysMap = map[string][]*telemetrytypes.TelemetryFieldKey{}
for _, name := range []string{"deployment.environment.name", "deployment.environment"} {
mockMetadataStore.KeysMap[name] = []*telemetrytypes.TelemetryFieldKey{{
Name: name,
Signal: telemetrytypes.SignalMetrics,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
}}
}
statementBuilder := NewMetricQueryStatementBuilder(
instrumentationtest.New().ToProviderSettings(),
mockMetadataStore,
metricstelemetryschema.NewStorage(),
fl,
)
query := qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Signal: telemetrytypes.SignalMetrics,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "k8s.pod.cpu.utilization",
Type: metrictypes.GaugeType,
Temporality: metrictypes.Unspecified,
TimeAggregation: metrictypes.TimeAggregationAvg,
SpaceAggregation: metrictypes.SpaceAggregationAvg,
},
},
Filter: &qbtypes.Filter{
Expression: "deployment.environment = 'production'",
},
GroupBy: []qbtypes.GroupByKey{
{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: "deployment.environment.name",
},
},
},
}
q, err := statementBuilder.Build(context.Background(), valuer.UUID{}, 1747947419000, 1747983448000, qbtypes.RequestTypeTimeSeries, query, nil)
require.NoError(t, err)
require.Equal(t, "WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(30)) AS ts, `__GROUP_BY_KEY_0_deployment.environment.name`, avg(value) AS per_series_value FROM signoz_metrics.distributed_samples_v4 AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'deployment.environment.name') AS `__GROUP_BY_KEY_0_deployment.environment.name` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND JSONExtractString(labels, 'deployment.environment') = ? GROUP BY fingerprint, `__GROUP_BY_KEY_0_deployment.environment.name`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `__GROUP_BY_KEY_0_deployment.environment.name` ORDER BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_deployment.environment.name`, avg(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `__GROUP_BY_KEY_0_deployment.environment.name`) SELECT * FROM __spatial_aggregation_cte ORDER BY `__GROUP_BY_KEY_0_deployment.environment.name`, ts", q.Query)
require.Equal(t, []any{"k8s.pod.cpu.utilization", uint64(1747936800000), uint64(1747983420000), "unspecified", "production", "k8s.pod.cpu.utilization", uint64(1747947390000), uint64(1747983420000), 0}, q.Args)
}

View File

@@ -117,7 +117,9 @@ func (b *StatementBuilder) Build(
query qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation],
variables map[string]qbtypes.VariableItem,
) (*qbtypes.Statement, error) {
keySelectors := GetKeySelectors(query)
keySelectors := querybuilder.ExpandKeySelectorsForFamilies(ctx, orgID, b.flagger, GetKeySelectors(query))
metricNames := querybuilder.FamilyMetricNames(ctx, orgID, b.flagger, query.Aggregations[0].MetricName)
keySelectors = expandSelectorsForMetricNames(keySelectors, metricNames)
keys, _, err := b.metadataStore.GetKeysMulti(ctx, orgID, keySelectors)
if err != nil {
return nil, err
@@ -125,7 +127,30 @@ func (b *StatementBuilder) Build(
start, end = querybuilder.AdjustedMetricTimeRange(start, end, uint64(query.StepInterval.Seconds()), query)
return b.buildPipelineStatement(ctx, orgID, start, end, requestType, query, keys, variables)
return b.buildPipelineStatement(ctx, orgID, start, end, requestType, query, keys, metricNames, variables)
}
// expandSelectorsForMetricNames duplicates the selectors per family metric
// name. Label-key metadata is filtered by the exact metric_name.
func expandSelectorsForMetricNames(selectors []*telemetrytypes.FieldKeySelector, metricNames []string) []*telemetrytypes.FieldKeySelector {
if len(metricNames) <= 1 {
return selectors
}
out := selectors
for _, selector := range selectors {
if selector.MetricContext == nil {
continue
}
for _, metricName := range metricNames {
if metricName == selector.MetricContext.MetricName {
continue
}
expanded := *selector
expanded.MetricContext = &telemetrytypes.MetricContext{MetricName: metricName}
out = append(out, &expanded)
}
}
return out
}
func (b *StatementBuilder) buildPipelineStatement(
@@ -135,6 +160,7 @@ func (b *StatementBuilder) buildPipelineStatement(
requestType qbtypes.RequestType,
query qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation],
keys map[string][]*telemetrytypes.TelemetryFieldKey,
metricNames []string,
variables map[string]qbtypes.VariableItem,
) (*qbtypes.Statement, error) {
var (
@@ -166,13 +192,13 @@ func (b *StatementBuilder) buildPipelineStatement(
var filterWarnings []string
var err error
if timeSeriesCTE, timeSeriesCTEArgs, filterWarnings, err = b.buildTimeSeriesCTE(ctx, orgID, tsStart, tsEnd, cteQuery, keys, variables, tsTable); err != nil {
if timeSeriesCTE, timeSeriesCTEArgs, filterWarnings, err = b.buildTimeSeriesCTE(ctx, orgID, tsStart, tsEnd, cteQuery, keys, metricNames, variables, tsTable); err != nil {
return nil, err
}
if qbtypes.CanShortCircuitDelta(agg) {
// spatial_aggregation_cte directly for certain delta queries
if frag, args, err := b.buildTemporalAggDeltaFastPath(start, end, cteQuery, samplesTable, timeSeriesCTE, timeSeriesCTEArgs); err != nil {
if frag, args, err := b.buildTemporalAggDeltaFastPath(start, end, cteQuery, metricNames, samplesTable, timeSeriesCTE, timeSeriesCTEArgs); err != nil {
return nil, err
} else if frag != "" {
cteFragments = append(cteFragments, frag)
@@ -180,7 +206,7 @@ func (b *StatementBuilder) buildPipelineStatement(
}
} else {
// temporal_aggregation_cte
if frag, args, err := b.buildTemporalAggregationCTE(ctx, start, end, cteQuery, keys, samplesTable, timeSeriesCTE, timeSeriesCTEArgs); err != nil {
if frag, args, err := b.buildTemporalAggregationCTE(ctx, start, end, cteQuery, keys, metricNames, samplesTable, timeSeriesCTE, timeSeriesCTEArgs); err != nil {
return nil, err
} else if frag != "" {
cteFragments = append(cteFragments, frag)
@@ -201,16 +227,16 @@ func (b *StatementBuilder) buildPipelineStatement(
var tsArgs []any
// time series rows are written on hour boundaries
tsStart := start - (start % metricstelemetryschema.OneHourInMilliseconds)
if tsCTE, tsArgs, err = b.buildReducedTimeSeriesCTE(ctx, orgID, tsStart, end, cteQuery, keys, variables); err != nil {
if tsCTE, tsArgs, err = b.buildReducedTimeSeriesCTE(ctx, orgID, tsStart, end, cteQuery, keys, metricNames, variables); err != nil {
return nil, err
}
if qbtypes.CanShortCircuitReduced(agg) {
// spatial_aggregation_cte directly, no per-series level
if spatialFrag, spatialArgs, ok := b.buildReducedSpatialAggFastPath(start, end, cteQuery, tsCTE, tsArgs); ok {
if spatialFrag, spatialArgs, ok := b.buildReducedSpatialAggFastPath(start, end, cteQuery, metricNames, tsCTE, tsArgs); ok {
reducedFragments = []string{spatialFrag}
reducedArgs = [][]any{spatialArgs}
}
} else if temporalFrag, temporalArgs, ok := b.buildReducedTemporalAggregationCTE(start, end, cteQuery, tsCTE, tsArgs); ok {
} else if temporalFrag, temporalArgs, ok := b.buildReducedTemporalAggregationCTE(start, end, cteQuery, metricNames, tsCTE, tsArgs); ok {
spatialFrag, spatialArgs := b.buildReducedSpatialAggregationCTE(cteQuery)
reducedFragments = []string{temporalFrag, spatialFrag}
reducedArgs = [][]any{temporalArgs, spatialArgs}
@@ -268,6 +294,7 @@ func (b *StatementBuilder) buildReducedTimeSeriesCTE(
start, end uint64,
query qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation],
keys map[string][]*telemetrytypes.TelemetryFieldKey,
metricNames []string,
variables map[string]qbtypes.VariableItem,
) (string, []any, error) {
sb := sqlbuilder.NewSelectBuilder()
@@ -300,7 +327,7 @@ func (b *StatementBuilder) buildReducedTimeSeriesCTE(
sb.SelectMore(sqlbuilder.Escape(fmt.Sprintf("%s AS %s", col, GroupByColumnAlias(i, g.Name))))
}
sb.Where(
sb.In("metric_name", query.Aggregations[0].MetricName),
sb.In("metric_name", sqlbuilder.List(metricNames)),
sb.GTE("unix_milli", start),
sb.LTE("unix_milli", end),
)
@@ -325,6 +352,7 @@ func (b *StatementBuilder) buildReducedTimeSeriesCTE(
func (b *StatementBuilder) buildReducedSpatialAggFastPath(
start, end uint64,
query qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation],
metricNames []string,
timeSeriesCTE string,
timeSeriesCTEArgs []any,
) (string, []any, bool) {
@@ -345,7 +373,7 @@ func (b *StatementBuilder) buildReducedSpatialAggFastPath(
sb.From(fmt.Sprintf("%s.%s AS points FINAL", metricstelemetryschema.DBName, metricstelemetryschema.WhichReducedSamplesTableToUse(agg.Type)))
sb.JoinWithOption(sqlbuilder.InnerJoin, timeSeriesCTE, "points.reduced_fingerprint = filtered_time_series.fingerprint")
sb.Where(
sb.In("metric_name", agg.MetricName),
sb.In("metric_name", sqlbuilder.List(metricNames)),
sb.GTE("unix_milli", start),
sb.LT("unix_milli", end),
)
@@ -359,6 +387,7 @@ func (b *StatementBuilder) buildReducedSpatialAggFastPath(
func (b *StatementBuilder) buildReducedTemporalAggregationCTE(
start, end uint64,
query qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation],
metricNames []string,
timeSeriesCTE string,
timeSeriesCTEArgs []any,
) (string, []any, bool) {
@@ -387,7 +416,7 @@ func (b *StatementBuilder) buildReducedTemporalAggregationCTE(
sb.From(fmt.Sprintf("%s.%s AS points FINAL", metricstelemetryschema.DBName, metricstelemetryschema.WhichReducedSamplesTableToUse(agg.Type)))
sb.JoinWithOption(sqlbuilder.InnerJoin, timeSeriesCTE, "points.reduced_fingerprint = filtered_time_series.fingerprint")
sb.Where(
sb.In("metric_name", agg.MetricName),
sb.In("metric_name", sqlbuilder.List(metricNames)),
sb.GTE("unix_milli", start),
sb.LT("unix_milli", end),
)
@@ -428,6 +457,7 @@ func (b *StatementBuilder) buildReducedSpatialAggregationCTE(
func (b *StatementBuilder) buildTemporalAggDeltaFastPath(
start, end uint64,
query qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation],
metricNames []string,
samplesTable string,
timeSeriesCTE string,
timeSeriesCTEArgs []any,
@@ -469,7 +499,7 @@ func (b *StatementBuilder) buildTemporalAggDeltaFastPath(
sb.From(fmt.Sprintf("%s.%s AS points", metricstelemetryschema.DBName, samplesTable))
sb.JoinWithOption(sqlbuilder.InnerJoin, timeSeriesCTE, "points.fingerprint = filtered_time_series.fingerprint")
sb.Where(
sb.In("metric_name", query.Aggregations[0].MetricName),
sb.In("metric_name", sqlbuilder.List(metricNames)),
sb.GTE("unix_milli", start),
sb.LT("unix_milli", end),
)
@@ -486,6 +516,7 @@ func (b *StatementBuilder) buildTimeSeriesCTE(
start, end uint64,
query qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation],
keys map[string][]*telemetrytypes.TelemetryFieldKey,
metricNames []string,
variables map[string]qbtypes.VariableItem,
tsTable string,
) (string, []any, []string, error) {
@@ -522,7 +553,7 @@ func (b *StatementBuilder) buildTimeSeriesCTE(
}
sb.Where(
sb.In("metric_name", query.Aggregations[0].MetricName),
sb.In("metric_name", sqlbuilder.List(metricNames)),
sb.GTE("unix_milli", start),
sb.LTE("unix_milli", end),
)
@@ -554,22 +585,24 @@ func (b *StatementBuilder) buildTemporalAggregationCTE(
start, end uint64,
query qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation],
_ map[string][]*telemetrytypes.TelemetryFieldKey,
metricNames []string,
samplesTable string,
timeSeriesCTE string,
timeSeriesCTEArgs []any,
) (string, []any, error) {
if query.Aggregations[0].Temporality == metrictypes.Delta {
return b.buildTemporalAggDelta(ctx, start, end, query, samplesTable, timeSeriesCTE, timeSeriesCTEArgs)
return b.buildTemporalAggDelta(ctx, start, end, query, metricNames, samplesTable, timeSeriesCTE, timeSeriesCTEArgs)
} else if query.Aggregations[0].Temporality != metrictypes.Multiple {
return b.buildTemporalAggCumulativeOrUnspecified(ctx, start, end, query, samplesTable, timeSeriesCTE, timeSeriesCTEArgs)
return b.buildTemporalAggCumulativeOrUnspecified(ctx, start, end, query, metricNames, samplesTable, timeSeriesCTE, timeSeriesCTEArgs)
}
return b.buildTemporalAggForMultipleTemporalities(ctx, start, end, query, samplesTable, timeSeriesCTE, timeSeriesCTEArgs)
return b.buildTemporalAggForMultipleTemporalities(ctx, start, end, query, metricNames, samplesTable, timeSeriesCTE, timeSeriesCTEArgs)
}
func (b *StatementBuilder) buildTemporalAggDelta(
_ context.Context,
start, end uint64,
query qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation],
metricNames []string,
samplesTable string,
timeSeriesCTE string,
timeSeriesCTEArgs []any,
@@ -601,7 +634,7 @@ func (b *StatementBuilder) buildTemporalAggDelta(
sb.From(fmt.Sprintf("%s.%s AS points", metricstelemetryschema.DBName, samplesTable))
sb.JoinWithOption(sqlbuilder.InnerJoin, timeSeriesCTE, "points.fingerprint = filtered_time_series.fingerprint")
sb.Where(
sb.In("metric_name", query.Aggregations[0].MetricName),
sb.In("metric_name", sqlbuilder.List(metricNames)),
sb.GTE("unix_milli", start),
sb.LT("unix_milli", end),
)
@@ -617,6 +650,7 @@ func (b *StatementBuilder) buildTemporalAggCumulativeOrUnspecified(
_ context.Context,
start, end uint64,
query qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation],
metricNames []string,
samplesTable string,
timeSeriesCTE string,
timeSeriesCTEArgs []any,
@@ -642,7 +676,7 @@ func (b *StatementBuilder) buildTemporalAggCumulativeOrUnspecified(
baseSb.From(fmt.Sprintf("%s.%s AS points", metricstelemetryschema.DBName, samplesTable))
baseSb.JoinWithOption(sqlbuilder.InnerJoin, timeSeriesCTE, "points.fingerprint = filtered_time_series.fingerprint")
baseSb.Where(
baseSb.In("metric_name", query.Aggregations[0].MetricName),
baseSb.In("metric_name", sqlbuilder.List(metricNames)),
baseSb.GTE("unix_milli", start),
baseSb.LT("unix_milli", end),
)
@@ -683,6 +717,7 @@ func (b *StatementBuilder) buildTemporalAggForMultipleTemporalities(
_ context.Context,
start, end uint64,
query qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation],
metricNames []string,
samplesTable string,
timeSeriesCTE string,
timeSeriesCTEArgs []any,
@@ -733,7 +768,7 @@ func (b *StatementBuilder) buildTemporalAggForMultipleTemporalities(
sb.From(fmt.Sprintf("%s.%s AS points", metricstelemetryschema.DBName, samplesTable))
sb.JoinWithOption(sqlbuilder.InnerJoin, timeSeriesCTE, "points.fingerprint = filtered_time_series.fingerprint")
sb.Where(
sb.In("metric_name", query.Aggregations[0].MetricName),
sb.In("metric_name", sqlbuilder.List(metricNames)),
sb.GTE("unix_milli", start),
sb.LT("unix_milli", end),
)

View File

@@ -7,6 +7,7 @@ import (
"testing"
"time"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/querybuilder"
@@ -24,7 +25,7 @@ var jsonAttrColRe = regexp.MustCompile(`,\s*attributes\s*(,| FROM )`)
func newBulkTestBuilder(t *testing.T, releaseTime time.Time) *traceQueryStatementBuilder {
t.Helper()
fl := flaggertest.New(t)
fl := flaggertest.WithBooleanFlags(t, map[string]bool{flagger.FeatureUseTraceAttributesJSON.String(): true})
storage := tracestelemetryschema.NewStorage()
store := telemetrytypestest.NewMockMetadataStore()
store.KeysMap = tracestelemetryschema.BuildCompleteFieldKeyMap(releaseTime)

View File

@@ -2062,3 +2062,51 @@ func TestStatementBuilderSemconvFamilies(t *testing.T) {
})
}
}
// The mid-migration state: metadata holds only the old spelling, and the
// query names the current one. The resource-filter condition and the group
// by column both read the stored spelling, so the filter and the groups
// agree.
func TestStatementBuilderSemconvSingleSpelling(t *testing.T) {
fl := flaggertest.WithBooleanFlags(t, map[string]bool{
flagger.FeatureResolveSemconvFamilies.String(): true,
})
storage := tracestelemetryschema.NewStorage()
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
mockMetadataStore.KeysMap = map[string][]*telemetrytypes.TelemetryFieldKey{
"deployment.environment": {{
Name: "deployment.environment",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
}},
}
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalTraces)
statementBuilder := NewTraceQueryStatementBuilder(
instrumentationtest.New().ToProviderSettings(),
mockMetadataStore,
storage,
aggExprRewriter,
nil,
fl,
false,
100000,
)
query := qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Aggregations: []qbtypes.TraceAggregation{{Expression: "count()"}},
Filter: &qbtypes.Filter{
Expression: "deployment.environment.name = 'production'",
},
GroupBy: []qbtypes.GroupByKey{
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "deployment.environment.name"}},
},
}
q, err := statementBuilder.Build(context.Background(), valuer.UUID{}, 1747947419000, 1747983448000, qbtypes.RequestTypeScalar, query, nil)
require.NoError(t, err)
require.Equal(t, "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'deployment.environment') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT toString(multiIf(resource.`deployment.environment` IS NOT NULL, resource.`deployment.environment`::String, mapContains(resources_string, 'deployment.environment'), resources_string['deployment.environment'], NULL)) AS `__GROUP_BY_KEY_0_deployment.environment.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY `__GROUP_BY_KEY_0_deployment.environment.name` ORDER BY __result_0 DESC", q.Query)
}

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