Compare commits

..

12 Commits

Author SHA1 Message Date
Gaurav Tewari
d19b9afff4 feat(trace-details): show AI dot on thread tab and log empty thread with AI summary (#13380)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
cacheci / tests (push) Waiting to run
Release Drafter / update_release_draft (push) Waiting to run
#### Description
- Thread tab shows a dot when the trace summary has an `ai` block, so AI
traces stand out before the tab is opened.
- New `Trace Detail: Thread empty with AI summary` event fires when a
trace has AI usage but the Thread tab shows its empty state, to measure
how often AI traces cannot be shown as a thread.
- The event carries the trace summary (root service, entry point, spans,
duration, errors, missing spans, AI tokens, cost) to find what those
traces have in common.

#### Issues closed by this PR
<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->

https://github.com/orgs/SigNoz/projects/59/views/18?pane=issue&itemId=292126938&issue=SigNoz%7Cnerve-pod%7C583

https://signoz-team.slack.com/archives/C0AMEPC0DFV/p1793636954294369 

#### Screenshots / Screen Recordings
<!--If applicable, include screenshots or screen recordings that clearly
show the behavior before the change and the result after the change. -->



https://github.com/user-attachments/assets/e675aefd-9581-43c5-b4fa-afe5ea5e35b8




 
#### Additional Information
- Stacked on #13358.
- Fires once per trace, like the thread `Data loaded` event; the summary
is reused, not fetched again.

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-11-04 06:53:22 +00:00
Srikanth Chekuri
4cb022dd06 fix(telemetrystore): return invalid input with a fix for three ClickHouse SQL mistakes (#13328)
#### Description

- ClickHouse errors 170 (`BAD_GET`), 288
(`DISTRIBUTED_IN_JOIN_SUBQUERY_DENIED`) and 407 (`DECIMAL_OVERFLOW`)
from raw SQL came back as 500s, though each is a fixable query mistake.
They now return invalid input with the fix as a suggestion.

#### Additional Information

- 170 and 407 and their fixes were verified on ClickHouse 25.12. 288
needs a multi-shard cluster, so its suggestion follows the
`distributed_product_mode` docs.
- Like the existing entries, these also apply to SQL SigNoz generates.
- Part of SigNoz/nerve-pod#465.
2026-11-04 05:12:37 +00:00
Vinicius Lourenço
9d465d54f5 test(e2e): retry clicks swallowed by dnd-kit and loading antd buttons (#13401)
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

- `app-shell/sidenav` TC-03: dnd-kit stops every click for 50 ms after a
drop, so the Save click that follows the drag was sometimes lost and no
preference request went out. `saveShortcuts` now clicks again while the
dialog is still open.
- `messaging-queues/kafka` TC-01: the health check button is a `loading`
antd `Button` while its onboarding queries fetch, and a loading antd
button ignores clicks without being disabled. `openHealthCheck` now
clicks again until the dialog opens.
- Both tests came in with #13189 and have been failing or needing
retries in CI on unrelated PRs. Notes added to
`tests/e2e/docs/flake-notes.md`.
2026-11-03 19:13:45 +00:00
Abhi kumar
7d95fa7b25 fix(query-builder): added query follows the previous query's signal (#13323)
#### Description

- In the V2 panel editor, "Add query" always created a Metrics query:
`createNewBuilderQuery` fell back to `DataSource.METRICS` because the
panel editor passes no fixed `initialDataSource`. A logs or traces panel
got a metrics query B.
- The new query now continues the last query's signal (and its `source`,
so a meter query stays meter). Hosts with a fixed data source
(explorers, alerts) are unchanged.
- No story adds a query, so no story renders differently.

#### Issues closed by this PR

Closes SigNoz/pulse-pod#659

#### Additional Information

- Stacked on #13315; #13313 (query builder e2e) sits on top and covers
this with a regression test.
2026-11-03 17:47:48 +00:00
Abhi kumar
cd1068e1c3 fix(query-builder): legacy having casing and non-positive limits (#13315)
#### Description

- **Legacy having:** the old having filter stored `COUNT()` (upper-cased
function). `convertHavingToExpression` passed it through and the v5
backend rejects it with a 400 (`Invalid references in Having expression:
[COUNT]`), so any saved legacy query with a having failed to run. Only
the function name is now lower-cased; attribute keys keep their case.
- **Limit:** a negative limit was sent as-is and the backend returned a
400. The query and formula Limit inputs now accept digits only (`-10`
shows as `10`), so the shown value is always what's sent. Empty or `0`
means no limit.
- `InputWithLabel` gains an optional `sanitize` prop for this.

#### Issues closed by this PR

Closes SigNoz/pulse-pod#657

#### Additional Information

- Found by the query builder e2e suite (#13313), where both cases are
`test.fixme` until this merges. The negative-limit case should expect
the sign to be dropped.
2026-11-03 17:43:47 +00:00
Nityananda Gohain
dca68d89bc feat(stats): report AI observability usage from the stats endpoint (#13359)
#### Description

 New stats from `/api/v1/stats`:

- `telemetry.traces.gen_ai.request.model.exists` – a span carried a
request model since the start of the previous day.
  - `telemetry.traces.gen_ai.tool.name.exists` – same, for a tool name.
- `telemetry.traces.gen_ai.agent.name.exists` – same, for an agent name.
- `llmpricingrule.custom.count` – pricing rules users added themselves.
  - `llmpricingrule.override.count` – synced default rules users pinned.
- `llmpricingrule.unmapped_model.count` – models seen in that window
that no rule prices.
  - `dashboard.panels.ai.count` – panels with an AI builder query.
 
The gen_ai values come from the traces tag table through the metadata
store, never from a span scan. The model list is capped at 100 names,
which is more than any org is expected to use in two days; past the cap
the unmapped count is a lower bound.

Existing stats change: every `dashboard.*` count now skips system
dashboards, and AI queries no longer count under
`dashboard.panels.traces.count`.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Closes https://github.com/SigNoz/nerve-pod/issues/577

  #### Additional Information

- `/api/v1/stats` latency against `main` on the same ClickHouse with
200k spans: sequential p50 14 ms → 21 ms, at 10 concurrent callers p50
78 ms → 101 ms. The three lookups run concurrently and cost about 8 ms
each regardless of limit.
- If ClickHouse is unreachable the rule counts are still reported and
only the gen_ai keys are omitted, so the reporter keeps their last
value.
2026-11-03 17:15:15 +00:00
Vishal Sharma
1be806c58d fix(ai-assistant): open explorer chips in the view the query implies (#11733)
#### Description
- Noz's `apply_filter` ("Open in Explorer") chips always opened the
Explorer in List view: the handler dropped the query's `requestType` and
never set `panelTypes`, so a grouped "count by service" landed on raw
rows.
- The chip now opens the view the query implies, on both the on-page
redirect and off-page navigation: `scalar` → Table, `time_series` → Time
Series, `trace` → Traces (traces signal only), anything else → List.

#### Issues closed by this PR


https://github.com/orgs/SigNoz/projects/59/views/18?pane=issue&itemId=292161688&issue=SigNoz%7Cnerve-pod%7C584
#### Screenshots / Screen Recordings
Prev - 


https://github.com/user-attachments/assets/a5f9f45c-6460-4109-a061-e2d49901d438



Now - 


https://github.com/user-attachments/assets/15c17c68-538a-43b9-965a-4440ec24a1ec



#### Additional Information
- Reviewable commit by commit: the fix, the `panelTypes` encoding
regression test, then the `trace` mapping from review.
- `distribution` isn't mapped: `query_range` rejects it, so the agent
can't send it.
- `trace` stays on List for logs and metrics, which have no Traces view.
- The existing `[apply_filter]` debug logs are left untouched.

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
Co-authored-by: Gaurav Tewari <gauravtewari111@gmail.com>
2026-11-03 17:10:21 +00:00
Ashwin Bhatkal
8569702161 test(query-builder): stop the QueryV2 Delete menu test timing out in CI (#13394)
#### Description

- The "shows Delete with more than one query and deletes on click" test
from [#13189](https://github.com/SigNoz/signoz/pull/13189) hits jest's 5
s timeout in CI. It has failed most `jsci` runs since it merged,
including merge-queue runs for unrelated PRs.
- It rendered the whole `QueryBuilderV2` with two queries only to make
the Delete item show. It now renders one `QueryV2` with
`queriesCount={2}`, which takes it from about 0.8 s to 0.3 s locally.
- The query actions menu tests find the trigger and items by the test
ids `Dropdown` already sets, in place of a class selector and role
lookups.
- The Delete test keeps a 15 s timeout as headroom for CI load.

#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/685
2026-11-03 17:06:32 +00:00
Vikrant Gupta
1b8451650a docs(go): require descriptive receiver names (#13391)
#### Description

- The packages guide asked for one- or two-character receivers, which is
what the older half of the codebase does; the newer layers use the
type's role name (`provider`, `module`, `handler`, `store`). The guide
now requires the latter.
- Settles the import-shadowing case the same way
`signozapiserver/registry.go` already does: alias the import, keep the
descriptive receiver.
2026-11-03 16:08:29 +00:00
Swapnil Nakade
a5bfebbc29 refactor: using foundry commands for aws and azure integration (#13270)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
- moving aws and azure integrations to use foundry commands for
integration connection initiation

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
contributes to https://github.com/keystone-pod/issues/167
2026-11-03 14:44:21 +00:00
Gaurav Tewari
afbe1ac7a6 feat: add events for span detail open and trace view (#13358)
Some checks failed
build-staging / js-build (push) Has been cancelled
build-staging / prepare (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
- New `Trace Detail: Span details opened` event, fired once per span
shown in the details panel (click or `spanId` deep link) on both the
Overview and Thread tabs.
- Thread tab now fires the existing `Trace Detail: Data loaded` event,
with the trace-level attributes it has: `totalSpansCount`,
`traceDurationMs`, `hadErrors`, `spanPanelVariant`.
- Both events carry `detailsTab` (`overview` | `thread`) so the tabs can
be split in analytics.

#### Issues closed by this PR
Closes SigNoz/nerve-pod#581

#### Screenshots / Screen Recordings
Before - 


https://github.com/user-attachments/assets/ef1c188c-898b-4a46-b113-39215d5e93d1


Now - 


https://github.com/user-attachments/assets/844dbb67-343c-4cd1-9947-d3c9c40a5e62



#### Additional Information
- Named `detailsTab`, not `tab`: `tab` already means the span panel's
tab on other trace-detail events.
- Data loaded fires once per trace per tab; the guard ref lives in the
page, so revisiting a tab doesn't re-fire it.

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-11-03 14:10:53 +00:00
ashu8912
2103d6c7b7 fix(notification-channels): scroll channel forms inside configuration and pin actions to the bottom (#13382)
#### Description

- New and edit channel forms now scroll inside the configuration pane,
with Discard / Test / Save pinned to the bottom. Before, the pane's
hidden overflow cut the form off, so the bottom fields and the actions
could not be reached on shorter screens.
- Playwright spec opens both forms in a 600px-tall viewport, wheels to
the end and checks the last control is reachable and sits above the
pinned actions. It fails without the fix.
- The existing ChannelsNew and ChannelsEdit stories already render these
pages.
2026-11-03 13:08:24 +00:00
109 changed files with 2872 additions and 3979 deletions

View File

@@ -2205,10 +2205,10 @@ components:
type: object
CloudintegrationtypesAWSConnectionArtifact:
properties:
connectionUrl:
cliCommand:
type: string
required:
- connectionUrl
- cliCommand
type: object
CloudintegrationtypesAWSIntegrationConfig:
properties:
@@ -2364,11 +2364,8 @@ components:
properties:
cliCommand:
type: string
cloudPowerShellCommand:
type: string
required:
- cliCommand
- cloudPowerShellCommand
type: object
CloudintegrationtypesAzureIntegrationConfig:
properties:

View File

@@ -28,7 +28,7 @@ func ConvertConfig(src ExternalConfig) (InternalConfig, error)
// Over this:
type ConfigAdapter struct { ... }
func NewConfigAdapter(src ExternalConfig) *ConfigAdapter
func (a *ConfigAdapter) ToInternal() (InternalConfig, error)
func (adapter *ConfigAdapter) ToInternal() (InternalConfig, error)
```
The two-step version is only justified when `ConfigAdapter` has multiple distinct consumers that use it in different ways.
@@ -66,10 +66,10 @@ A method on a structured type should not strip meaning from the structure it bel
```go
// Problematic: callers cannot distinguish how items were related.
func (o *Order) AllLineItems() []LineItem { ... }
func (order *Order) AllLineItems() []LineItem { ... }
// Better: the validation logic operates on the full structure.
func ValidateOrder(o *Order) error { ... }
func ValidateOrder(order *Order) error { ... }
```
Public methods shape how a type is used. Once a lossy accessor exists, callers will depend on it, and the lost information becomes unrecoverable at those call sites.

View File

@@ -74,7 +74,7 @@ The `*Visitor` passed in provides everything needed to build predicates. Use the
In the simplest case, keys map straight to columns and the resolver is a switch. The doc's running example, an imaginary `sample_entity` table:
```go
func (r sampleEntityFieldResolver) ResolveComparison(v *sqlcompiler.Visitor, key string, operation qbtypesv5.FilterOperator, ctx *grammar.ComparisonContext) string {
func (resolver sampleEntityFieldResolver) ResolveComparison(v *sqlcompiler.Visitor, key string, operation qbtypesv5.FilterOperator, ctx *grammar.ComparisonContext) string {
switch key {
case "created_by":
return v.BuildStringOperation(v.Sb, ctx, operation, "sample_entity.created_by", key)

View File

@@ -82,7 +82,7 @@ In the module implementation, implement the new method. A typical implementation
- Uses the `render` package to write responses or errors
```go
func (h *handler) CreateThing(rw http.ResponseWriter, req *http.Request) {
func (handler *handler) CreateThing(rw http.ResponseWriter, req *http.Request) {
// Extract authentication and organization context from req.Context()
claims, err := authtypes.ClaimsFromContext(req.Context())
if err != nil {
@@ -98,7 +98,7 @@ func (h *handler) CreateThing(rw http.ResponseWriter, req *http.Request) {
}
// Call module functions
out, err := h.module.CreateThing(req.Context(), claims.OrgID, &in)
out, err := handler.module.CreateThing(req.Context(), claims.OrgID, &in)
if err != nil {
render.Error(rw, err)
return

View File

@@ -59,7 +59,8 @@ Follow these rules:
### Unexported symbols
- Struct receivers: one or two characters (`c`, `f`, `br`).
- Struct receivers: the lowercase name of the role the type plays, the same on every method of the type (`provider`, `module`, `handler`, `store`, `middleware`, `migration`). Never one or two characters: `c`, `h`, `m` say nothing at the call site and collide with local variables.
- When the receiver name would shadow an imported package, alias the import, not the receiver (`pkghandler "github.com/SigNoz/signoz/pkg/http/handler"` with a `handler` receiver).
- Helper functions: descriptive lowercase names (`parseToken`, `buildQuery`).
### Constants

View File

@@ -70,16 +70,16 @@ The instrumentation SDK (`pkg/instrumentation/sdk.go`) is idle while running but
```go
// pkg/instrumentation/sdk.go
func (i *SDK) Start(ctx context.Context) error {
<-i.startCh
func (sdk *SDK) Start(ctx context.Context) error {
<-sdk.startCh
return nil
}
func (i *SDK) Stop(ctx context.Context) error {
close(i.startCh)
func (sdk *SDK) Stop(ctx context.Context) error {
close(sdk.startCh)
return errors.Join(
i.sdk.Shutdown(ctx),
i.meterProviderShutdownFunc(ctx),
sdk.sdk.Shutdown(ctx),
sdk.meterProviderShutdownFunc(ctx),
)
}
```
@@ -197,8 +197,8 @@ A standalone service only has the `factory.Service` lifecycle i.e it does not se
}
}
func (s *service) Start(ctx context.Context) error { ... }
func (s *service) Stop(ctx context.Context) error { ... }
func (service *service) Start(ctx context.Context) error { ... }
func (service *service) Stop(ctx context.Context) error { ... }
```
### Provider that is also a service

View File

@@ -17,7 +17,7 @@ Two rules shape how the core type behaves:
func NewGettableAuthDomainFromAuthDomain(d *AuthDomain, info *AuthNProviderInfo) *GettableAuthDomain
// Receiver form
func (m *PlannedMaintenanceWithRules) ToPlannedMaintenance() *PlannedMaintenance
func (maintenance *PlannedMaintenanceWithRules) ToPlannedMaintenance() *PlannedMaintenance
```
- **`X` can double as the storage row** when the DB shape would be identical. `Channel` embeds `bun.BaseModel` directly, and there is no `StorableChannel`. This is the preferred shape when it works.
@@ -175,10 +175,10 @@ The rules that make the envelope work:
```go
// Domain invariants: every write path re-runs these.
func (x *X) Validate() error { ... }
func (entity *X) Validate() error { ... }
// Request-shape-only: checks that do not apply once the value is persisted.
func (p *PostableX) Validate() error { ... }
func (postable *PostableX) Validate() error { ... }
```
- **Type aliases, not wrappers**, when two shapes are identical. `type GettableChannels = []*Channel` is correct because it adds no semantics beyond the underlying type.
- **Serialization tags** follow [handler.md](handler.md): `required:"true"` means the JSON key must be present, `nullable:"true"` is required on any slice or map that may serialize as `null`, and types with a fixed value set must implement `Enum() []any`.

View File

@@ -2,8 +2,6 @@ package implcloudprovider
import (
"context"
"fmt"
"net/url"
"sort"
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
@@ -18,29 +16,14 @@ func NewAWSCloudProvider(defStore cloudintegrationtypes.ServiceDefinitionStore)
return &awscloudprovider{serviceDefinitions: defStore}, nil
}
// TODO: move URL construction logic to cloudintegrationtypes and add unit tests for it.
func (provider *awscloudprovider) GetConnectionArtifact(ctx context.Context, account *cloudintegrationtypes.Account, req *cloudintegrationtypes.GetConnectionArtifactRequest) (*cloudintegrationtypes.ConnectionArtifact, error) {
baseURL := fmt.Sprintf(cloudintegrationtypes.CloudFormationQuickCreateBaseURL.StringValue(), req.Config.AWS.DeploymentRegion)
u, _ := url.Parse(baseURL)
q := u.Query()
q.Set("region", req.Config.AWS.DeploymentRegion)
u.Fragment = "/stacks/quickcreate"
u.RawQuery = q.Encode()
q = u.Query()
q.Set("stackName", cloudintegrationtypes.AgentCloudFormationBaseStackName.StringValue())
q.Set("templateURL", fmt.Sprintf(cloudintegrationtypes.AgentCloudFormationTemplateS3Path.StringValue(), req.Config.AgentVersion))
q.Set("param_SigNozIntegrationAgentVersion", req.Config.AgentVersion)
q.Set("param_SigNozApiUrl", req.Credentials.SigNozAPIURL)
q.Set("param_SigNozApiKey", req.Credentials.SigNozAPIKey)
q.Set("param_SigNozAccountId", account.ID.StringValue())
q.Set("param_IngestionUrl", req.Credentials.IngestionURL)
q.Set("param_IngestionKey", req.Credentials.IngestionKey)
connectionArtifact, err := cloudintegrationtypes.NewAWSConnectionArtifact(account.ID, req.Config.AgentVersion, req.Credentials, req.Config.AWS.DeploymentRegion)
if err != nil {
return nil, err
}
return &cloudintegrationtypes.ConnectionArtifact{
AWS: cloudintegrationtypes.NewAWSConnectionArtifact(u.String() + "?&" + q.Encode()), // this format is required by AWS
AWS: connectionArtifact,
}, nil
}

View File

@@ -3936,7 +3936,7 @@ export interface CloudintegrationtypesAWSConnectionArtifactDTO {
/**
* @type string
*/
connectionUrl: string;
cliCommand: string;
}
export interface CloudintegrationtypesAWSLogsCollectionStrategyDTO {
@@ -4146,10 +4146,6 @@ export interface CloudintegrationtypesAzureConnectionArtifactDTO {
* @type string
*/
cliCommand: string;
/**
* @type string
*/
cloudPowerShellCommand: string;
}
export interface CloudintegrationtypesAzureLogsCollectionStrategyDTO {

View File

@@ -18,6 +18,7 @@ function InputWithLabel({
className,
closeIcon,
disabled,
sanitize,
}: {
label: string;
initialValue?: string | number | null;
@@ -29,14 +30,17 @@ function InputWithLabel({
className?: string;
closeIcon?: React.ReactNode;
disabled?: boolean;
/** Rewrites the typed value before it is shown and reported. */
sanitize?: (value: string) => string;
}): JSX.Element {
const [inputValue, setInputValue] = useState<string>(
initialValue ? initialValue.toString() : '',
);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
setInputValue(e.target.value);
onChange?.(e.target.value);
const value = sanitize ? sanitize(e.target.value) : e.target.value;
setInputValue(value);
onChange?.(value);
};
return (
@@ -78,6 +82,7 @@ InputWithLabel.defaultProps = {
initialValue: undefined,
className: undefined,
closeIcon: undefined,
sanitize: undefined,
};
export default InputWithLabel;

View File

@@ -26,6 +26,7 @@ import {
RAW_QUERY_FIELDS,
resolveQueryBuilderFields,
} from '../../queryBuilderFields.utils';
import { parseLimit, sanitizeLimit } from '../../utils';
import BucketOptions from './BucketOptions/BucketOptions';
import HavingFilter from './HavingFilter/HavingFilter';
@@ -278,7 +279,7 @@ function QueryAddOns({
const handleChangeLimit = useCallback(
(value: string) => {
handleChangeQueryData('limit', Number(value) || null);
handleChangeQueryData('limit', parseLimit(value));
},
[handleChangeQueryData],
);
@@ -374,6 +375,7 @@ function QueryAddOns({
label="Limit"
type="number"
onChange={handleChangeLimit}
sanitize={sanitizeLimit}
initialValue={query?.limit ?? undefined}
placeholder={placeholderOf(QueryBuilderField.Limit)}
onClose={(): void => handleRemoveView(QueryBuilderField.Limit)}

View File

@@ -25,6 +25,8 @@ import {
import '@testing-library/jest-dom';
import { QueryBuilderV2 } from '../../QueryBuilderV2';
import { QueryBuilderV2Provider } from '../../QueryBuilderV2Context';
import { QueryV2 } from '../QueryV2';
// Local mocks for domain-specific heavy child components
jest.mock(
@@ -249,11 +251,7 @@ describe('QueryBuilderV2 + QueryV2 - base render', () => {
describe('query actions menu', () => {
const openQueryActions = async (): Promise<void> => {
const trigger = document.querySelector(
'.query-actions-container [aria-haspopup="menu"]',
) as HTMLElement;
expect(trigger).toBeInTheDocument();
await userEvent.click(trigger);
await userEvent.click(screen.getAllByTestId('query-actions')[0]);
};
it('Clone calls cloneQuery with the query', async () => {
@@ -261,7 +259,7 @@ describe('QueryBuilderV2 + QueryV2 - base render', () => {
await openQueryActions();
await userEvent.click(
await screen.findByRole('menuitem', { name: 'Clone' }),
await screen.findByTestId('query-actions-item-clone-query'),
);
expect(baseQBContext.cloneQuery).toHaveBeenCalledTimes(1);
@@ -275,41 +273,44 @@ describe('QueryBuilderV2 + QueryV2 - base render', () => {
render(<QueryBuilderV2 panelType={PANEL_TYPES.TABLE} version="v4" />);
await openQueryActions();
await screen.findByRole('menuitem', { name: 'Clone' });
await screen.findByTestId('query-actions-item-clone-query');
expect(
screen.queryByRole('menuitem', { name: 'Delete' }),
screen.queryByTestId('query-actions-item-delete-query'),
).not.toBeInTheDocument();
});
it('shows Delete with more than one query and deletes on click', async () => {
const currentQueryBase = baseQBContext.currentQuery as Query;
const [queryA] = currentQueryBase.builder.queryData;
mockedUseQueryBuilder.mockReturnValue({
...baseQBContext,
currentQuery: {
...currentQueryBase,
builder: {
...currentQueryBase.builder,
queryData: [queryA, { ...queryA, queryName: 'B', legend: 'B' }],
},
},
});
const [queryA] = (baseQBContext.currentQuery as Query).builder.queryData;
const handleDeleteQuery = jest.fn();
mockedUseQueryOperations.mockReturnValue({
...mockedUseQueryOperations({} as Parameters<UseQueryOperations>[0]),
handleDeleteQuery,
});
render(<QueryBuilderV2 panelType={PANEL_TYPES.TABLE} version="v4" />);
render(
<QueryBuilderV2Provider>
<QueryV2
index={0}
query={queryA}
version="v4"
isAvailableToDisable={false}
isMultiQueryAllowed
queriesCount={2}
onSignalSourceChange={jest.fn()}
signalSourceChangeEnabled={false}
savePreviousQuery={false}
/>
</QueryBuilderV2Provider>,
);
await openQueryActions();
await userEvent.click(
await screen.findByRole('menuitem', { name: 'Delete' }),
await screen.findByTestId('query-actions-item-delete-query'),
);
expect(handleDeleteQuery).toHaveBeenCalledTimes(1);
});
}, 15000);
});
it('fx button is disabled when functions already exist', () => {

View File

@@ -169,8 +169,15 @@ describe('QueryAddOns', () => {
expect(screen.getByTestId('limit-content')).toBeInTheDocument();
expect(input.value).toBe('5');
fireEvent.change(input, { target: { value: '10' } });
expect(mockHandleChangeQueryData).toHaveBeenCalledWith('limit', 10);
const user = userEvent.setup();
await user.clear(input);
await user.type(input, '10');
expect(mockHandleChangeQueryData).toHaveBeenLastCalledWith('limit', 10);
await user.clear(input);
await user.type(input, '-5');
expect(input.value).toBe('5');
expect(mockHandleChangeQueryData).toHaveBeenLastCalledWith('limit', 5);
});
it.each([

View File

@@ -12,10 +12,62 @@ import {
convertExpressionToFilters,
convertFiltersToExpression,
convertFiltersToExpressionWithExistingQuery,
convertHavingToExpression,
formatValueForExpression,
parseLimit,
removeKeysFromExpression,
sanitizeLimit,
} from '../utils';
describe('convertHavingToExpression', () => {
it.each([
['COUNT()', 'count() > 5'],
['COUNT_DISTINCT(service.name)', 'count_distinct(service.name) > 5'],
['P99(duration_nano)', 'p99(duration_nano) > 5'],
['SUM(http.Request.Size)', 'sum(http.Request.Size) > 5'],
['count()', 'count() > 5'],
])('references %s by its lower-case function', (columnName, expected) => {
expect(
convertHavingToExpression([{ columnName, op: '>', value: 5 }]),
).toStrictEqual({ expression: expected });
});
it('joins several conditions and formats list values', () => {
expect(
convertHavingToExpression([
{ columnName: 'COUNT()', op: '>', value: 5 },
{ columnName: 'COUNT()', op: 'NOT IN', value: [1, 2] },
]),
).toStrictEqual({ expression: 'count() > 5 AND count() NOT IN [1, 2]' });
});
});
describe('sanitizeLimit', () => {
it.each([
['10', '10'],
['-10', '10'],
['2.5', '25'],
['1e3', '13'],
['', ''],
])('sanitizes %j to %j', (value, expected) => {
expect(sanitizeLimit(value)).toBe(expected);
});
});
describe('parseLimit', () => {
it.each([
['10', 10],
['1', 1],
['', null],
['0', null],
['-5', null],
['2.5', null],
['abc', null],
])('parses %j as %j', (value, expected) => {
expect(parseLimit(value)).toBe(expected);
});
});
describe('convertFiltersToExpression', () => {
beforeEach(() => {
jest.clearAllMocks();

View File

@@ -840,6 +840,16 @@ export const appendAndClause = (
return hasTopLevelOr ? `(${base}) AND ${clause}` : `${base} AND ${clause}`;
};
/** Strips everything but digits, so a limit input can't hold a sign or decimal. */
export const sanitizeLimit = (value: string): string =>
value.replace(/\D/g, '');
/** A query or formula limit: a positive integer, otherwise none. */
export const parseLimit = (value: string): number | null => {
const limit = Number(value);
return Number.isInteger(limit) && limit > 0 ? limit : null;
};
/**
* Convert old having format to new having format
* @param having - Array of old having objects with columnName, op, and value
@@ -871,7 +881,14 @@ export const convertHavingToExpression = (
formattedValue = String(value);
}
return `${columnName} ${op} ${formattedValue}`;
// The old having filter upper-cased the function (`COUNT()`); v5 only
// resolves the aggregation's own lower-case expression.
const reference = columnName.replace(
/^\s*([A-Za-z_]\w*)\(/,
(_, fn: string) => `${fn.toLowerCase()}(`,
);
return `${reference} ${op} ${formattedValue}`;
})
.filter((expression) => expression !== ''); // Remove empty expressions

View File

@@ -26,6 +26,7 @@ import {
} from 'api/ai-assistant/chat';
import ROUTES from 'constants/routes';
import { QueryParams } from 'constants/query';
import { buildExplorerNavigationUrl } from 'utils/buildExplorerNavigationUrl';
import { openInNewTab } from 'utils/navigation';
import {
ArchiveRestore,
@@ -47,6 +48,10 @@ import { AIAssistantEvents, SuggestedPromptCategory } from '../../events';
import { useAIAssistantAnalyticsContext } from '../../hooks/useAIAssistantAnalyticsContext';
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
import {
getPanelTypeForRequestType,
requestTypeFromActionQuery,
} from './utils/getPanelTypeForRequestType';
import { openSavedViewByKey } from './utils/openSavedView';
import {
isSavedViewOpenAction,
@@ -333,6 +338,11 @@ function applyFilter(action: MessageActionDTO, deps: ApplyFilterDeps): void {
);
return;
}
// Read before toUrlCompositeQuery, which drops the requestType envelope.
const panelType = getPanelTypeForRequestType(
requestTypeFromActionQuery(action.query),
action.signal,
);
const normalized = withDerivedFilterExpressions(urlQuery as unknown as Query);
// eslint-disable-next-line no-console
console.log('[apply_filter] normalized', normalized);
@@ -342,7 +352,9 @@ function applyFilter(action: MessageActionDTO, deps: ApplyFilterDeps): void {
normalized.builder.queryData.forEach((q, i) => {
deps.handleSetQueryData(i, q);
});
deps.redirectWithQueryBuilderData(normalized);
deps.redirectWithQueryBuilderData(normalized, {
[QueryParams.panelTypes]: panelType,
});
return;
}
const base = explorerRouteForSignal(action.signal);
@@ -353,8 +365,10 @@ function applyFilter(action: MessageActionDTO, deps: ApplyFilterDeps): void {
}
// eslint-disable-next-line no-console
console.log('[apply_filter] off-page → navigate', base);
const encoded = encodeURIComponent(JSON.stringify(normalized));
navigate(`${base}?${QueryParams.compositeQuery}=${encoded}`);
const url = buildExplorerNavigationUrl(base, normalized, {
[QueryParams.panelTypes]: panelType,
});
navigate(url);
}
/** Picks the right rollback API call for a given action kind. */
@@ -613,6 +627,7 @@ export default function ActionsSection({
disabled={isSuccess}
loading={isLoading}
aria-label={action.label}
testId={`ai-action-chip-${action.kind}`}
prefix={icon}
>
<span className={styles.chipLabel}>{action.label}</span>

View File

@@ -1,7 +1,16 @@
import { undoExecution } from 'api/ai-assistant/chat';
import type { MessageActionDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import { MessageActionKindDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import {
ApplyFilterSignalDTO,
MessageActionKindDTO,
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import { Querybuildertypesv5RequestTypeDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { getCurrentLocation } from 'lib/router/navigation';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { QueryBuilderContextType } from 'types/common/queryBuilder';
import ActionsSection from '../ActionsSection';
@@ -28,6 +37,37 @@ function renderUndoChip(): HTMLElement {
return screen.getByRole('button', { name: 'Undo change' });
}
// Labels differ per test: auto-applied filters are deduped module-wide by label.
function countByServiceAction(label: string): MessageActionDTO {
return {
kind: MessageActionKindDTO.apply_filter,
label,
signal: ApplyFilterSignalDTO.logs,
query: {
requestType: Querybuildertypesv5RequestTypeDTO.scalar,
compositeQuery: {
queries: [
{
type: 'builder_query',
spec: {
name: 'A',
signal: 'logs',
aggregations: [{ expression: 'count()' }],
groupBy: [{ name: 'service.name' }],
},
},
],
},
},
};
}
function currentPanelTypesParam(): string | null {
return new URLSearchParams(getCurrentLocation().search).get(
QueryParams.panelTypes,
);
}
describe('ActionsSection rollback chip', () => {
beforeEach(() => {
mockUndo.mockReset();
@@ -81,3 +121,52 @@ describe('ActionsSection rollback chip', () => {
).resolves.toBeInTheDocument();
});
});
describe('ActionsSection apply_filter', () => {
it('opens the explorer in the Table view when the chip is clicked from another page', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<ActionsSection
actions={[countByServiceAction('Open in Logs Explorer')]}
messageId="message-1"
/>,
undefined,
{ initialRoute: ROUTES.HOME },
);
await user.click(screen.getByTestId('ai-action-chip-apply_filter'));
await waitFor(() =>
expect(getCurrentLocation().pathname).toBe(ROUTES.LOGS_EXPLORER),
);
expect(currentPanelTypesParam()).toBe(JSON.stringify(PANEL_TYPES.TABLE));
});
it('switches the open explorer to the Table view when applied on it', async () => {
const redirectWithQueryBuilderData = jest.fn() as jest.MockedFunction<
QueryBuilderContextType['redirectWithQueryBuilderData']
>;
const handleSetQueryData = jest.fn() as jest.MockedFunction<
QueryBuilderContextType['handleSetQueryData']
>;
render(
<ActionsSection
actions={[countByServiceAction('Apply on Logs Explorer')]}
messageId="message-2"
/>,
undefined,
{
initialRoute: ROUTES.LOGS_EXPLORER,
queryBuilderOverrides: { redirectWithQueryBuilderData, handleSetQueryData },
},
);
await waitFor(() =>
expect(redirectWithQueryBuilderData).toHaveBeenCalledTimes(1),
);
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
expect.objectContaining({ builder: expect.anything() }),
{ [QueryParams.panelTypes]: PANEL_TYPES.TABLE },
);
});
});

View File

@@ -0,0 +1,106 @@
import { ApplyFilterSignalDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import { Querybuildertypesv5RequestTypeDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
getPanelTypeForRequestType,
requestTypeFromActionQuery,
} from '../getPanelTypeForRequestType';
describe('getPanelTypeForRequestType', () => {
it.each([
[
Querybuildertypesv5RequestTypeDTO.scalar,
ApplyFilterSignalDTO.logs,
PANEL_TYPES.TABLE,
],
[
Querybuildertypesv5RequestTypeDTO.time_series,
ApplyFilterSignalDTO.logs,
PANEL_TYPES.TIME_SERIES,
],
[
Querybuildertypesv5RequestTypeDTO.raw,
ApplyFilterSignalDTO.logs,
PANEL_TYPES.LIST,
],
[
Querybuildertypesv5RequestTypeDTO.trace,
ApplyFilterSignalDTO.traces,
PANEL_TYPES.TRACE,
],
[
Querybuildertypesv5RequestTypeDTO.trace,
ApplyFilterSignalDTO.logs,
PANEL_TYPES.LIST,
],
[
Querybuildertypesv5RequestTypeDTO.trace,
ApplyFilterSignalDTO.metrics,
PANEL_TYPES.LIST,
],
])('maps %s on the %s signal to the %s view', (requestType, signal, view) => {
expect(getPanelTypeForRequestType(requestType, signal)).toBe(view);
});
it.each([
undefined,
null,
'',
Querybuildertypesv5RequestTypeDTO.raw_stream,
Querybuildertypesv5RequestTypeDTO.heatmap,
'distribution',
'nonsense',
'toString',
'constructor',
42,
{},
])(
'falls back to the List view for unmapped or invalid requestType (%p)',
(value) => {
expect(getPanelTypeForRequestType(value, ApplyFilterSignalDTO.traces)).toBe(
PANEL_TYPES.LIST,
);
},
);
});
describe('requestTypeFromActionQuery', () => {
it('reads the requestType envelope field and returns undefined when absent', () => {
expect(
requestTypeFromActionQuery({
requestType: 'scalar',
schemaVersion: 'v5',
compositeQuery: { queries: [] },
}),
).toBe('scalar');
expect(requestTypeFromActionQuery({})).toBeUndefined();
expect(requestTypeFromActionQuery(null)).toBeUndefined();
expect(requestTypeFromActionQuery(undefined)).toBeUndefined();
});
it('maps a grouped count() payload to the Table view', () => {
const query = {
requestType: 'scalar',
schemaVersion: 'v5',
compositeQuery: {
queries: [
{
type: 'builder_query',
spec: {
signal: 'logs',
aggregations: [{ expression: 'count()' }],
groupBy: [{ name: 'service.name' }],
},
},
],
},
};
expect(
getPanelTypeForRequestType(
requestTypeFromActionQuery(query),
ApplyFilterSignalDTO.logs,
),
).toBe(PANEL_TYPES.TABLE);
});
});

View File

@@ -0,0 +1,30 @@
import {
ApplyFilterSignalDTO,
type MessageActionDTOQuery,
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import { Querybuildertypesv5RequestTypeDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
export function getPanelTypeForRequestType(
requestType: unknown,
signal: ApplyFilterSignalDTO,
): PANEL_TYPES {
switch (requestType) {
case Querybuildertypesv5RequestTypeDTO.scalar:
return PANEL_TYPES.TABLE;
case Querybuildertypesv5RequestTypeDTO.time_series:
return PANEL_TYPES.TIME_SERIES;
case Querybuildertypesv5RequestTypeDTO.trace:
return signal === ApplyFilterSignalDTO.traces
? PANEL_TYPES.TRACE
: PANEL_TYPES.LIST;
default:
return PANEL_TYPES.LIST;
}
}
export function requestTypeFromActionQuery(
query: MessageActionDTOQuery | undefined,
): unknown {
return query?.requestType;
}

View File

@@ -10,10 +10,6 @@
gap: var(--spacing-3);
}
.mono {
composes: mono from '../shared.module.scss';
}
// CodeBlock renders the copy button's wrapper first and the highlighted code last;
// the code theme sets the block's margin and padding inline, hence !important.
.command {

View File

@@ -1,27 +1,28 @@
import { ReactNode } from 'react';
import { Typography } from '@signozhq/ui/typography';
import CodeBlock from 'components/CodeBlock/CodeBlock';
import styles from './AgentSetupCommand.module.scss';
interface AgentSetupCommandProps {
deploymentProjectId: string;
description: ReactNode;
command: string;
testId: string;
}
function AgentSetupCommand({
deploymentProjectId,
description,
command,
testId,
}: AgentSetupCommandProps): JSX.Element {
return (
<div className={styles.commands} data-testid="gcp-agent-command">
<div className={styles.commands} data-testid={testId}>
<div className={styles.commandsHead}>
<Typography.Text weight="bold" size="base">
Deploy the SigNoz agent
</Typography.Text>
<Typography.Text as="p" size="small" color="muted">
Run this command in Google Cloud Shell, or in a terminal where gcloud is
signed in to <span className={styles.mono}>{deploymentProjectId}</span>. It
contains your SigNoz keys, so do not share it.
{description}
</Typography.Text>
</div>
<CodeBlock language="bash" code={command} className={styles.command} />

View File

@@ -15,14 +15,7 @@ function AlertMessage({
<Callout
title={
<div className="cloud-account-setup-form__alert-message">
<Spin
indicator={
<LoaderCircle
size={14}
className="anticon anticon-loading anticon-spin ant-spin-dot"
/>
}
/>
<Spin indicator={<LoaderCircle size={14} className="animate-spin" />} />
Waiting for connection, retrying in{' '}
<span className="retry-time">10</span> secs...
</div>

View File

@@ -12,6 +12,13 @@
overflow: hidden;
}
&__footer {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
&__content {
flex: 1;
overflow-y: auto;
@@ -144,52 +151,6 @@
}
}
.cloud-account-setup-form__code-block-tabs {
padding: 8px;
border-radius: 4px;
border: 1px solid var(--l2-border);
background: var(--l2-background);
&-header {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 12px;
&-title {
color: var(--l1-foreground);
font-size: 14px;
font-weight: 500;
line-height: 20px;
letter-spacing: -0.07px;
}
&-description {
color: var(--l2-foreground);
font-size: 12px;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.06px;
}
}
[role='tablist'] {
gap: 8px !important;
}
[role='tabpanel'] {
padding: 0 !important;
}
[data-slot='tabs-item'] {
padding: 4px 24px !important;
border: none !important;
background-color: transparent !important;
font-size: 12px !important;
}
}
@keyframes cloud-account-setup-accordion-reveal {
from {
opacity: 0;

View File

@@ -1,15 +1,15 @@
import { useCallback } from 'react';
import { Color } from '@signozhq/design-tokens';
import { Button } from '@signozhq/ui/button';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import { useIntegrationModal } from 'hooks/integration/aws/useIntegrationModal';
import { SquareArrowOutUpRight } from '@signozhq/icons';
import {
ActiveViewEnum,
IntegrationModalProps,
ModalStateEnum,
} from '../../../HeroSection/types';
import CloseSetupConfirm from '../../CloseSetupConfirm/CloseSetupConfirm';
import { useCloseSetupConfirm } from '../../CloseSetupConfirm/useCloseSetupConfirm';
import { RegionForm } from '../RegionForm/RegionForm';
import './CloudAccountSetupModal.style.scss';
@@ -24,22 +24,24 @@ function CloudAccountSetupModal({
activeView,
selectedRegions,
includeAllRegions,
isGeneratingUrl,
isGeneratingCommand,
connectionCommand,
setSelectedRegions,
setIncludeAllRegions,
handleRegionSelect,
handleSubmit,
handleClose,
setActiveView,
accountId,
handleRegionChange,
connectionParams,
isConnectionParamsLoading,
handleConnectionSuccess,
handleConnectionTimeout,
handleConnectionError,
checkConnectionAgain,
} = useIntegrationModal({ onClose });
const { isConfirmOpen, setIsConfirmOpen, requestClose } = useCloseSetupConfirm(
{ shouldConfirm: Boolean(connectionCommand), onClose: handleClose },
);
const renderContent = useCallback(() => {
return (
<div className="cloud-account-setup-modal__content">
@@ -50,15 +52,12 @@ function CloudAccountSetupModal({
includeAllRegions={includeAllRegions}
onRegionSelect={handleRegionSelect}
onSubmit={handleSubmit}
accountId={accountId}
connectionCommand={connectionCommand}
handleRegionChange={handleRegionChange}
connectionParams={connectionParams}
isConnectionParamsLoading={isConnectionParamsLoading}
setSelectedRegions={setSelectedRegions}
setIncludeAllRegions={setIncludeAllRegions}
onConnectionSuccess={handleConnectionSuccess}
onConnectionTimeout={handleConnectionTimeout}
onConnectionError={handleConnectionError}
/>
</div>
);
@@ -69,38 +68,50 @@ function CloudAccountSetupModal({
includeAllRegions,
handleRegionSelect,
handleSubmit,
accountId,
connectionCommand,
handleRegionChange,
connectionParams,
isConnectionParamsLoading,
setSelectedRegions,
setIncludeAllRegions,
handleConnectionSuccess,
handleConnectionTimeout,
handleConnectionError,
]);
const footer = (
<div className="cloud-account-setup-modal__footer">
<Button
disabledTooltip={
selectedRegions.length === 0
? 'Select at least one region'
: 'Waiting for the account to connect'
}
size="md"
variant="solid"
color="primary"
prefix={<SquareArrowOutUpRight size={17} color={Color.BG_VANILLA_100} />}
onClick={handleSubmit}
testId="aws-launch-cfn-btn"
disabled={
selectedRegions.length === 0 || modalState === ModalStateEnum.WAITING
}
loading={isLoading || isGeneratingUrl}
variant="outlined"
color="secondary"
onClick={requestClose}
testId="aws-cancel-btn"
>
Launch Cloud Formation Template
Cancel
</Button>
{!connectionCommand && (
<Button
disabledTooltip="Select at least one region"
size="md"
variant="solid"
color="primary"
onClick={handleSubmit}
disabled={selectedRegions.length === 0}
loading={isLoading || isGeneratingCommand}
testId="aws-generate-command-btn"
>
Generate Command
</Button>
)}
{modalState === ModalStateEnum.ERROR && connectionCommand && (
<Button
size="md"
variant="solid"
color="primary"
onClick={checkConnectionAgain}
testId="aws-check-connection-btn"
>
Check Again
</Button>
)}
</div>
);
@@ -113,22 +124,17 @@ function CloudAccountSetupModal({
const viewConfigs = {
[ActiveViewEnum.FORM]: {
title: 'Add AWS Account',
okText: (
<div className="cloud-account-setup-form__submit-button-content">
Launch Cloud Formation Template{' '}
<SquareArrowOutUpRight size={17} color={Color.BG_VANILLA_100} />
</div>
),
okText: 'Generate Command',
onOk: handleSubmit,
disabled:
isLoading || isGeneratingUrl || modalState === ModalStateEnum.WAITING,
isLoading || isGeneratingCommand || modalState === ModalStateEnum.WAITING,
cancelButtonProps: { style: { display: 'none' } },
},
[ActiveViewEnum.SELECT_REGIONS]: {
title: 'Which regions do you want to monitor?',
okText: `Confirm Selection (${getSelectedRegionsCount()})`,
onOk: (): void => setActiveView(ActiveViewEnum.FORM),
isLoading: isLoading || isGeneratingUrl,
isLoading: isLoading || isGeneratingCommand,
cancelButtonProps: { style: { display: 'block' } },
disabled: false,
},
@@ -140,7 +146,7 @@ function CloudAccountSetupModal({
handleSubmit,
getSelectedRegionsCount,
isLoading,
isGeneratingUrl,
isGeneratingCommand,
activeView,
setActiveView,
]);
@@ -149,23 +155,32 @@ function CloudAccountSetupModal({
const handleDrawerOpenChange = (open: boolean): void => {
if (!open) {
handleClose();
requestClose();
}
};
return (
<DrawerWrapper
open={true}
className="cloud-account-setup-modal"
onOpenChange={handleDrawerOpenChange}
direction="right"
showCloseButton
title={modalConfig.title}
width="wide"
footer={footer}
>
{renderContent()}
</DrawerWrapper>
<>
<DrawerWrapper
open={true}
className="cloud-account-setup-modal"
onOpenChange={handleDrawerOpenChange}
direction="right"
showCloseButton
title={modalConfig.title}
width="wide"
footer={footer}
>
{renderContent()}
</DrawerWrapper>
<CloseSetupConfirm
providerName="AWS"
testIdPrefix="aws"
open={isConfirmOpen}
onOpenChange={setIsConfirmOpen}
onConfirm={handleClose}
/>
</>
);
}

View File

@@ -11,11 +11,9 @@ import { RegionSelector } from './RegionForm/RegionSelector';
function RegionDeploymentSection({
regions,
handleRegionChange,
isFormDisabled,
}: {
regions: Region[];
handleRegionChange: (value: string) => void;
isFormDisabled: boolean;
}): JSX.Element {
return (
<div className="cloud-account-setup-form__form-group">
@@ -36,7 +34,6 @@ function RegionDeploymentSection({
suffixIcon={<ChevronDown size={16} color={Color.BG_VANILLA_400} />}
className="cloud-account-setup-form__select integrations-select"
onChange={handleRegionChange}
disabled={isFormDisabled}
getPopupContainer={popupContainer}
>
{regions.flatMap((region) =>

View File

@@ -1,14 +1,8 @@
import { useRef } from 'react';
import { Form } from 'antd';
import { useGetAccount } from 'api/generated/services/cloudintegration';
import cx from 'classnames';
import { INTEGRATION_TYPES } from 'container/Integrations/constants';
import {
ModalStateEnum,
RegionFormProps,
} from 'container/Integrations/HeroSection/types';
import { RegionFormProps } from 'container/Integrations/HeroSection/types';
import { regions } from 'utils/regions';
import AgentSetupCommand from '../../AgentSetupCommand/AgentSetupCommand';
import AlertMessage from '../../AlertMessage';
import {
ComplianceNote,
@@ -22,56 +16,13 @@ export function RegionForm({
modalState,
selectedRegions,
onSubmit,
accountId,
connectionCommand,
handleRegionChange,
connectionParams,
isConnectionParamsLoading,
setSelectedRegions,
setIncludeAllRegions,
onConnectionSuccess,
onConnectionTimeout,
onConnectionError,
}: RegionFormProps): JSX.Element {
const startTimeRef = useRef(Date.now());
const refetchInterval = 10 * 1000;
const errorTimeout = 10 * 60 * 1000;
const { isLoading: isAccountStatusLoading } = useGetAccount(
{
cloudProvider: INTEGRATION_TYPES.AWS,
id: accountId ?? '',
},
{
query: {
refetchInterval,
enabled: !!accountId && modalState === ModalStateEnum.WAITING,
onSuccess: (response) => {
const isConnected =
Boolean(response.data.providerAccountId) &&
response.data.removedAt === null;
if (isConnected) {
const cloudAccountId =
response.data.providerAccountId ?? response.data.id;
onConnectionSuccess({
cloudAccountId,
status: response.data.agentReport,
});
} else if (Date.now() - startTimeRef.current >= errorTimeout) {
onConnectionTimeout({ id: accountId });
}
},
onError: () => {
onConnectionError();
},
},
},
);
const isFormDisabled =
modalState === ModalStateEnum.WAITING || isAccountStatusLoading;
return (
<Form
form={form}
@@ -79,28 +30,30 @@ export function RegionForm({
layout="vertical"
onFinish={onSubmit}
>
<div
className={cx(`cloud-account-setup-form__content`, {
disabled: isFormDisabled,
})}
>
<RegionDeploymentSection
regions={regions}
handleRegionChange={handleRegionChange}
isFormDisabled={isFormDisabled}
{connectionCommand ? (
<AgentSetupCommand
description="Run this command in AWS CloudShell, or in a terminal where the AWS CLI is signed in to the account you want to monitor. It contains your SigNoz keys, so do not share it."
command={connectionCommand}
testId="aws-agent-command"
/>
<MonitoringRegionsSection
selectedRegions={selectedRegions}
setSelectedRegions={setSelectedRegions}
setIncludeAllRegions={setIncludeAllRegions}
/>
<ComplianceNote />
<RenderConnectionFields
isConnectionParamsLoading={isConnectionParamsLoading}
connectionParams={connectionParams}
isFormDisabled={isFormDisabled}
/>
</div>
) : (
<div className="cloud-account-setup-form__content">
<RegionDeploymentSection
regions={regions}
handleRegionChange={handleRegionChange}
/>
<MonitoringRegionsSection
selectedRegions={selectedRegions}
setSelectedRegions={setSelectedRegions}
setIncludeAllRegions={setIncludeAllRegions}
/>
<ComplianceNote />
<RenderConnectionFields
isConnectionParamsLoading={isConnectionParamsLoading}
connectionParams={connectionParams}
/>
</div>
)}
<div className="cloud-account-setup-form__alert">
<AlertMessage modalState={modalState} />

View File

@@ -5,11 +5,9 @@ import { CloudintegrationtypesCredentialsDTO } from 'api/generated/services/sigN
function RenderConnectionFields({
isConnectionParamsLoading,
connectionParams,
isFormDisabled,
}: {
isConnectionParamsLoading?: boolean;
connectionParams?: CloudintegrationtypesCredentialsDTO | null;
isFormDisabled?: boolean;
}): JSX.Element | null {
if (
isConnectionParamsLoading ||
@@ -29,7 +27,7 @@ function RenderConnectionFields({
label="Ingestion URL"
rules={[{ required: true, message: 'Please enter ingestion URL' }]}
>
<Input placeholder="Enter ingestion URL" disabled={isFormDisabled} />
<Input placeholder="Enter ingestion URL" />
</Form.Item>
)}
{!connectionParams?.ingestionKey && (
@@ -38,7 +36,7 @@ function RenderConnectionFields({
label="Ingestion Key"
rules={[{ required: true, message: 'Please enter ingestion key' }]}
>
<Input placeholder="Enter ingestion key" disabled={isFormDisabled} />
<Input placeholder="Enter ingestion key" />
</Form.Item>
)}
{!connectionParams?.sigNozApiUrl && (
@@ -47,7 +45,7 @@ function RenderConnectionFields({
label="SigNoz API URL"
rules={[{ required: true, message: 'Please enter SigNoz API URL' }]}
>
<Input placeholder="Enter SigNoz API URL" disabled={isFormDisabled} />
<Input placeholder="Enter SigNoz API URL" />
</Form.Item>
)}
{!connectionParams?.sigNozApiKey && (
@@ -56,7 +54,7 @@ function RenderConnectionFields({
label="SigNoz API KEY"
rules={[{ required: true, message: 'Please enter SigNoz API Key' }]}
>
<Input placeholder="Enter SigNoz API Key" disabled={isFormDisabled} />
<Input placeholder="Enter SigNoz API Key" />
</Form.Item>
)}
</Form.Item>
@@ -65,7 +63,6 @@ function RenderConnectionFields({
RenderConnectionFields.defaultProps = {
connectionParams: null,
isFormDisabled: false,
isConnectionParamsLoading: false,
};

View File

@@ -0,0 +1,168 @@
import { ComponentProps } from 'react';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { rest, server } from 'mocks-server/server';
import { RestRequest } from 'msw';
import type logEvent from '../../../../../api/common/logEvent';
import CloudAccountSetupModal from '../AddNewAccount/CloudAccountSetupModal';
// `useIntegrationModal` imports logEvent by relative path, which the
// jest.config moduleNameMapper (keyed on the `api/common/logEvent` alias) does
// not intercept, so mock the resolved module directly.
jest.mock('../../../../../api/common/logEvent', () => ({
__esModule: true,
default: jest.fn() as jest.MockedFunction<typeof logEvent>,
}));
// The form flow drives antd selects and typing, which outruns the 5s default on CI.
jest.setTimeout(15_000);
type OnClose = ComponentProps<typeof CloudAccountSetupModal>['onClose'];
const mockOnClose = (): jest.MockedFunction<OnClose> =>
jest.fn() as jest.MockedFunction<OnClose>;
const AWS_CREDENTIALS_URL =
'http://localhost/api/v1/cloud_integrations/aws/credentials';
const AWS_ACCOUNTS_URL =
'http://localhost/api/v1/cloud_integrations/aws/accounts';
const ACCOUNT_ID = 'ci-aws-1234';
const credentials = {
sigNozApiUrl: 'https://signoz.example.com',
sigNozApiKey: 'signoz-api-key',
ingestionUrl: 'https://ingest.example.com',
ingestionKey: 'ingestion-key',
};
const cliCommand = `curl -fsSL 'https://signoz.io/foundry.sh' | bash -s -- cloudintegration aws cloudformation --region 'us-east-1'`;
const mockApi = ({ failFirstPoll = false } = {}): {
createAccountPayload: unknown;
pollCount: () => number;
} => {
let polls = 0;
const api = {
createAccountPayload: null as unknown,
pollCount: (): number => polls,
};
server.use(
rest.get(AWS_CREDENTIALS_URL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: credentials })),
),
rest.post(AWS_ACCOUNTS_URL, async (req: RestRequest, res, ctx) => {
api.createAccountPayload = await req.json();
return res(
ctx.status(201),
ctx.json({
status: 'success',
data: { id: ACCOUNT_ID, connectionArtifact: { aws: { cliCommand } } },
}),
);
}),
rest.get(`${AWS_ACCOUNTS_URL}/${ACCOUNT_ID}`, (_req, res, ctx) => {
polls += 1;
if (failFirstPoll && polls === 1) {
return res(ctx.status(500), ctx.json({ status: 'error' }));
}
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
id: ACCOUNT_ID,
providerAccountId: null,
agentReport: null,
removedAt: null,
},
}),
);
}),
);
return api;
};
describe('AWS CloudAccountSetupModal', () => {
it('creates the account and shows its setup command', async () => {
const api = mockApi();
const openSpy = jest.spyOn(window, 'open').mockImplementation(() => null);
const user = userEvent.setup();
const onClose = mockOnClose();
render(<CloudAccountSetupModal onClose={onClose} />);
await user.click(await screen.findByRole('combobox'));
await user.click(await screen.findByText('US East (N. Virginia)'));
await user.click(await screen.findByRole('checkbox', { name: /us-east-1/i }));
await user.click(screen.getByTestId('aws-generate-command-btn'));
await waitFor(() => {
expect(screen.getByTestId('aws-agent-command')).toHaveTextContent(
cliCommand,
);
});
expect(api.createAccountPayload).toStrictEqual({
config: { aws: { deploymentRegion: 'us-east-1', regions: ['us-east-1'] } },
credentials,
});
expect(
screen.queryByTestId('aws-generate-command-btn'),
).not.toBeInTheDocument();
expect(screen.queryAllByRole('combobox')).toHaveLength(0);
expect(screen.getByText(/waiting for connection/i)).toBeInTheDocument();
expect(openSpy).not.toHaveBeenCalled();
openSpy.mockRestore();
await user.click(screen.getByTestId('aws-cancel-btn'));
await user.click(await screen.findByTestId('aws-continue-setup-btn'));
await waitFor(() => {
expect(
screen.queryByTestId('aws-continue-setup-btn'),
).not.toBeInTheDocument();
});
expect(onClose).not.toHaveBeenCalled();
await user.click(screen.getByTestId('aws-cancel-btn'));
await user.click(await screen.findByTestId('aws-close-setup-btn'));
expect(onClose).toHaveBeenCalledTimes(1);
});
it('closes without asking before a setup command exists', async () => {
mockApi();
const user = userEvent.setup();
const onClose = mockOnClose();
render(<CloudAccountSetupModal onClose={onClose} />);
await user.click(await screen.findByTestId('aws-cancel-btn'));
expect(onClose).toHaveBeenCalledTimes(1);
expect(screen.queryByTestId('aws-close-setup-btn')).not.toBeInTheDocument();
});
it('resumes polling with Check Again after the connection check fails', async () => {
const api = mockApi({ failFirstPoll: true });
const user = userEvent.setup();
render(<CloudAccountSetupModal onClose={mockOnClose()} />);
await user.click(await screen.findByRole('combobox'));
await user.click(await screen.findByText('US East (N. Virginia)'));
await user.click(await screen.findByRole('checkbox', { name: /us-east-1/i }));
await user.click(screen.getByTestId('aws-generate-command-btn'));
await user.click(await screen.findByTestId('aws-check-connection-btn'));
await waitFor(() => expect(api.pollCount()).toBe(2));
await screen.findByText(/waiting for connection/i);
expect(
screen.queryByTestId('aws-check-connection-btn'),
).not.toBeInTheDocument();
expect(screen.getByTestId('aws-agent-command')).toHaveTextContent(cliCommand);
});
});

View File

@@ -1,23 +1,10 @@
import { useCallback, useRef, useState } from 'react';
import { Color } from '@signozhq/design-tokens';
import {
ChevronDown,
ChevronRight,
LoaderCircle,
SquareArrowOutUpRight,
} from '@signozhq/icons';
import { useCallback, useState } from 'react';
import { ChevronDown, ChevronRight, LoaderCircle } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Callout } from '@signozhq/ui/callout';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import { Tabs } from '@signozhq/ui/tabs';
import { Form, Select, Spin } from 'antd';
import { useGetAccount } from 'api/generated/services/cloudintegration';
import { CloudintegrationtypesAccountDTO } from 'api/generated/services/sigNoz.schemas';
import CodeBlock from 'components/CodeBlock/CodeBlock';
import {
AZURE_REGIONS,
INTEGRATION_TYPES,
} from 'container/Integrations/constants';
import { AZURE_REGIONS } from 'container/Integrations/constants';
import {
IntegrationModalProps,
ModalStateEnum,
@@ -25,15 +12,13 @@ import {
import { popupContainer } from 'utils/selectPopupContainer';
import { useIntegrationModal } from '../../../../../hooks/integration/azure/useIntegrationModal';
import AgentSetupCommand from '../../AgentSetupCommand/AgentSetupCommand';
import CloseSetupConfirm from '../../CloseSetupConfirm/CloseSetupConfirm';
import { useCloseSetupConfirm } from '../../CloseSetupConfirm/useCloseSetupConfirm';
import RenderConnectionFields from '../../AmazonWebServices/RegionForm/RenderConnectionParams';
import '../../AmazonWebServices/AddNewAccount/CloudAccountSetupModal.style.scss';
const AZURE_CLI_DESC =
'Paste the following command if you have Azure CLI setup locally on your machine or use BASH CloudShell on Azure portal with above mentioned permissions.';
const AZURE_POWERSHELL_DESC =
'Paste the following command in PowerShell CloudShell on Azure portal, you can switch to PowerShell on Azure portal.';
function CloudAccountSetupModal({
onClose,
}: IntegrationModalProps): JSX.Element {
@@ -41,53 +26,19 @@ function CloudAccountSetupModal({
form,
modalState,
isLoading,
accountId,
connectionCommands,
connectionCommand,
handleSubmit,
handleClose,
connectionParams,
isConnectionParamsLoading,
handleConnectionSuccess,
handleConnectionTimeout,
handleConnectionError,
checkConnectionAgain,
} = useIntegrationModal({ onClose });
const startTimeRef = useRef(Date.now());
const refetchInterval = 10 * 1000;
const errorTimeout = 10 * 60 * 1000;
const { isConfirmOpen, setIsConfirmOpen, requestClose } = useCloseSetupConfirm(
{ shouldConfirm: Boolean(connectionCommand), onClose: handleClose },
);
const [isHowItWorksOpen, setIsHowItWorksOpen] = useState(true);
const [activeTab, setActiveTab] = useState('cli');
useGetAccount(
{
cloudProvider: INTEGRATION_TYPES.AZURE,
id: accountId ?? '',
},
{
query: {
enabled: Boolean(accountId) && modalState === ModalStateEnum.WAITING,
refetchInterval,
select: (response): CloudintegrationtypesAccountDTO => response.data,
onSuccess: (account) => {
const isConnected =
Boolean(account.providerAccountId) && account.removedAt === null;
if (isConnected) {
handleConnectionSuccess({
cloudAccountId: account.providerAccountId ?? account.id,
status: account.agentReport,
});
} else if (Date.now() - startTimeRef.current >= errorTimeout) {
handleConnectionTimeout({ id: accountId });
}
},
onError: () => {
handleConnectionError();
},
},
},
);
const renderAlert = useCallback((): JSX.Element | null => {
if (modalState === ModalStateEnum.WAITING) {
@@ -96,14 +47,7 @@ function CloudAccountSetupModal({
<Callout
title={
<div className="cloud-account-setup-form__alert-message">
<Spin
indicator={
<LoaderCircle
size={14}
className="anticon anticon-loading anticon-spin ant-spin-dot"
/>
}
/>
<Spin indicator={<LoaderCircle size={14} className="animate-spin" />} />
Waiting for Azure account connection, retrying in{' '}
<span className="retry-time">10</span> secs...
</div>
@@ -136,234 +80,233 @@ function CloudAccountSetupModal({
const footer = (
<div className="cloud-account-setup-modal__footer">
{modalState === ModalStateEnum.FORM && (
<Button
size="md"
variant="outlined"
color="secondary"
onClick={requestClose}
testId="azure-cancel-btn"
>
Cancel
</Button>
{!connectionCommand && (
<Button
size="md"
variant="solid"
color="primary"
prefix={<SquareArrowOutUpRight size={17} color={Color.BG_VANILLA_100} />}
onClick={handleSubmit}
loading={isLoading}
testId="azure-generate-command-btn"
>
Generate Azure Setup Commands
Generate Command
</Button>
)}
{modalState === ModalStateEnum.ERROR && connectionCommand && (
<Button
size="md"
variant="solid"
color="primary"
onClick={checkConnectionAgain}
testId="azure-check-connection-btn"
>
Check Again
</Button>
)}
</div>
);
return (
<DrawerWrapper
open={true}
className="cloud-account-setup-modal"
onOpenChange={(open): void => {
if (!open) {
handleClose();
}
}}
direction="right"
showCloseButton
title="Add Azure Account"
width="wide"
footer={footer}
>
<div className="cloud-account-setup-modal__content">
<div className="cloud-account-setup-prerequisites">
<div className="cloud-account-setup-prerequisites__title">
Prerequisites
<>
<DrawerWrapper
open={true}
className="cloud-account-setup-modal"
onOpenChange={(open): void => {
if (!open) {
requestClose();
}
}}
direction="right"
showCloseButton
title="Add Azure Account"
width="wide"
footer={footer}
>
<div className="cloud-account-setup-modal__content">
<div className="cloud-account-setup-prerequisites">
<div className="cloud-account-setup-prerequisites__title">
Prerequisites
</div>
<ul className="cloud-account-setup-prerequisites__list">
<li className="cloud-account-setup-prerequisites__list-item">
<span className="cloud-account-setup-prerequisites__list-item-bullet">
—
</span>{' '}
<span className="cloud-account-setup-prerequisites__list-item-text">
Ensure that you&apos;re logged in to the Azure workspace which you want
to monitor.
</span>
</li>
<li className="cloud-account-setup-prerequisites__list-item">
<span className="cloud-account-setup-prerequisites__list-item-bullet">
—
</span>{' '}
<span className="cloud-account-setup-prerequisites__list-item-text">
Ensure that you either have the{' '}
<span className="cloud-account-setup-prerequisites__list-item-highlight">
Owner
</span>{' '}
role OR
</span>
</li>
<li className="cloud-account-setup-prerequisites__list-item">
<span className="cloud-account-setup-prerequisites__list-item-bullet">
—
</span>{' '}
<span className="cloud-account-setup-prerequisites__list-item-text">
Both the{' '}
<span className="cloud-account-setup-prerequisites__list-item-highlight">
Contributor
</span>{' '}
and{' '}
<span className="cloud-account-setup-prerequisites__list-item-highlight">
user access admin
</span>{' '}
roles
</span>
</li>
</ul>
</div>
<ul className="cloud-account-setup-prerequisites__list">
<li className="cloud-account-setup-prerequisites__list-item">
<span className="cloud-account-setup-prerequisites__list-item-bullet">
—
</span>{' '}
<span className="cloud-account-setup-prerequisites__list-item-text">
Ensure that you&apos;re logged in to the Azure workspace which you want
to monitor.
</span>
</li>
<li className="cloud-account-setup-prerequisites__list-item">
<span className="cloud-account-setup-prerequisites__list-item-bullet">
—
</span>{' '}
<span className="cloud-account-setup-prerequisites__list-item-text">
Ensure that you either have the{' '}
<span className="cloud-account-setup-prerequisites__list-item-highlight">
Owner
</span>{' '}
role OR
</span>
</li>
<li className="cloud-account-setup-prerequisites__list-item">
<span className="cloud-account-setup-prerequisites__list-item-bullet">
—
</span>{' '}
<span className="cloud-account-setup-prerequisites__list-item-text">
Both the{' '}
<span className="cloud-account-setup-prerequisites__list-item-highlight">
Contributor
</span>{' '}
and{' '}
<span className="cloud-account-setup-prerequisites__list-item-highlight">
user access admin
</span>{' '}
roles
</span>
</li>
</ul>
</div>
<div className="cloud-account-setup-how-it-works-accordion">
<div
className={`cloud-account-setup-how-it-works-accordion__title ${
isHowItWorksOpen ? 'open' : ''
}`}
>
<Button
size="md"
variant="link"
color="secondary"
icon
aria-label="How it works"
onClick={(): void => setIsHowItWorksOpen(!isHowItWorksOpen)}
<div className="cloud-account-setup-how-it-works-accordion">
<div
className={`cloud-account-setup-how-it-works-accordion__title ${
isHowItWorksOpen ? 'open' : ''
}`}
>
{isHowItWorksOpen ? <ChevronDown /> : <ChevronRight />}
</Button>
<span className="cloud-account-setup-how-it-works-accordion__title-text">
How it works?
</span>
</div>
{isHowItWorksOpen && (
<div className="cloud-account-setup-how-it-works-accordion__description">
<div className="cloud-account-setup-how-it-works-accordion__description-item">
SigNoz will create new resource-group to manage the resources required
for this integration. The following steps will create a User-Assigned
Managed Identity with the necessary permissions and follows the
Principle of Least Privilege.
</div>
<div className="cloud-account-setup-how-it-works__description-item">
Once the Integration template is deployed, you can enable the services
you want to monitor right here in Signoz dashboard.
</div>
</div>
)}
</div>
<Form
form={form}
className="cloud-account-setup-form"
layout="vertical"
initialValues={{ resourceGroups: [] }}
>
<div className="cloud-account-setup-form__content">
<div className="cloud-account-setup-form__form-group">
<div className="cloud-account-setup-form__title">
Where should we deploy the SigNoz collector resources?
</div>
<div className="cloud-account-setup-form__description">
Choose the Azure region for deployment.
</div>
<Form.Item
name="region"
rules={[{ required: true, message: 'Please select a region' }]}
className="cloud-account-setup-form__form-item"
<Button
size="md"
variant="link"
color="secondary"
icon
aria-label="How it works"
onClick={(): void => setIsHowItWorksOpen(!isHowItWorksOpen)}
>
<Select
placeholder="e.g. East US"
options={AZURE_REGIONS.map((region) => ({
label: `${region.label} (${region.value})`,
value: region.value,
}))}
getPopupContainer={popupContainer}
disabled={modalState === ModalStateEnum.WAITING}
/>
</Form.Item>
{isHowItWorksOpen ? <ChevronDown /> : <ChevronRight />}
</Button>
<span className="cloud-account-setup-how-it-works-accordion__title-text">
How it works?
</span>
</div>
<div className="cloud-account-setup-form__form-group">
<div className="cloud-account-setup-form__title">
Which resource groups do you want to monitor?
</div>
<div className="cloud-account-setup-form__description">
Add one or more Azure resource group names.
</div>
<Form.Item
name="resourceGroups"
rules={[
{
required: true,
type: 'array',
min: 1,
message: 'Please add at least one resource group',
},
]}
className="cloud-account-setup-form__form-item"
>
<Select
mode="tags"
placeholder="e.g. prod-platform-rg"
tokenSeparators={[',']}
disabled={modalState === ModalStateEnum.WAITING}
/>
</Form.Item>
</div>
<RenderConnectionFields
isConnectionParamsLoading={isConnectionParamsLoading}
connectionParams={connectionParams}
isFormDisabled={modalState === ModalStateEnum.WAITING}
/>
{connectionCommands && (
<div className="cloud-account-setup-form__code-block-tabs-container">
<div className="cloud-account-setup-form__code-block-tabs-header">
<div className="cloud-account-setup-form__code-block-tabs-header-title">
Deploy Agent
</div>
<div className="cloud-account-setup-form__code-block-tabs-header-description">
{activeTab === 'cli' ? AZURE_CLI_DESC : AZURE_POWERSHELL_DESC}
</div>
{isHowItWorksOpen && (
<div className="cloud-account-setup-how-it-works-accordion__description">
<div className="cloud-account-setup-how-it-works-accordion__description-item">
SigNoz will create new resource-group to manage the resources required
for this integration. The following steps will create a User-Assigned
Managed Identity with the necessary permissions and follows the
Principle of Least Privilege.
</div>
<div className="cloud-account-setup-how-it-works__description-item">
Once the Integration template is deployed, you can enable the services
you want to monitor right here in Signoz dashboard.
</div>
<Tabs
orientation="horizontal"
alignment="start"
className="cloud-account-setup-form__code-block-tabs"
items={[
{
key: 'cli',
label: 'CLI',
children: <CodeBlock code={connectionCommands?.cliCommand || ''} />,
},
{
key: 'powershell',
label: 'PowerShell',
children: (
<CodeBlock
code={connectionCommands?.cloudPowerShellCommand || ''}
/>
),
},
]}
value={activeTab}
onChange={(key): void => setActiveTab(key)}
variant="primary"
/>
</div>
)}
{renderAlert()}
{modalState === ModalStateEnum.WAITING && (
<div className="cloud-account-setup-status-message">
After running the command, return here and wait for automatic connection
detection.
</div>
)}
</div>
</Form>
</div>
</DrawerWrapper>
<Form
form={form}
className="cloud-account-setup-form"
layout="vertical"
initialValues={{ resourceGroups: [] }}
>
<div className="cloud-account-setup-form__content">
{connectionCommand ? (
<AgentSetupCommand
description="Run this command in Azure Cloud Shell (Bash), or in a terminal where the Azure CLI is signed in to the subscription you want to monitor. It contains your SigNoz keys, so do not share it."
command={connectionCommand}
testId="azure-agent-command"
/>
) : (
<>
<div className="cloud-account-setup-form__form-group">
<div className="cloud-account-setup-form__title">
Where should we deploy the SigNoz collector resources?
</div>
<div className="cloud-account-setup-form__description">
Choose the Azure region for deployment.
</div>
<Form.Item
name="region"
rules={[{ required: true, message: 'Please select a region' }]}
className="cloud-account-setup-form__form-item"
>
<Select
placeholder="e.g. East US"
options={AZURE_REGIONS.map((region) => ({
label: `${region.label} (${region.value})`,
value: region.value,
}))}
getPopupContainer={popupContainer}
/>
</Form.Item>
</div>
<div className="cloud-account-setup-form__form-group">
<div className="cloud-account-setup-form__title">
Which resource groups do you want to monitor?
</div>
<div className="cloud-account-setup-form__description">
Add one or more Azure resource group names.
</div>
<Form.Item
name="resourceGroups"
rules={[
{
required: true,
type: 'array',
min: 1,
message: 'Please add at least one resource group',
},
]}
className="cloud-account-setup-form__form-item"
>
<Select
mode="tags"
placeholder="e.g. prod-platform-rg"
tokenSeparators={[',']}
/>
</Form.Item>
</div>
<RenderConnectionFields
isConnectionParamsLoading={isConnectionParamsLoading}
connectionParams={connectionParams}
/>
</>
)}
{renderAlert()}
{modalState === ModalStateEnum.WAITING && (
<div className="cloud-account-setup-status-message">
After running the command, return here and wait for automatic
connection detection.
</div>
)}
</div>
</Form>
</div>
</DrawerWrapper>
<CloseSetupConfirm
providerName="Azure"
testIdPrefix="azure"
open={isConfirmOpen}
onOpenChange={setIsConfirmOpen}
onConfirm={handleClose}
/>
</>
);
}

View File

@@ -0,0 +1,175 @@
import { ComponentProps } from 'react';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { rest, server } from 'mocks-server/server';
import { RestRequest } from 'msw';
import type logEvent from '../../../../../api/common/logEvent';
import CloudAccountSetupModal from '../AddNewAccount/CloudAccountSetupModal';
// `useIntegrationModal` imports logEvent by relative path, which the
// jest.config moduleNameMapper (keyed on the `api/common/logEvent` alias) does
// not intercept, so mock the resolved module directly.
jest.mock('../../../../../api/common/logEvent', () => ({
__esModule: true,
default: jest.fn() as jest.MockedFunction<typeof logEvent>,
}));
// The form flow drives antd selects and typing, which outruns the 5s default on CI.
jest.setTimeout(15_000);
type OnClose = ComponentProps<typeof CloudAccountSetupModal>['onClose'];
const mockOnClose = (): jest.MockedFunction<OnClose> =>
jest.fn() as jest.MockedFunction<OnClose>;
const AZURE_CREDENTIALS_URL =
'http://localhost/api/v1/cloud_integrations/azure/credentials';
const AZURE_ACCOUNTS_URL =
'http://localhost/api/v1/cloud_integrations/azure/accounts';
const ACCOUNT_ID = 'ci-azure-1234';
const credentials = {
sigNozApiUrl: 'https://signoz.example.com',
sigNozApiKey: 'signoz-api-key',
ingestionUrl: 'https://ingest.example.com',
ingestionKey: 'ingestion-key',
};
const cliCommand = `curl -fsSL 'https://signoz.io/foundry.sh' | bash -s -- cloudintegration azure bicep --region 'australiaeast'`;
const mockApi = ({ failFirstPoll = false } = {}): {
createAccountPayload: unknown;
pollCount: () => number;
} => {
let polls = 0;
const api = {
createAccountPayload: null as unknown,
pollCount: (): number => polls,
};
server.use(
rest.get(AZURE_CREDENTIALS_URL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: credentials })),
),
rest.post(AZURE_ACCOUNTS_URL, async (req: RestRequest, res, ctx) => {
api.createAccountPayload = await req.json();
return res(
ctx.status(201),
ctx.json({
status: 'success',
data: { id: ACCOUNT_ID, connectionArtifact: { azure: { cliCommand } } },
}),
);
}),
rest.get(`${AZURE_ACCOUNTS_URL}/${ACCOUNT_ID}`, (_req, res, ctx) => {
polls += 1;
if (failFirstPoll && polls === 1) {
return res(ctx.status(500), ctx.json({ status: 'error' }));
}
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
id: ACCOUNT_ID,
providerAccountId: null,
agentReport: null,
removedAt: null,
},
}),
);
}),
);
return api;
};
describe('Azure CloudAccountSetupModal', () => {
it('creates the account and shows its setup command', async () => {
const api = mockApi();
const user = userEvent.setup();
const onClose = mockOnClose();
render(<CloudAccountSetupModal onClose={onClose} />);
const [region, resourceGroups] = await screen.findAllByRole('combobox');
await user.click(region);
await user.click(await screen.findByText('Australia East (australiaeast)'));
await user.type(resourceGroups, 'prod-platform-rg{Enter}');
await user.click(screen.getByTestId('azure-generate-command-btn'));
await waitFor(() => {
expect(screen.getByTestId('azure-agent-command')).toHaveTextContent(
cliCommand,
);
});
expect(api.createAccountPayload).toStrictEqual({
config: {
azure: {
deploymentRegion: 'australiaeast',
resourceGroups: ['prod-platform-rg'],
},
},
credentials,
});
expect(
screen.queryByTestId('azure-generate-command-btn'),
).not.toBeInTheDocument();
expect(screen.queryAllByRole('combobox')).toHaveLength(0);
expect(
screen.getByText(/waiting for azure account connection/i),
).toBeInTheDocument();
await user.click(screen.getByTestId('azure-cancel-btn'));
await user.click(await screen.findByTestId('azure-continue-setup-btn'));
await waitFor(() => {
expect(
screen.queryByTestId('azure-continue-setup-btn'),
).not.toBeInTheDocument();
});
expect(onClose).not.toHaveBeenCalled();
await user.click(screen.getByTestId('azure-cancel-btn'));
await user.click(await screen.findByTestId('azure-close-setup-btn'));
expect(onClose).toHaveBeenCalledTimes(1);
});
it('closes without asking before a setup command exists', async () => {
mockApi();
const user = userEvent.setup();
const onClose = mockOnClose();
render(<CloudAccountSetupModal onClose={onClose} />);
await user.click(await screen.findByTestId('azure-cancel-btn'));
expect(onClose).toHaveBeenCalledTimes(1);
expect(screen.queryByTestId('azure-close-setup-btn')).not.toBeInTheDocument();
});
it('resumes polling with Check Again after the connection check fails', async () => {
const api = mockApi({ failFirstPoll: true });
const user = userEvent.setup();
render(<CloudAccountSetupModal onClose={mockOnClose()} />);
const [region, resourceGroups] = await screen.findAllByRole('combobox');
await user.click(region);
await user.click(await screen.findByText('Australia East (australiaeast)'));
await user.type(resourceGroups, 'prod-platform-rg{Enter}');
await user.click(screen.getByTestId('azure-generate-command-btn'));
await user.click(await screen.findByTestId('azure-check-connection-btn'));
await waitFor(() => expect(api.pollCount()).toBe(2));
await screen.findByText(/waiting for azure account connection/i);
expect(
screen.queryByTestId('azure-check-connection-btn'),
).not.toBeInTheDocument();
expect(screen.getByTestId('azure-agent-command')).toHaveTextContent(
cliCommand,
);
});
});

View File

@@ -0,0 +1,59 @@
import { Button } from '@signozhq/ui/button';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { Typography } from '@signozhq/ui/typography';
interface CloseSetupConfirmProps {
/** Provider name as shown in the title, e.g. `AWS`. */
providerName: string;
/** Prefix for the dialog's test ids, e.g. `aws`. */
testIdPrefix: string;
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
}
function CloseSetupConfirm({
providerName,
testIdPrefix,
open,
onOpenChange,
onConfirm,
}: CloseSetupConfirmProps): JSX.Element {
return (
<DialogWrapper
open={open}
onOpenChange={onOpenChange}
title={`Close ${providerName} agent setup?`}
testId={`${testIdPrefix}-close-setup-confirm`}
footer={
<>
<Button
size="md"
variant="solid"
color="highlight-danger"
onClick={onConfirm}
testId={`${testIdPrefix}-close-setup-btn`}
>
Close Setup
</Button>
<Button
size="md"
variant="solid"
color="primary"
onClick={(): void => onOpenChange(false)}
testId={`${testIdPrefix}-continue-setup-btn`}
>
Continue Setup
</Button>
</>
}
>
<Typography>
The agent has not connected yet. If you close now, you will have to start
the installation process again with a new setup command.
</Typography>
</DialogWrapper>
);
}
export default CloseSetupConfirm;

View File

@@ -0,0 +1,30 @@
import { useCallback, useState } from 'react';
interface UseCloseSetupConfirmProps {
/** True once closing would lose a pending setup, i.e. a setup command exists. */
shouldConfirm: boolean;
onClose: () => void;
}
interface UseCloseSetupConfirm {
isConfirmOpen: boolean;
setIsConfirmOpen: (open: boolean) => void;
requestClose: () => void;
}
export function useCloseSetupConfirm({
shouldConfirm,
onClose,
}: UseCloseSetupConfirmProps): UseCloseSetupConfirm {
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
const requestClose = useCallback((): void => {
if (shouldConfirm) {
setIsConfirmOpen(true);
return;
}
onClose();
}, [shouldConfirm, onClose]);
return { isConfirmOpen, setIsConfirmOpen, requestClose };
}

View File

@@ -3,3 +3,7 @@
align-items: center;
gap: var(--spacing-4);
}
.mono {
composes: mono from '../shared.module.scss';
}

View File

@@ -6,6 +6,7 @@ import { ModalStateEnum } from 'container/Integrations/HeroSection/types';
import { useAgentAccountSetup } from 'hooks/integration/gcp/useAgentAccountSetup';
import { useForm } from 'react-hook-form';
import AgentSetupCommand from 'container/Integrations/CloudIntegration/AgentSetupCommand/AgentSetupCommand';
import ConnectionMethodSelector from '../ConnectionMethodSelector';
import ConnectionSecretsFields from '../ConnectionSecretsFields';
import { AGENT_SETUP_GUIDE, DEFAULT_SETUP_FORM_VALUES } from '../constants';
@@ -15,7 +16,6 @@ import SetupDrawerFooter from '../SetupDrawerFooter';
import SetupGuideCallout from '../SetupGuideCallout';
import { GcpSetupFormValues } from '../types';
import { usePrefillCredentials } from '../usePrefillCredentials';
import AgentSetupCommand from './AgentSetupCommand';
import styles from './AgentSetup.module.scss';
@@ -67,8 +67,15 @@ function AgentSetup({
{connectionCommand ? (
<>
<AgentSetupCommand
deploymentProjectId={commandProjectId}
description={
<>
Run this command in Google Cloud Shell, or in a terminal where gcloud is
signed in to <span className={styles.mono}>{commandProjectId}</span>. It
contains your SigNoz keys, so do not share it.
</>
}
command={connectionCommand}
testId="gcp-agent-command"
/>
{modalState === ModalStateEnum.WAITING && (
<Callout

View File

@@ -1,10 +1,9 @@
import { useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { Typography } from '@signozhq/ui/typography';
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
import { IntegrationModalProps } from 'container/Integrations/HeroSection/types';
import CloseSetupConfirm from '../../CloseSetupConfirm/CloseSetupConfirm';
import { useCloseSetupConfirm } from '../../CloseSetupConfirm/useCloseSetupConfirm';
import AgentSetup from './AgentSetup/AgentSetup';
import ManualSetup from './ManualSetup/ManualSetup';
import SetupDrawerLayout from './SetupDrawerLayout';
@@ -18,15 +17,9 @@ function CloudAccountSetupDrawer({
);
// Set once the agent command exists; closing after that loses the pending setup.
const [hasGeneratedCommand, setHasGeneratedCommand] = useState(false);
const [isCloseConfirmOpen, setIsCloseConfirmOpen] = useState(false);
const requestClose = (): void => {
if (hasGeneratedCommand) {
setIsCloseConfirmOpen(true);
return;
}
onClose();
};
const { isConfirmOpen, setIsConfirmOpen, requestClose } = useCloseSetupConfirm(
{ shouldConfirm: hasGeneratedCommand, onClose },
);
return (
<>
@@ -49,39 +42,13 @@ function CloudAccountSetupDrawer({
)
}
</SetupDrawerLayout>
<DialogWrapper
open={isCloseConfirmOpen}
onOpenChange={setIsCloseConfirmOpen}
title="Close GCP agent setup?"
testId="gcp-close-setup-confirm"
footer={
<>
<Button
size="md"
variant="solid"
color="highlight-danger"
onClick={onClose}
testId="gcp-close-setup-btn"
>
Close Setup
</Button>
<Button
size="md"
variant="solid"
color="primary"
onClick={(): void => setIsCloseConfirmOpen(false)}
testId="gcp-continue-setup-btn"
>
Continue Setup
</Button>
</>
}
>
<Typography>
The agent has not connected yet. If you close now, you will have to start
the installation process again with a new setup command.
</Typography>
</DialogWrapper>
<CloseSetupConfirm
providerName="GCP"
testIdPrefix="gcp"
open={isConfirmOpen}
onOpenChange={setIsConfirmOpen}
onConfirm={onClose}
/>
</>
);
}

View File

@@ -20,18 +20,12 @@ export interface RegionFormProps {
includeAllRegions: boolean;
onRegionSelect: () => void;
onSubmit: () => Promise<void>;
accountId?: string;
connectionCommand: string | null;
handleRegionChange: (value: string) => void;
connectionParams?: CloudintegrationtypesCredentialsDTO;
isConnectionParamsLoading?: boolean;
setSelectedRegions: Dispatch<SetStateAction<string[]>>;
setIncludeAllRegions: Dispatch<SetStateAction<boolean>>;
onConnectionSuccess: (payload: {
cloudAccountId: string;
status?: unknown;
}) => void;
onConnectionTimeout: (payload: { id?: string }) => void;
onConnectionError: () => void;
}
export interface IntegrationModalProps {

View File

@@ -2,6 +2,7 @@ import { ChangeEvent, useCallback, useMemo, useState } from 'react';
import { Col, Input, Row, Select } from 'antd';
import InputWithLabel from 'components/InputWithLabel/InputWithLabel';
import BucketOptions from 'components/QueryBuilderV2/QueryV2/QueryAddOns/BucketOptions/BucketOptions';
import { parseLimit, sanitizeLimit } from 'components/QueryBuilderV2/utils';
import { LEGEND } from 'constants/global';
import { PANEL_TYPES } from 'constants/queryBuilder';
// ** Hooks
@@ -171,7 +172,8 @@ export function Formula({
<InputWithLabel
label="Limit"
type="number"
onChange={(value): void => handleChangeLimit(Number(value))}
onChange={(value): void => handleChangeLimit(parseLimit(value))}
sanitize={sanitizeLimit}
initialValue={formula?.limit ?? undefined}
placeholder="Enter limit"
/>

View File

@@ -22,6 +22,7 @@ const renderPolling = (): void => {
renderHook(
() =>
useAgentConnectionPolling({
cloudProvider: 'gcp',
accountId: ACCOUNT_ID,
enabled: true,
...callbacks,

View File

@@ -21,6 +21,7 @@ const renderPolling = (
const { rerender } = renderHook(
({ enabled }: { enabled: boolean }) =>
useAgentConnectionPolling({
cloudProvider: 'gcp',
accountId: ACCOUNT_ID,
enabled,
...callbacks,

View File

@@ -14,9 +14,10 @@ import {
GetConnectionCredentialsQueryResult,
invalidateListAccounts,
useCreateAccount,
useGetConnectionCredentials,
} from 'api/generated/services/cloudintegration';
import { useGetConnectionCredentials } from 'api/generated/services/cloudintegration';
import {
CloudintegrationtypesAccountDTO,
CloudintegrationtypesCredentialsDTO,
CloudintegrationtypesPostableAccountDTO,
} from 'api/generated/services/sigNoz.schemas';
@@ -29,6 +30,7 @@ import useAxiosError from 'hooks/useAxiosError';
import { regions } from 'utils/regions';
import logEvent from '../../../api/common/logEvent';
import { useAgentConnectionPolling } from '../useAgentConnectionPolling';
interface UseIntegrationModalProps {
onClose: () => void;
@@ -42,7 +44,8 @@ interface UseIntegrationModal {
activeView: ActiveViewEnum;
selectedRegions: string[];
includeAllRegions: boolean;
isGeneratingUrl: boolean;
isGeneratingCommand: boolean;
connectionCommand: string | null;
setSelectedRegions: Dispatch<SetStateAction<string[]>>;
setIncludeAllRegions: Dispatch<SetStateAction<boolean>>;
handleRegionSelect: () => void;
@@ -50,17 +53,11 @@ interface UseIntegrationModal {
handleClose: () => void;
setActiveView: (view: ActiveViewEnum) => void;
allRegions: string[];
accountId?: string;
selectedDeploymentRegion: string | undefined;
handleRegionChange: (value: string) => void;
connectionParams?: CloudintegrationtypesCredentialsDTO;
isConnectionParamsLoading: boolean;
handleConnectionSuccess: (payload: {
cloudAccountId: string;
status?: unknown;
}) => void;
handleConnectionTimeout: (payload: { id?: string }) => void;
handleConnectionError: () => void;
checkConnectionAgain: () => void;
}
export function useIntegrationModal({
@@ -73,6 +70,9 @@ export function useIntegrationModal({
);
const [isLoading, setIsLoading] = useState(false);
const [accountId, setAccountId] = useState<string | undefined>();
const [connectionCommand, setConnectionCommand] = useState<string | null>(
null,
);
const [activeView, setActiveView] = useState<ActiveViewEnum>(
ActiveViewEnum.FORM,
);
@@ -103,14 +103,15 @@ export function useIntegrationModal({
setSelectedRegions([]);
setIncludeAllRegions(false);
setModalState(ModalStateEnum.FORM);
setConnectionCommand(null);
onClose();
}, [onClose]);
const handleConnectionSuccess = useCallback(
(payload: { cloudAccountId: string; status?: unknown }): void => {
(account: CloudintegrationtypesAccountDTO): void => {
void logEvent('AWS Integration: Account connected', {
cloudAccountId: payload.cloudAccountId,
status: payload.status,
cloudAccountId: account.providerAccountId ?? account.id,
status: account.agentReport,
});
toast.success('AWS account connected successfully', {
position: 'bottom-right',
@@ -123,21 +124,32 @@ export function useIntegrationModal({
[handleClose, queryClient],
);
const handleConnectionTimeout = useCallback(
(payload: { id?: string }): void => {
setModalState(ModalStateEnum.ERROR);
void logEvent('AWS Integration: Account connection attempt timed out', {
id: payload.id,
});
},
[],
);
const handleConnectionTimeout = useCallback((): void => {
setModalState(ModalStateEnum.ERROR);
void logEvent('AWS Integration: Account connection attempt timed out', {
id: accountId,
});
}, [accountId]);
const handleConnectionError = useCallback((): void => {
setModalState(ModalStateEnum.ERROR);
}, []);
const { mutate: generateUrl, isLoading: isGeneratingUrl } = useCreateAccount();
const checkConnectionAgain = useCallback((): void => {
setModalState(ModalStateEnum.WAITING);
}, []);
useAgentConnectionPolling({
cloudProvider: INTEGRATION_TYPES.AWS,
accountId: accountId ?? '',
enabled: modalState === ModalStateEnum.WAITING && accountId !== undefined,
onConnected: handleConnectionSuccess,
onTimeout: handleConnectionTimeout,
onError: handleConnectionError,
});
const { mutate: createAccount, isLoading: isGeneratingCommand } =
useCreateAccount();
const handleError = useAxiosError();
const { data: connectionParams, isLoading: isConnectionParamsLoading } =
@@ -152,9 +164,9 @@ export function useIntegrationModal({
},
);
const handleGenerateUrl = useCallback(
const handleGenerateCommand = useCallback(
(payload: CloudintegrationtypesPostableAccountDTO): void => {
generateUrl(
createAccount(
{
pathParams: { cloudProvider: INTEGRATION_TYPES.AWS },
data: payload,
@@ -162,17 +174,19 @@ export function useIntegrationModal({
{
onSuccess: (response: CreateAccountMutationResult) => {
const accountId = response.data.id;
const connectionUrl =
response.data.connectionArtifact.aws?.connectionUrl ?? '';
const command = response.data.connectionArtifact.aws?.cliCommand;
if (!command) {
setModalState(ModalStateEnum.ERROR);
toast.error('Failed to generate the AWS setup command', {
position: 'bottom-right',
});
return;
}
void logEvent(
'AWS Integration: Account connection attempt redirected to AWS',
{
id: accountId,
},
);
// oxlint-disable-next-line signoz/no-raw-absolute-path -- connectionUrl is an external AWS console URL, not an internal path
window.open(connectionUrl, '_blank');
void logEvent('AWS Integration: Account connection command generated', {
id: accountId,
});
setConnectionCommand(command);
setModalState(ModalStateEnum.WAITING);
setAccountId(accountId);
},
@@ -185,7 +199,7 @@ export function useIntegrationModal({
},
);
},
[generateUrl],
[createAccount],
);
const handleSubmit = useCallback(async (): Promise<void> => {
@@ -208,13 +222,13 @@ export function useIntegrationModal({
},
};
handleGenerateUrl(payload);
handleGenerateCommand(payload);
} catch (error) {
console.error('Form submission failed:', error);
} finally {
setIsLoading(false);
}
}, [form, selectedRegions, handleGenerateUrl, connectionParams]);
}, [form, selectedRegions, handleGenerateCommand, connectionParams]);
return {
form,
@@ -223,7 +237,8 @@ export function useIntegrationModal({
activeView,
selectedRegions,
includeAllRegions,
isGeneratingUrl,
isGeneratingCommand,
connectionCommand,
setSelectedRegions,
setIncludeAllRegions,
handleRegionSelect,
@@ -231,7 +246,6 @@ export function useIntegrationModal({
handleClose,
setActiveView,
allRegions,
accountId,
setModalState,
selectedDeploymentRegion,
handleRegionChange,
@@ -239,8 +253,6 @@ export function useIntegrationModal({
| CloudintegrationtypesCredentialsDTO
| undefined,
isConnectionParamsLoading,
handleConnectionSuccess,
handleConnectionTimeout,
handleConnectionError,
checkConnectionAgain,
};
}

View File

@@ -10,6 +10,7 @@ import {
useGetConnectionCredentials,
} from 'api/generated/services/cloudintegration';
import {
CloudintegrationtypesAccountDTO,
CloudintegrationtypesCredentialsDTO,
CloudintegrationtypesPostableAccountDTO,
} from 'api/generated/services/sigNoz.schemas';
@@ -18,6 +19,7 @@ import { ModalStateEnum } from 'container/Integrations/HeroSection/types';
import useAxiosError from 'hooks/useAxiosError';
import logEvent from '../../../api/common/logEvent';
import { useAgentConnectionPolling } from '../useAgentConnectionPolling';
interface UseIntegrationModalProps {
onClose: () => void;
@@ -27,22 +29,13 @@ interface UseAzureIntegrationModal {
form: FormInstance;
modalState: ModalStateEnum;
isLoading: boolean;
accountId?: string;
connectionCommands: {
cliCommand: string;
cloudPowerShellCommand: string;
} | null;
connectionCommand: string | null;
setModalState: Dispatch<SetStateAction<ModalStateEnum>>;
handleSubmit: () => Promise<void>;
handleClose: () => void;
connectionParams?: CloudintegrationtypesCredentialsDTO;
isConnectionParamsLoading: boolean;
handleConnectionSuccess: (payload: {
cloudAccountId: string;
status?: unknown;
}) => void;
handleConnectionTimeout: (payload: { id?: string }) => void;
handleConnectionError: () => void;
checkConnectionAgain: () => void;
}
export function useIntegrationModal({
@@ -55,22 +48,21 @@ export function useIntegrationModal({
);
const [isLoading, setIsLoading] = useState(false);
const [accountId, setAccountId] = useState<string | undefined>(undefined);
const [connectionCommands, setConnectionCommands] = useState<{
cliCommand: string;
cloudPowerShellCommand: string;
} | null>(null);
const [connectionCommand, setConnectionCommand] = useState<string | null>(
null,
);
const handleClose = useCallback((): void => {
setModalState(ModalStateEnum.FORM);
setConnectionCommands(null);
setConnectionCommand(null);
onClose();
}, [onClose]);
const handleConnectionSuccess = useCallback(
(payload: { cloudAccountId: string; status?: unknown }): void => {
logEvent('Azure Integration: Account connected', {
cloudAccountId: payload.cloudAccountId,
status: payload.status,
(account: CloudintegrationtypesAccountDTO): void => {
void logEvent('Azure Integration: Account connected', {
cloudAccountId: account.providerAccountId ?? account.id,
status: account.agentReport,
});
toast.success('Azure account connected successfully', {
position: 'bottom-right',
@@ -83,20 +75,30 @@ export function useIntegrationModal({
[handleClose, queryClient],
);
const handleConnectionTimeout = useCallback(
(payload: { id?: string }): void => {
setModalState(ModalStateEnum.ERROR);
logEvent('Azure Integration: Account connection attempt timed out', {
id: payload.id,
});
},
[],
);
const handleConnectionTimeout = useCallback((): void => {
setModalState(ModalStateEnum.ERROR);
void logEvent('Azure Integration: Account connection attempt timed out', {
id: accountId,
});
}, [accountId]);
const handleConnectionError = useCallback((): void => {
setModalState(ModalStateEnum.ERROR);
}, []);
const checkConnectionAgain = useCallback((): void => {
setModalState(ModalStateEnum.WAITING);
}, []);
useAgentConnectionPolling({
cloudProvider: INTEGRATION_TYPES.AZURE,
accountId: accountId ?? '',
enabled: modalState === ModalStateEnum.WAITING && accountId !== undefined,
onConnected: handleConnectionSuccess,
onTimeout: handleConnectionTimeout,
onError: handleConnectionError,
});
const { mutate: createAccount } = useCreateAccount();
const handleError = useAxiosError();
@@ -140,16 +142,20 @@ export function useIntegrationModal({
{
onSuccess: (response: CreateAccountMutationResult) => {
const nextAccountId = response.data.id;
const artifact = response.data.connectionArtifact.azure;
const command = response.data.connectionArtifact.azure?.cliCommand;
if (!command) {
setModalState(ModalStateEnum.ERROR);
toast.error('Failed to generate the Azure setup command', {
position: 'bottom-right',
});
return;
}
logEvent('Azure Integration: Account connection commands generated', {
void logEvent('Azure Integration: Account connection command generated', {
id: nextAccountId,
});
setConnectionCommands({
cliCommand: artifact?.cliCommand || '',
cloudPowerShellCommand: artifact?.cloudPowerShellCommand || '',
});
setConnectionCommand(command);
setModalState(ModalStateEnum.WAITING);
setAccountId(nextAccountId);
},
@@ -172,8 +178,7 @@ export function useIntegrationModal({
form,
modalState,
isLoading,
accountId,
connectionCommands,
connectionCommand,
setModalState,
handleSubmit,
handleClose,
@@ -181,8 +186,6 @@ export function useIntegrationModal({
| CloudintegrationtypesCredentialsDTO
| undefined,
isConnectionParamsLoading,
handleConnectionSuccess,
handleConnectionTimeout,
handleConnectionError,
checkConnectionAgain,
};
}

View File

@@ -5,10 +5,11 @@ import {
CloudintegrationtypesGCPConnectionMethodDTO,
} from 'api/generated/services/sigNoz.schemas';
import { GcpSetupFormValues } from 'container/Integrations/CloudIntegration/GoogleCloudPlatform/AddNewAccount/types';
import { INTEGRATION_TYPES } from 'container/Integrations/constants';
import { ModalStateEnum } from 'container/Integrations/HeroSection/types';
import logEvent from '../../../api/common/logEvent';
import { useAgentConnectionPolling } from './useAgentConnectionPolling';
import { useAgentConnectionPolling } from '../useAgentConnectionPolling';
import { useGcpAccountSetup } from './useGcpAccountSetup';
const GENERATE_COMMAND_ERROR = 'Failed to generate the GCP setup command';
@@ -116,6 +117,7 @@ export function useAgentAccountSetup({
}, []);
useAgentConnectionPolling({
cloudProvider: INTEGRATION_TYPES.GCP,
accountId: accountId ?? '',
enabled: modalState === ModalStateEnum.WAITING && accountId !== null,
onConnected: handleAgentConnected,

View File

@@ -1,12 +1,12 @@
import { useEffect, useRef } from 'react';
import { useGetAccount } from 'api/generated/services/cloudintegration';
import { CloudintegrationtypesAccountDTO } from 'api/generated/services/sigNoz.schemas';
import { INTEGRATION_TYPES } from 'container/Integrations/constants';
export const POLL_INTERVAL_MS = 10 * 1000;
export const CONNECTION_TIMEOUT_MS = 10 * 60 * 1000;
interface UseAgentConnectionPollingProps {
cloudProvider: string;
accountId: string;
/** Polls exactly while true; the caller decides when there is an account to poll. */
enabled: boolean;
@@ -16,10 +16,11 @@ interface UseAgentConnectionPollingProps {
}
/**
* Polls a GCP agent account until the agent checks in. The timeout is measured
* from when polling gets enabled, so re-enabling it starts a fresh window.
* Polls a cloud integration account until its agent checks in. The timeout is
* measured from when polling gets enabled, so re-enabling it starts a fresh window.
*/
export function useAgentConnectionPolling({
cloudProvider,
accountId,
enabled,
onConnected,
@@ -33,7 +34,7 @@ export function useAgentConnectionPolling({
}, [enabled]);
useGetAccount(
{ cloudProvider: INTEGRATION_TYPES.GCP, id: accountId },
{ cloudProvider, id: accountId },
{
query: {
enabled,

View File

@@ -3,5 +3,8 @@
}
.page {
padding: var(--spacing-4) var(--spacing-6) var(--spacing-6);
flex: 1;
min-height: 0;
overflow-y: auto;
padding: var(--spacing-4) var(--spacing-6) 0;
}

View File

@@ -3,5 +3,8 @@
}
.page {
padding: var(--spacing-4) var(--spacing-6) var(--spacing-6);
flex: 1;
min-height: 0;
overflow-y: auto;
padding: var(--spacing-4) var(--spacing-6) 0;
}

View File

@@ -144,7 +144,7 @@ export const cloudIntegrationMocks = defineStoryMocks({
newAccountCheckIn: choiceControl<NewAccountCheckIn>('New account check-in', {
group: MUTATIONS,
description:
'How the poll for the account the connect flow just created answers. `checked-in` connects it on the first poll and the drawer closes with the success toast, `pending` keeps the drawer waiting on the agent (the GCP agent setup command stays up), `error` fails the poll so the drawer reports it could not connect.',
'How the poll for the account the connect flow just created answers. `checked-in` connects it on the first poll and the drawer closes with the success toast, `pending` keeps the drawer waiting on the agent with the AWS, Azure or GCP agent setup command up, `error` fails the poll so the drawer reports it could not connect.',
options: NEW_ACCOUNT_CHECK_INS,
value: 'checked-in',
}),
@@ -319,7 +319,7 @@ export const connectAwsAccount = async (
await screen.findByRole('checkbox', { name: /us-east-1/i }),
);
await userEvent.click(
await screen.findByRole('button', { name: /launch cloud formation/i }),
await screen.findByRole('button', { name: /generate command/i }),
);
};
@@ -339,7 +339,7 @@ export const connectAzureAccount = async (
);
await userEvent.type(resourceGroups, 'prod-platform-rg{Enter}');
await userEvent.click(
await screen.findByRole('button', { name: /generate azure setup commands/i }),
await screen.findByRole('button', { name: /generate command/i }),
);
};

View File

@@ -173,8 +173,8 @@ export const TooltipsInDataCollected: Story = {
};
/**
* Connecting an AWS account: region and monitored regions picked, the template
* launched, and the first poll finds the account connected. Set Account
* Connecting an AWS account: region and monitored regions picked, the setup
* command generated, and the first poll finds the account connected. Set Account
* connection to `error` for the failure toast.
*/
export const AwsAccountConnectedToast: Story = {
@@ -198,7 +198,7 @@ export const AwsAccountConnectionErrorToast: Story = {
},
};
/** Connecting an Azure subscription through the generated setup commands. */
/** Connecting an Azure subscription through the generated setup command. */
export const AzureAccountConnectedToast: Story = {
args: { provider: 'azure', accounts: 0 },
play: async ({ canvasElement, args }): Promise<void> => {
@@ -220,6 +220,89 @@ export const AzureAccountConnectionErrorToast: Story = {
},
};
/**
* The AWS setup command, with the drawer waiting for the account to connect.
* Set New account check-in to `checked-in` or `error` to see it connect or fail.
*/
export const AwsAgentCommand: Story = {
args: { accounts: 0, newAccountCheckIn: 'pending' },
play: async ({ canvasElement }): Promise<void> => {
await connectAwsAccount(canvasElement);
await screen.findByTestId('aws-agent-command', undefined, untilLoaded);
},
};
/**
* The connection poll fails, so the drawer reports it could not connect and
* offers Check Again.
*/
export const AwsAgentConnectionError: Story = {
args: { accounts: 0, newAccountCheckIn: 'error' },
play: async ({ canvasElement }): Promise<void> => {
await connectAwsAccount(canvasElement);
await screen.findByTestId('aws-check-connection-btn', undefined, untilLoaded);
},
};
/**
* Leaving the drawer while the agent has not connected yet, which asks first
* because closing means starting the installation again.
*/
export const AwsAgentCloseConfirm: Story = {
args: { accounts: 0, newAccountCheckIn: 'pending' },
play: async ({ canvasElement }): Promise<void> => {
await connectAwsAccount(canvasElement);
await userEvent.click(
await screen.findByTestId('aws-cancel-btn', undefined, untilLoaded),
);
await screen.findByTestId('aws-close-setup-btn', undefined, untilLoaded);
},
};
/**
* The Azure setup command, with the drawer waiting for the subscription to
* connect. Set New account check-in to `checked-in` or `error` to see it connect
* or fail.
*/
export const AzureAgentCommand: Story = {
args: { provider: 'azure', accounts: 0, newAccountCheckIn: 'pending' },
play: async ({ canvasElement }): Promise<void> => {
await connectAzureAccount(canvasElement);
await screen.findByTestId('azure-agent-command', undefined, untilLoaded);
},
};
/**
* The connection poll fails, so the drawer reports it could not connect and
* offers Check Again.
*/
export const AzureAgentConnectionError: Story = {
args: { provider: 'azure', accounts: 0, newAccountCheckIn: 'error' },
play: async ({ canvasElement }): Promise<void> => {
await connectAzureAccount(canvasElement);
await screen.findByTestId(
'azure-check-connection-btn',
undefined,
untilLoaded,
);
},
};
/**
* Leaving the drawer while the agent has not connected yet, which asks first
* because closing means starting the installation again.
*/
export const AzureAgentCloseConfirm: Story = {
args: { provider: 'azure', accounts: 0, newAccountCheckIn: 'pending' },
play: async ({ canvasElement }): Promise<void> => {
await connectAzureAccount(canvasElement);
await userEvent.click(
await screen.findByTestId('azure-cancel-btn', undefined, untilLoaded),
);
await screen.findByTestId('azure-close-setup-btn', undefined, untilLoaded);
},
};
/**
* Connecting a GCP account manually: the account is created and the story's
* check-in stands in for the agent. A failure at either step shows inline in the

View File

@@ -366,6 +366,30 @@ export const accountResponse = (
};
};
/** The backend's command template, filled in for the story's region. */
const AWS_AGENT_COMMAND = {
cliCommand: `curl -fsSL 'https://signoz.io/foundry.sh' | bash -s -- cloudintegration aws cloudformation \\
--region 'us-east-1' \\
--integration-account-id 'aws-account-new' \\
--signoz-api-url 'https://story.us.signoz.cloud' \\
--signoz-api-key 'story-api-key-8b7d02' \\
--ingestion-url 'https://ingest.us.signoz.cloud:443' \\
--ingestion-key 'story-ingestion-key-2f9c41' \\
--agent-version 'v0.0.8'`,
};
/** The backend's command template, filled in for the story's region. */
const AZURE_AGENT_COMMAND = {
cliCommand: `curl -fsSL 'https://signoz.io/foundry.sh' | bash -s -- cloudintegration azure bicep \\
--region 'australiaeast' \\
--integration-account-id 'azure-account-new' \\
--signoz-api-url 'https://story.us.signoz.cloud' \\
--signoz-api-key 'story-api-key-8b7d02' \\
--ingestion-url 'https://ingest.us.signoz.cloud:443' \\
--ingestion-key 'story-ingestion-key-2f9c41' \\
--agent-version 'v0.0.8'`,
};
/** The backend's command template, filled in for the story's project and region. */
const GCP_AGENT_COMMAND = {
cliCommand: `curl -fsSL 'https://signoz.io/foundry.sh' | bash -s -- cloudintegration gcp terraform \\
@@ -613,9 +637,8 @@ export const credentialsResponse = (): GetConnectionCredentials200 => ({
});
/**
* The connection URL is what the setup flow opens in a new tab, which the story
* reports through the navigation overlay rather than following. GCP returns its
* setup command only for an agent connection; a manual one gets none.
* GCP returns its setup command only for an agent connection; a manual one gets
* none.
*/
export const createdAccountResponse = (
provider: CloudProvider,
@@ -625,15 +648,8 @@ export const createdAccountResponse = (
data: {
id: newAccountId(provider),
connectionArtifact: {
aws: {
connectionUrl: `https://console.aws.amazon.com/cloudformation/home#/stacks/create?templateURL=https://signoz.io/${provider}.yaml`,
},
azure: {
cliCommand:
'az deployment sub create --template-uri https://signoz.io/azure.json',
cloudPowerShellCommand:
'New-AzSubscriptionDeployment -TemplateUri https://signoz.io/azure.json',
},
aws: AWS_AGENT_COMMAND,
azure: AZURE_AGENT_COMMAND,
...(gcpConnectionMethod ===
CloudintegrationtypesGCPConnectionMethodDTO.agent && {
gcp: GCP_AGENT_COMMAND,

View File

@@ -33,6 +33,13 @@
flex-shrink: 0;
}
.aiDot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent-primary);
}
.filterSection {
display: flex;
align-items: center;

View File

@@ -137,7 +137,7 @@ function TraceDetailsHeader({
badgeValue={traceID}
maxCharacters={100}
/>
<TraceDetailsTabs />
<TraceDetailsTabs hasAiSummary={!!traceSummary?.ai} />
</div>
)}
{isOverview && showTraceDetailsHeaderOptions && traceSummary && (

View File

@@ -1,31 +1,53 @@
import { ToggleGroup } from '@signozhq/ui/toggle-group';
import { useAppParams } from 'lib/router/useAppParams';
import { TraceDetailsTab } from '../constants';
import { TraceDetailEventKeys, TraceDetailEvents } from '../events';
import { useTraceDetailLogEvent } from '../hooks/useTraceDetailLogEvent';
import { useTraceDetailsTab } from '../hooks/useTraceDetailsTab';
import styles from './TraceDetailsHeader.module.scss';
const TAB_ITEMS = [
{
value: TraceDetailsTab.Overview,
label: 'Overview',
testId: 'trace-details-tab-overview',
},
{
value: TraceDetailsTab.Thread,
label: 'Thread',
testId: 'trace-details-tab-thread',
},
];
interface TraceDetailsTabsProps {
hasAiSummary: boolean;
}
function TraceDetailsTabs(): JSX.Element {
function TraceDetailsTabs({
hasAiSummary,
}: TraceDetailsTabsProps): JSX.Element {
const { id: traceId = '' } = useAppParams<'id'>();
const [tab, setTab] = useTraceDetailsTab();
const logTraceEvent = useTraceDetailLogEvent('v3', traceId);
const handleChange = (value: string): void => {
logTraceEvent(TraceDetailEvents.DetailsTabChanged, {
[TraceDetailEventKeys.From]: tab,
[TraceDetailEventKeys.To]: value,
});
// Pushed so the thread's "Go back" returns to the previous tab, not the previous page.
void setTab(value as TraceDetailsTab, { history: 'push' });
};
const items = [
{
value: TraceDetailsTab.Overview,
label: 'Overview',
testId: 'trace-details-tab-overview',
},
{
value: TraceDetailsTab.Thread,
label: 'Thread',
testId: 'trace-details-tab-thread',
suffix: hasAiSummary ? (
<span
className={styles.aiDot}
aria-hidden
data-testid="trace-details-tab-thread-ai-dot"
/>
) : undefined,
},
];
return (
<div className={styles.tabs}>
<ToggleGroup
@@ -35,7 +57,7 @@ function TraceDetailsTabs(): JSX.Element {
value={tab}
onChange={handleChange}
size="sm"
items={TAB_ITEMS}
items={items}
/>
</div>
);

View File

@@ -1,12 +1,17 @@
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import logEvent from 'api/common/logEvent';
import type { SpantypesGettableTraceSummaryDTO } from 'api/generated/services/sigNoz.schemas';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import ROUTES from 'constants/routes';
import { fireEvent, render, screen, userEvent } from 'tests/test-utils';
import { TraceDetailsTab } from '../../constants';
import { TraceDetailEvents } from '../../events';
import TraceDetailsHeader from '../TraceDetailsHeader';
import { useTraceSummary } from '../useTraceSummary';
jest.mock('api/common/logEvent');
jest.mock('../useTraceSummary', () => ({
useTraceSummary: jest.fn(() => ({ data: undefined, isLoading: false })),
}));
@@ -389,6 +394,27 @@ describe('TraceDetailsHeader – tabs', () => {
expect(screen.getByText(metadataText)).toBeInTheDocument();
});
it('shows the AI dot on the Thread tab only when the summary has an AI block', () => {
renderHeader();
expect(
screen.queryByTestId('trace-details-tab-thread-ai-dot'),
).not.toBeInTheDocument();
jest.mocked(useTraceSummary).mockReturnValue({
data: {
...SUMMARY,
ai: {
tokens: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, reasoning: 0 },
},
},
isLoading: false,
});
renderHeader();
expect(
screen.getByTestId('trace-details-tab-thread-ai-dot'),
).toBeInTheDocument();
});
it('clears the filter result when switching to the Thread tab', async () => {
const user = userEvent.setup({ delay: null });
renderHeader();
@@ -416,4 +442,23 @@ describe('TraceDetailsHeader – tabs', () => {
'false',
);
});
it('logs the tab change with the previous and next tab', async () => {
const user = userEvent.setup({ delay: null });
renderHeader();
jest.mocked(logEvent).mockClear();
await user.click(screen.getByTestId('trace-details-tab-overview'));
expect(logEvent).not.toHaveBeenCalled();
await user.click(screen.getByTestId('trace-details-tab-thread'));
expect(logEvent).toHaveBeenCalledTimes(1);
expect(logEvent).toHaveBeenCalledWith(TraceDetailEvents.DetailsTabChanged, {
view: 'v3',
traceId: 'trace-123',
from: TraceDetailsTab.Overview,
to: TraceDetailsTab.Thread,
});
});
});

View File

@@ -27,7 +27,9 @@ import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchPa
import { ResizableBox } from 'periscope/components/ResizableBox';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { TraceDetailsTab } from '../constants';
import { TraceDetailEventKeys, TraceDetailEvents } from '../events';
import { useSpanDetailsOpenedEvent } from '../hooks/useSpanDetailsOpenedEvent';
import { useSpanDetailVariant } from '../hooks/useSpanDetailVariant';
import { useTraceDetailLogEvent } from '../hooks/useTraceDetailLogEvent';
import NoData from '../NoData/NoData';
@@ -106,6 +108,12 @@ function TraceDetailsOverview({
entityId: selectedSpanId,
onClose: handleSpanDetailsClose,
});
useSpanDetailsOpenedEvent({
isOpen: panelState.isOpen,
spanId: selectedSpan?.span_id,
detailsTab: TraceDetailsTab.Overview,
logTraceEvent,
});
const allSpansRef = useRef<SpanV3[]>([]);
const deepLinkResolvedRef = useRef(false);
@@ -199,6 +207,7 @@ function TraceDetailsOverview({
dataLoadedFiredForRef.current = traceId;
const numServices = new Set(payload.spans.map((s) => s['service.name'])).size;
logTraceEvent(TraceDetailEvents.DataLoaded, {
[TraceDetailEventKeys.DetailsTab]: TraceDetailsTab.Overview,
[TraceDetailEventKeys.TotalSpansCount]: totalSpansCount,
[TraceDetailEventKeys.NumServices]: numServices,
[TraceDetailEventKeys.TraceDurationMs]:

View File

@@ -1,11 +1,15 @@
import { MutableRefObject } from 'react';
import { Callout } from '@signozhq/ui/callout';
import { back, hasInAppHistory } from 'lib/router/navigation';
import { useAppParams } from 'lib/router/useAppParams';
import { TraceDetailsTab } from '../constants';
import { useSpanDetailsOpenedEvent } from '../hooks/useSpanDetailsOpenedEvent';
import { useSpanDetailVariant } from '../hooks/useSpanDetailVariant';
import { useTraceDetailLogEvent } from '../hooks/useTraceDetailLogEvent';
import { useTraceDetailsTab } from '../hooks/useTraceDetailsTab';
import { SpanDetailVariant } from '../SpanDetailsPanel/constants';
import { useThreadLoadedEvents } from './hooks/useThreadLoadedEvents';
import { useThreadSpanSelection } from './hooks/useThreadSpanSelection';
import { useThreadView } from './hooks/useThreadView';
import { useTraceThread } from './hooks/useTraceThread';
@@ -23,7 +27,16 @@ import styles from './TraceDetailsThread.module.scss';
const INITIAL_LOADER_COUNT = 4;
function TraceDetailsThread(): JSX.Element {
interface TraceDetailsThreadProps {
// Owned by the page so the load event fires once per trace, not per tab visit.
dataLoadedFiredForRef: MutableRefObject<string>;
threadEmptyFiredForRef: MutableRefObject<string>;
}
function TraceDetailsThread({
dataLoadedFiredForRef,
threadEmptyFiredForRef,
}: TraceDetailsThreadProps): JSX.Element {
const { id: traceId } = useAppParams<'id'>();
const [, setTab] = useTraceDetailsTab();
const [view, setView] = useThreadView();
@@ -57,11 +70,33 @@ function TraceDetailsThread(): JSX.Element {
const interestedSpanIndex = spans.findIndex(
(span) => span.span_id === interestedSpanId,
);
const hasNoThread =
!isLoading &&
interestedSpanStatus !== InterestedSpanStatus.NotFound &&
(isError || spans.length === 0);
const selectedSpan = spans.find((span) => span.span_id === selectedSpanId);
const [spanDetailVariant, setSpanDetailVariant] = useSpanDetailVariant();
const isBottomDocked = spanDetailVariant === SpanDetailVariant.DOCKED;
const logTraceEvent = useTraceDetailLogEvent('v3', traceId || '');
useThreadLoadedEvents({
traceId: traceId || '',
interestedSpanId,
hasSpans: spans.length > 0,
hasNoThread,
spanPanelVariant: spanDetailVariant,
dataLoadedFiredForRef,
threadEmptyFiredForRef,
logTraceEvent,
});
useSpanDetailsOpenedEvent({
isOpen: panelState.isOpen,
spanId: selectedSpan?.span_id,
detailsTab: TraceDetailsTab.Thread,
logTraceEvent,
});
const spanDetails = panelState.isOpen && selectedSpan && (
<ThreadSpanDetails
panelState={panelState}
@@ -70,10 +105,6 @@ function TraceDetailsThread(): JSX.Element {
onVariantChange={setSpanDetailVariant}
/>
);
const hasNoThread =
!isLoading &&
interestedSpanStatus !== InterestedSpanStatus.NotFound &&
(isError || spans.length === 0);
const renderBody = (): JSX.Element => {
if (isLoading) {

View File

@@ -0,0 +1,135 @@
import { MutableRefObject, ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import {
GetTraceSummary200,
SpantypesGettableTraceSummaryDTO,
} from 'api/generated/services/sigNoz.schemas';
import { getGetTraceSummaryQueryKey } from 'api/generated/services/tracedetail';
import { rest, server } from 'mocks-server/server';
import { renderHook, waitFor } from 'tests/test-utils';
import { TraceDetailLogEvent } from '../../../hooks/useTraceDetailLogEvent';
import { SpanDetailVariant } from '../../../SpanDetailsPanel/constants';
import { useThreadLoadedEvents } from '../useThreadLoadedEvents';
const BASE_SUMMARY: SpantypesGettableTraceSummaryDTO = {
startTimestampMillis: 1_000,
endTimestampMillis: 4_000,
totalSpansCount: 12,
totalErrorSpansCount: 2,
hasMissingSpans: true,
rootServiceEntryPoint: 'POST /chat',
rootServiceName: 'agent',
rootSpanStatusCode: 'STATUS_CODE_ERROR',
};
const SUMMARIES: Record<string, SpantypesGettableTraceSummaryDTO> = {
'trace-ai': {
...BASE_SUMMARY,
ai: {
tokens: {
input: 1_200,
output: 300,
cacheRead: 50,
cacheWrite: 10,
reasoning: 40,
},
totalCost: 0.0123,
},
},
'trace-ai-next': {
...BASE_SUMMARY,
endTimestampMillis: 1_500,
totalSpansCount: 4,
ai: {
tokens: { input: 80, output: 20, cacheRead: 0, cacheWrite: 0, reasoning: 0 },
totalCost: 0.0004,
},
},
'trace-no-ai': BASE_SUMMARY,
};
export interface ThreadLoadedEventsProps {
traceId: string;
hasSpans?: boolean;
hasNoThread?: boolean;
interestedSpanId?: string;
spanPanelVariant?: SpanDetailVariant;
}
interface RenderOptions {
dataLoadedFiredForRef?: MutableRefObject<string>;
threadEmptyFiredForRef?: MutableRefObject<string>;
queryClient?: QueryClient;
}
interface RenderResult {
logTraceEvent: jest.MockedFunction<TraceDetailLogEvent>;
rerender: (props: ThreadLoadedEventsProps) => void;
unmount: () => void;
queryClient: QueryClient;
}
/** Serves the fixture summaries and returns the trace ids requested, in order. */
export function mockTraceSummaries(): string[] {
const requests: string[] = [];
server.use(
rest.get('*/api/v1/traces/:traceId/summary', (req, res, ctx) => {
requests.push(req.params.traceId as string);
const body: GetTraceSummary200 = {
status: 'success',
data: SUMMARIES[req.params.traceId as string],
};
return res(ctx.status(200), ctx.json(body));
}),
);
return requests;
}
export function renderThreadLoadedEvents(
initialProps: ThreadLoadedEventsProps,
{
dataLoadedFiredForRef = { current: '' },
threadEmptyFiredForRef = { current: '' },
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
}),
}: RenderOptions = {},
): RenderResult {
const logTraceEvent = jest.fn() as jest.MockedFunction<TraceDetailLogEvent>;
const wrapper = ({ children }: { children: ReactNode }): JSX.Element => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { rerender, unmount } = renderHook(
({
traceId,
hasSpans = false,
hasNoThread = false,
interestedSpanId,
spanPanelVariant = SpanDetailVariant.DOCKED_RIGHT,
}: ThreadLoadedEventsProps) =>
useThreadLoadedEvents({
traceId,
interestedSpanId,
hasSpans,
hasNoThread,
spanPanelVariant,
dataLoadedFiredForRef,
threadEmptyFiredForRef,
logTraceEvent,
}),
{ initialProps, wrapper },
);
return { logTraceEvent, rerender, unmount, queryClient };
}
export async function waitForSummary(
queryClient: QueryClient,
traceId: string,
): Promise<void> {
await waitFor(() =>
expect(
queryClient.getQueryData(getGetTraceSummaryQueryKey({ traceID: traceId })),
).toBeDefined(),
);
}

View File

@@ -0,0 +1,111 @@
import { waitFor } from 'tests/test-utils';
import { TraceDetailsTab } from '../../../constants';
import { TraceDetailEvents } from '../../../events';
import { SpanDetailVariant } from '../../../SpanDetailsPanel/constants';
import {
mockTraceSummaries,
renderThreadLoadedEvents,
ThreadLoadedEventsProps,
waitForSummary,
} from './threadLoadedEventsTestUtils';
describe('useThreadLoadedEvents: data loaded', () => {
beforeEach(() => {
mockTraceSummaries();
});
it('waits for the trace summary, then reports it as a thread load', async () => {
const { logTraceEvent } = renderThreadLoadedEvents({
traceId: 'trace-ai',
hasSpans: true,
});
expect(logTraceEvent).not.toHaveBeenCalled();
await waitFor(() => expect(logTraceEvent).toHaveBeenCalledTimes(1));
expect(logTraceEvent).toHaveBeenCalledWith(TraceDetailEvents.DataLoaded, {
detailsTab: TraceDetailsTab.Thread,
totalSpansCount: 12,
traceDurationMs: 3_000,
hadErrors: true,
spanPanelVariant: SpanDetailVariant.DOCKED_RIGHT,
});
});
it('waits for the thread spans even when the summary is already loaded', async () => {
const { logTraceEvent, rerender, queryClient } = renderThreadLoadedEvents({
traceId: 'trace-ai',
});
await waitForSummary(queryClient, 'trace-ai');
rerender({ traceId: 'trace-ai' });
expect(logTraceEvent).not.toHaveBeenCalled();
rerender({ traceId: 'trace-ai', hasSpans: true });
expect(logTraceEvent).toHaveBeenCalledTimes(1);
});
it('reports a trace once, including when the tab is visited again', async () => {
const dataLoadedFiredForRef = { current: '' };
const props: ThreadLoadedEventsProps = {
traceId: 'trace-ai',
hasSpans: true,
};
const firstVisit = renderThreadLoadedEvents(props, { dataLoadedFiredForRef });
await waitFor(() =>
expect(firstVisit.logTraceEvent).toHaveBeenCalledTimes(1),
);
firstVisit.rerender({
...props,
spanPanelVariant: SpanDetailVariant.DOCKED,
});
const secondVisit = renderThreadLoadedEvents(props, {
dataLoadedFiredForRef,
});
await waitForSummary(secondVisit.queryClient, 'trace-ai');
secondVisit.rerender(props);
expect(firstVisit.logTraceEvent).toHaveBeenCalledTimes(1);
expect(secondVisit.logTraceEvent).not.toHaveBeenCalled();
});
it('reports the next trace with its own summary', async () => {
const { logTraceEvent, rerender } = renderThreadLoadedEvents({
traceId: 'trace-ai',
hasSpans: true,
});
await waitFor(() => expect(logTraceEvent).toHaveBeenCalledTimes(1));
rerender({ traceId: 'trace-ai-next', hasSpans: true });
await waitFor(() => expect(logTraceEvent).toHaveBeenCalledTimes(2));
expect(logTraceEvent).toHaveBeenLastCalledWith(
TraceDetailEvents.DataLoaded,
expect.objectContaining({ totalSpansCount: 4, traceDurationMs: 500 }),
);
});
it('also reports an empty thread, once each, when an errored thread still holds spans', async () => {
const props: ThreadLoadedEventsProps = {
traceId: 'trace-ai',
hasSpans: true,
hasNoThread: true,
};
const { logTraceEvent, rerender } = renderThreadLoadedEvents(props);
await waitFor(() => expect(logTraceEvent).toHaveBeenCalledTimes(2));
rerender(props);
expect(logTraceEvent).toHaveBeenCalledTimes(2);
expect(logTraceEvent).toHaveBeenCalledWith(
TraceDetailEvents.DataLoaded,
expect.any(Object),
);
expect(logTraceEvent).toHaveBeenCalledWith(
TraceDetailEvents.ThreadEmptyWithAiSummary,
expect.any(Object),
);
});
});

View File

@@ -0,0 +1,114 @@
import { waitFor } from 'tests/test-utils';
import { TraceDetailsTab } from '../../../constants';
import { TraceDetailEvents } from '../../../events';
import {
mockTraceSummaries,
renderThreadLoadedEvents,
ThreadLoadedEventsProps,
waitForSummary,
} from './threadLoadedEventsTestUtils';
describe('useThreadLoadedEvents: thread empty with AI summary', () => {
let summaryRequests: string[];
beforeEach(() => {
summaryRequests = mockTraceSummaries();
});
it('reports the trace summary when the thread is empty but the summary has AI usage', async () => {
const { logTraceEvent } = renderThreadLoadedEvents({
traceId: 'trace-ai',
hasNoThread: true,
interestedSpanId: 'span-1',
});
await waitFor(() => expect(logTraceEvent).toHaveBeenCalledTimes(1));
expect(logTraceEvent).toHaveBeenCalledWith(
TraceDetailEvents.ThreadEmptyWithAiSummary,
{
detailsTab: TraceDetailsTab.Thread,
spanId: 'span-1',
rootServiceName: 'agent',
rootServiceEntryPoint: 'POST /chat',
totalSpansCount: 12,
traceDurationMs: 3_000,
hadErrors: true,
hasMissingSpans: true,
inputTokens: 1_200,
outputTokens: 300,
cacheReadTokens: 50,
cacheWriteTokens: 10,
reasoningTokens: 40,
totalCost: 0.0123,
},
);
});
it('does not report when the summary has no AI usage', async () => {
const props: ThreadLoadedEventsProps = {
traceId: 'trace-no-ai',
hasNoThread: true,
};
const { logTraceEvent, rerender, queryClient } =
renderThreadLoadedEvents(props);
await waitForSummary(queryClient, 'trace-no-ai');
rerender(props);
expect(logTraceEvent).not.toHaveBeenCalled();
});
it('waits until the thread is known to be empty', async () => {
const { logTraceEvent, rerender, queryClient } = renderThreadLoadedEvents({
traceId: 'trace-ai',
});
await waitForSummary(queryClient, 'trace-ai');
rerender({ traceId: 'trace-ai' });
expect(logTraceEvent).not.toHaveBeenCalled();
rerender({ traceId: 'trace-ai', hasNoThread: true });
await waitFor(() => expect(logTraceEvent).toHaveBeenCalledTimes(1));
});
it('reports once per trace and reuses the loaded summary when the tab is visited again', async () => {
const threadEmptyFiredForRef = { current: '' };
const props: ThreadLoadedEventsProps = {
traceId: 'trace-ai',
hasNoThread: true,
};
const first = renderThreadLoadedEvents(props, { threadEmptyFiredForRef });
await waitFor(() => expect(first.logTraceEvent).toHaveBeenCalledTimes(1));
first.unmount();
const second = renderThreadLoadedEvents(props, {
threadEmptyFiredForRef,
queryClient: first.queryClient,
});
expect(second.queryClient.isFetching()).toBe(0);
expect(summaryRequests).toStrictEqual(['trace-ai']);
expect(second.logTraceEvent).not.toHaveBeenCalled();
});
it('reports the next trace with its own summary', async () => {
const { logTraceEvent, rerender } = renderThreadLoadedEvents({
traceId: 'trace-ai',
hasNoThread: true,
});
await waitFor(() => expect(logTraceEvent).toHaveBeenCalledTimes(1));
rerender({ traceId: 'trace-ai-next', hasNoThread: true });
await waitFor(() => expect(logTraceEvent).toHaveBeenCalledTimes(2));
expect(logTraceEvent).toHaveBeenLastCalledWith(
TraceDetailEvents.ThreadEmptyWithAiSummary,
expect.objectContaining({
totalSpansCount: 4,
inputTokens: 80,
totalCost: 0.0004,
}),
);
});
});

View File

@@ -0,0 +1,70 @@
import { MutableRefObject, useEffect } from 'react';
import { useGetTraceSummary } from 'api/generated/services/tracedetail';
import { TraceDetailEvents } from '../../events';
import { TraceDetailLogEvent } from '../../hooks/useTraceDetailLogEvent';
import { SpanDetailVariant } from '../../SpanDetailsPanel/constants';
import {
getThreadDataLoadedEventAttributes,
getThreadEmptyWithAiEventAttributes,
} from '../utils';
interface ThreadLoadedEventsArgs {
traceId: string;
interestedSpanId?: string;
hasSpans: boolean;
hasNoThread: boolean;
spanPanelVariant: SpanDetailVariant;
dataLoadedFiredForRef: MutableRefObject<string>;
threadEmptyFiredForRef: MutableRefObject<string>;
logTraceEvent: TraceDetailLogEvent;
}
export function useThreadLoadedEvents({
traceId,
interestedSpanId,
hasSpans,
hasNoThread,
spanPanelVariant,
dataLoadedFiredForRef,
threadEmptyFiredForRef,
logTraceEvent,
}: ThreadLoadedEventsArgs): void {
// No keepPreviousData: a previous trace's summary must not be reported for this one.
const { data } = useGetTraceSummary(
{ traceID: traceId },
{ query: { enabled: !!traceId, refetchOnMount: false } },
);
const summary = data?.data;
useEffect(() => {
if (!traceId || !summary) {
return;
}
// Not exclusive: an errored thread can still hold spans, so each event keeps its own ref.
if (hasSpans && dataLoadedFiredForRef.current !== traceId) {
dataLoadedFiredForRef.current = traceId;
logTraceEvent(
TraceDetailEvents.DataLoaded,
getThreadDataLoadedEventAttributes(summary, spanPanelVariant),
);
}
if (hasNoThread && summary.ai && threadEmptyFiredForRef.current !== traceId) {
threadEmptyFiredForRef.current = traceId;
logTraceEvent(
TraceDetailEvents.ThreadEmptyWithAiSummary,
getThreadEmptyWithAiEventAttributes(summary, interestedSpanId),
);
}
}, [
traceId,
interestedSpanId,
hasSpans,
hasNoThread,
summary,
spanPanelVariant,
dataLoadedFiredForRef,
threadEmptyFiredForRef,
logTraceEvent,
]);
}

View File

@@ -1,7 +1,13 @@
import type { SpantypesThreadSpanDTO } from 'api/generated/services/sigNoz.schemas';
import type {
SpantypesGettableTraceSummaryDTO,
SpantypesThreadSpanDTO,
} from 'api/generated/services/sigNoz.schemas';
import { isAxiosError } from 'axios';
import { collectToolCallKeys } from '../AIThreadMessage/utils';
import { TraceDetailsTab } from '../constants';
import { TraceDetailEventKeys } from '../events';
import { SpanDetailVariant } from '../SpanDetailsPanel/constants';
import { InterestedSpanStatus, ThreadSpan } from './types';
/** Derives SpanV3's `timestamp` and `service.name`, as the waterfall does. */
@@ -47,3 +53,44 @@ export function getInterestedSpanStatus(
? InterestedSpanStatus.Found
: InterestedSpanStatus.NoIOMessages;
}
function getThreadSummaryAttributes(
summary: SpantypesGettableTraceSummaryDTO,
): Record<string, unknown> {
return {
[TraceDetailEventKeys.DetailsTab]: TraceDetailsTab.Thread,
[TraceDetailEventKeys.TotalSpansCount]: summary.totalSpansCount,
[TraceDetailEventKeys.TraceDurationMs]:
summary.endTimestampMillis - summary.startTimestampMillis,
[TraceDetailEventKeys.HadErrors]: summary.totalErrorSpansCount > 0,
};
}
export function getThreadDataLoadedEventAttributes(
summary: SpantypesGettableTraceSummaryDTO,
spanPanelVariant: SpanDetailVariant,
): Record<string, unknown> {
return {
...getThreadSummaryAttributes(summary),
[TraceDetailEventKeys.SpanPanelVariant]: spanPanelVariant,
};
}
export function getThreadEmptyWithAiEventAttributes(
summary: SpantypesGettableTraceSummaryDTO,
interestedSpanId: string | undefined,
): Record<string, unknown> {
return {
...getThreadSummaryAttributes(summary),
[TraceDetailEventKeys.SpanId]: interestedSpanId,
[TraceDetailEventKeys.RootServiceName]: summary.rootServiceName,
[TraceDetailEventKeys.RootServiceEntryPoint]: summary.rootServiceEntryPoint,
[TraceDetailEventKeys.HasMissingSpans]: summary.hasMissingSpans,
[TraceDetailEventKeys.InputTokens]: summary.ai?.tokens.input,
[TraceDetailEventKeys.OutputTokens]: summary.ai?.tokens.output,
[TraceDetailEventKeys.CacheReadTokens]: summary.ai?.tokens.cacheRead,
[TraceDetailEventKeys.CacheWriteTokens]: summary.ai?.tokens.cacheWrite,
[TraceDetailEventKeys.ReasoningTokens]: summary.ai?.tokens.reasoning,
[TraceDetailEventKeys.TotalCost]: summary.ai?.totalCost,
};
}

View File

@@ -12,6 +12,9 @@ export enum TraceDetailEvents {
SpanPercentileTimeRangeChanged = 'Trace Detail: Span percentile time range changed',
SpanPercentileAttributesSelectorToggled = 'Trace Detail: Span percentile attributes selector toggled',
SpanPercentileAttributeChanged = 'Trace Detail: Span percentile attribute changed',
SpanDetailsOpened = 'Trace Detail: Span details opened',
DetailsTabChanged = 'Trace Detail: Details tab changed',
ThreadEmptyWithAiSummary = 'Trace Detail: Thread empty with AI summary',
}
export enum TraceDetailEventKeys {
@@ -28,7 +31,7 @@ export enum TraceDetailEventKeys {
SpanPanelVariant = 'spanPanelVariant',
ColorByField = 'colorByField',
PreviewFieldsCount = 'previewFieldsCount',
// View switched
// View switched / details tab changed
From = 'from',
To = 'to',
DwellMs = 'dwellMs',
@@ -44,6 +47,18 @@ export enum TraceDetailEventKeys {
PercentileValue = 'percentileValue',
ResourceAttributeKey = 'resourceAttributeKey',
Selected = 'selected',
// Data loaded / span details opened: which tab the user is on
DetailsTab = 'detailsTab',
// Thread empty with AI summary (reuses SpanId, DetailsTab, TotalSpansCount, TraceDurationMs, HadErrors)
RootServiceName = 'rootServiceName',
RootServiceEntryPoint = 'rootServiceEntryPoint',
HasMissingSpans = 'hasMissingSpans',
InputTokens = 'inputTokens',
OutputTokens = 'outputTokens',
CacheReadTokens = 'cacheReadTokens',
CacheWriteTokens = 'cacheWriteTokens',
ReasoningTokens = 'reasoningTokens',
TotalCost = 'totalCost',
}
export type TraceDetailView = 'v2' | 'v3';

View File

@@ -0,0 +1,90 @@
import { renderHook } from 'tests/test-utils';
import { TraceDetailsTab } from '../../constants';
import { TraceDetailEvents } from '../../events';
import { useSpanDetailsOpenedEvent } from '../useSpanDetailsOpenedEvent';
import { TraceDetailLogEvent } from '../useTraceDetailLogEvent';
interface PanelProps {
isOpen: boolean;
spanId?: string;
}
function renderOpenedEvent(initialProps: PanelProps): {
logTraceEvent: jest.MockedFunction<TraceDetailLogEvent>;
rerender: (props: PanelProps) => void;
} {
const logTraceEvent = jest.fn() as jest.MockedFunction<TraceDetailLogEvent>;
const { rerender } = renderHook(
({ isOpen, spanId }: PanelProps) =>
useSpanDetailsOpenedEvent({
isOpen,
spanId,
detailsTab: TraceDetailsTab.Thread,
logTraceEvent,
}),
{ initialProps },
);
return { logTraceEvent, rerender };
}
function openedSpanIds(
logTraceEvent: jest.MockedFunction<TraceDetailLogEvent>,
): unknown[] {
return logTraceEvent.mock.calls.map(([, attributes]) => attributes?.spanId);
}
describe('useSpanDetailsOpenedEvent', () => {
it('waits for the panel to be open on a span, then reports it with the tab', () => {
const { logTraceEvent, rerender } = renderOpenedEvent({ isOpen: false });
rerender({ isOpen: true });
rerender({ isOpen: false, spanId: 'span-1' });
expect(logTraceEvent).not.toHaveBeenCalled();
rerender({ isOpen: true, spanId: 'span-1' });
expect(logTraceEvent).toHaveBeenCalledTimes(1);
expect(logTraceEvent).toHaveBeenCalledWith(
TraceDetailEvents.SpanDetailsOpened,
{ spanId: 'span-1', detailsTab: TraceDetailsTab.Thread },
);
});
it('does not repeat while the same span stays open', () => {
const { logTraceEvent, rerender } = renderOpenedEvent({
isOpen: true,
spanId: 'span-1',
});
rerender({ isOpen: true, spanId: 'span-1' });
rerender({ isOpen: true, spanId: 'span-1' });
expect(logTraceEvent).toHaveBeenCalledTimes(1);
});
it('reports the same span again after the panel is closed and reopened', () => {
const { logTraceEvent, rerender } = renderOpenedEvent({
isOpen: true,
spanId: 'span-1',
});
rerender({ isOpen: false });
rerender({ isOpen: true, spanId: 'span-1' });
expect(openedSpanIds(logTraceEvent)).toStrictEqual(['span-1', 'span-1']);
});
it('reports each span selected while the panel stays open', () => {
const { logTraceEvent, rerender } = renderOpenedEvent({
isOpen: true,
spanId: 'span-1',
});
rerender({ isOpen: true, spanId: 'span-2' });
rerender({ isOpen: true, spanId: 'span-3' });
expect(openedSpanIds(logTraceEvent)).toStrictEqual([
'span-1',
'span-2',
'span-3',
]);
});
});

View File

@@ -0,0 +1,30 @@
import { useEffect } from 'react';
import { TraceDetailsTab } from '../constants';
import { TraceDetailEventKeys, TraceDetailEvents } from '../events';
import { TraceDetailLogEvent } from './useTraceDetailLogEvent';
interface SpanDetailsOpenedEventArgs {
isOpen: boolean;
spanId: string | undefined;
detailsTab: TraceDetailsTab;
logTraceEvent: TraceDetailLogEvent;
}
// Fires once per span shown in the details panel; resets when the panel closes.
export function useSpanDetailsOpenedEvent({
isOpen,
spanId,
detailsTab,
logTraceEvent,
}: SpanDetailsOpenedEventArgs): void {
useEffect(() => {
if (!isOpen || !spanId) {
return;
}
logTraceEvent(TraceDetailEvents.SpanDetailsOpened, {
[TraceDetailEventKeys.SpanId]: spanId,
[TraceDetailEventKeys.DetailsTab]: detailsTab,
});
}, [isOpen, spanId, detailsTab, logTraceEvent]);
}

View File

@@ -16,6 +16,8 @@ function TraceDetailsV3(): JSX.Element {
// Tracks which traceId the load event already fired for, so navigating
// between traces (the route component stays mounted) re-fires it once each.
const dataLoadedFiredForRef = useRef<string>('');
const threadDataLoadedFiredForRef = useRef<string>('');
const threadEmptyFiredForRef = useRef<string>('');
const handleFilteredSpansChange = useCallback(
(spanIds: string[], isActive: boolean): void => {
@@ -39,7 +41,10 @@ function TraceDetailsV3(): JSX.Element {
dataLoadedFiredForRef={dataLoadedFiredForRef}
/>
) : (
<TraceDetailsThread />
<TraceDetailsThread
dataLoadedFiredForRef={threadDataLoadedFiredForRef}
threadEmptyFiredForRef={threadEmptyFiredForRef}
/>
)}
</TraceDetailsWrapper>
);

View File

@@ -475,12 +475,17 @@ export function QueryBuilderProvider({
const createNewBuilderQuery = useCallback(
(queries: IBuilderQuery[]): IBuilderQuery => {
const existNames = queries.map((item) => item.queryName);
// Hosts without a fixed data source (the panel editor) continue the
// previous query's signal rather than resetting to metrics.
const previous = queries[queries.length - 1];
const initialBuilderQuery =
initialQueryBuilderFormValuesMap[initialDataSource || DataSource.METRICS];
initialQueryBuilderFormValuesMap[
initialDataSource || previous?.dataSource || DataSource.METRICS
];
const newQuery: IBuilderQuery = {
...initialBuilderQuery,
source: queries?.[0]?.source || '',
source: previous?.source || '',
builderQueryType: queries?.[0]?.builderQueryType,
queryName: createNewBuilderItemName({ existNames, sourceNames: alphabet }),
expression: createNewBuilderItemName({

View File

@@ -0,0 +1,104 @@
import {
initialQueriesMap,
initialQueryBuilderFormValuesMap,
PANEL_TYPES,
} from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { act, AllTheProviders, renderHook } from 'tests/test-utils';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
const renderQueryBuilder = (
initialQuery: Query,
): ReturnType<
typeof renderHook<ReturnType<typeof useQueryBuilder>, unknown>
> => {
const hook = renderHook(() => useQueryBuilder(), {
wrapper: AllTheProviders,
});
act(() => {
hook.result.current.initQueryBuilderData(initialQuery);
});
return hook;
};
describe('createNewBuilderQuery data source', () => {
it.each([DataSource.LOGS, DataSource.TRACES, DataSource.METRICS])(
'continues the previous %s query when the host fixes no data source',
(dataSource) => {
const { result } = renderQueryBuilder(initialQueriesMap[dataSource]);
act(() => {
result.current.addNewBuilderQuery();
});
const [, added] = result.current.currentQuery.builder.queryData;
expect(added.dataSource).toBe(dataSource);
expect(added.aggregations).toStrictEqual(
initialQueryBuilderFormValuesMap[dataSource].aggregations,
);
},
);
it('follows the last query when queries mix signals', () => {
const { result } = renderQueryBuilder(initialQueriesMap.logs);
act(() => {
result.current.addNewBuilderQuery();
});
act(() => {
result.current.handleSetQueryData(1, {
...initialQueryBuilderFormValuesMap.traces,
queryName: 'B',
expression: 'B',
});
});
act(() => {
result.current.addNewBuilderQuery();
});
expect(result.current.currentQuery.builder.queryData[2].dataSource).toBe(
DataSource.TRACES,
);
});
it('takes the source from the last query, not the first', () => {
const [metricsQuery] = initialQueriesMap.metrics.builder.queryData;
const { result } = renderQueryBuilder({
...initialQueriesMap.metrics,
builder: {
...initialQueriesMap.metrics.builder,
queryData: [
{ ...metricsQuery, source: '' },
{ ...metricsQuery, queryName: 'B', expression: 'B', source: 'meter' },
],
},
});
act(() => {
result.current.addNewBuilderQuery();
});
expect(result.current.currentQuery.builder.queryData[2]).toMatchObject({
dataSource: DataSource.METRICS,
source: 'meter',
});
});
it('uses the host’s fixed data source over the previous query', () => {
const { result } = renderQueryBuilder(initialQueriesMap.metrics);
act(() => {
result.current.handleSetConfig(PANEL_TYPES.TIME_SERIES, DataSource.LOGS);
});
act(() => {
result.current.addNewBuilderQuery();
});
expect(result.current.currentQuery.builder.queryData[1].dataSource).toBe(
DataSource.LOGS,
);
});
});

View File

@@ -1,6 +1,7 @@
import ROUTES from 'constants/routes';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { DataSource } from 'types/common/queryBuilder';
import { buildExplorerNavigationUrl } from '../buildExplorerNavigationUrl';
@@ -19,4 +20,14 @@ describe('buildExplorerNavigationUrl', () => {
expect(url).toContain(`${QueryParams.compositeQuery}=`);
expect(url).toContain(`${QueryParams.viewKey}=`);
});
it('JSON-encodes panelTypes so the explorer opens the right view', () => {
const url = buildExplorerNavigationUrl(
ROUTES.LOGS_EXPLORER,
initialQueriesMap[DataSource.LOGS],
{ [QueryParams.panelTypes]: PANEL_TYPES.TABLE },
);
expect(url).toContain(`${QueryParams.panelTypes}=%22table%22`);
});
});

View File

@@ -1,103 +0,0 @@
grammar AggregationExpression;
/*
* Parser Rules
*/
// Expressions after the first are ignored, as the rewriter always has.
query
: expression ( COMMA expression )* EOF
;
/*
* Aggregation calls combined with arithmetic. Earlier alternatives bind tighter.
* E.g.: count(), p99(duration_nano), sum(a) + sum(b), count() / 60, -(sum(a) - sum(b))
*/
expression
: (PLUS | MINUS) expression # unaryExpression
| expression (STAR | SLASH | PERCENT) expression # multiplicativeExpression
| expression (PLUS | MINUS) expression # additiveExpression
| LPAREN expression RPAREN # parenthesizedExpression
| functionCall # functionExpression
| NUMBER # numberExpression
;
/*
* E.g.: count(), sum(bytes), count_distinct(a, b), countIf(level = 'error'),
* sumIf(bytes, status IN (500, 503))
*/
functionCall
: KEY LPAREN argumentList? RPAREN
;
argumentList
: argument ( COMMA argument )*
;
/*
* An argument is a field key, or for a *If combinator a filter predicate, which
* the filter grammar validates. Commas separate arguments only at the top level.
*/
argument
: argumentToken+
;
argumentToken
: KEY
| NUMBER
| QUOTED_TEXT
| OPERATOR
| PLUS | MINUS | STAR | SLASH | PERCENT
| LPAREN ( argumentToken | COMMA )* RPAREN
| LBRACK ( argumentToken | COMMA )* RBRACK
| OTHER
;
/*
* Lexer Rules
*/
LPAREN : '(' ;
RPAREN : ')' ;
LBRACK : '[' ;
RBRACK : ']' ;
COMMA : ',' ;
PLUS : '+' ;
MINUS : '-' ;
STAR : '*' ;
SLASH : '/' ;
PERCENT : '%' ;
OPERATOR
: '=' | '==' | '!=' | '<>' | '<' | '<=' | '>' | '>='
;
// No leading sign, so `count()-10` reads as a subtraction.
NUMBER
: DIGIT+ ('.' DIGIT*)? ([eE] [+-]? DIGIT+)?
| '.' DIGIT+ ([eE] [+-]? DIGIT+)?
;
QUOTED_TEXT
: '"' ( ~["\\] | '\\' . )* '"'
| '\'' ( ~['\\] | '\\' . )* '\''
;
// Same key syntax as the filter grammar.
fragment SEGMENT : [a-zA-Z$_@{#] [a-zA-Z0-9$_@#{}:\-/]* ;
fragment EMPTY_BRACKS : '[' ']' ;
fragment OLD_JSON_BRACKS : '[' '*' ']' ;
KEY
: SEGMENT ( '.' SEGMENT | EMPTY_BRACKS | OLD_JSON_BRACKS | '.' DIGIT+ )*
;
WS
: [ \t\r\n]+ -> skip
;
fragment DIGIT : [0-9] ;
// Anything else is left for a predicate's own grammar to judge.
OTHER : . ;

View File

@@ -4,9 +4,13 @@ import (
"context"
"encoding/json"
"fmt"
"log/slog"
"maps"
"sync"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/llmpricingrule"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
@@ -22,12 +26,14 @@ import (
const unmappedModelsLookback = time.Hour
type module struct {
store llmpricingruletypes.Store
querier querier.Querier
store llmpricingruletypes.Store
querier querier.Querier
telemetryMetadataStore telemetrytypes.MetadataStore
logger *slog.Logger
}
func NewModule(store llmpricingruletypes.Store, querier querier.Querier) llmpricingrule.Module {
return &module{store: store, querier: querier}
func NewModule(store llmpricingruletypes.Store, querier querier.Querier, telemetryMetadataStore telemetrytypes.MetadataStore, providerSettings factory.ProviderSettings) llmpricingrule.Module {
return &module{store: store, querier: querier, telemetryMetadataStore: telemetryMetadataStore, logger: providerSettings.Logger}
}
func (module *module) List(ctx context.Context, orgID valuer.UUID, offset, limit int, search string, isOverride *bool) ([]*llmpricingruletypes.LLMPricingRule, int, error) {
@@ -51,13 +57,47 @@ func (module *module) ListUnmappedModels(ctx context.Context, orgID valuer.UUID)
return nil, err
}
unmapped := make([]*llmpricingruletypes.UnmappedModel, 0, len(models))
for _, m := range models {
if !llmpricingruletypes.ModelMatchesAnyRule(m.ModelName, rules) {
unmapped = append(unmapped, m)
return llmpricingruletypes.UnmappedModels(models, rules), nil
}
// Collect keeps the rule counts even when ClickHouse is unreachable: the
// counts that depend on it are skipped so the reporter retains their last value.
// Models come from the traces tag table rather than a span scan, so the
// unmapped count covers the metadata lookback instead of the last hour.
func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[string]any, error) {
rules, err := module.listAllRules(ctx, orgID)
if err != nil {
return nil, err
}
stats := llmpricingruletypes.NewStatsFromRules(rules)
selectors := aiobservabilitytypes.StatsFieldValueSelectors(time.Now())
results := make([]*telemetrytypes.TelemetryFieldValues, len(selectors))
var lookups sync.WaitGroup
for i, selector := range selectors {
lookups.Go(func() {
fieldValues, _, err := module.telemetryMetadataStore.GetAllValues(ctx, orgID, selector)
if err != nil {
module.logger.DebugContext(ctx, "failed to collect gen_ai attribute stats", slog.String("key", selector.Name), errors.Attr(err))
return
}
results[i] = fieldValues
})
}
lookups.Wait()
values := make(map[string]*telemetrytypes.TelemetryFieldValues, len(selectors))
for i, selector := range selectors {
if results[i] != nil {
values[selector.Name] = results[i]
}
}
return unmapped, nil
maps.Copy(stats, aiobservabilitytypes.NewStatsFromFieldValues(values))
if models := values[aiobservabilitytypes.GenAIRequestModel]; models != nil {
maps.Copy(stats, llmpricingruletypes.NewStatsFromModels(models.StringValues, rules))
}
return stats, nil
}
// CreateOrUpdate saves a batch of pricing rules. isOverride decides how a rule

View File

@@ -5,6 +5,7 @@ import (
"net/http"
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
"github.com/SigNoz/signoz/pkg/statsreporter"
"github.com/SigNoz/signoz/pkg/types/llmpricingruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -13,6 +14,8 @@ type Module interface {
// Since this module interacts with OpAMP, it must implement the AgentFeature interface.
agentConf.AgentFeature
statsreporter.StatsCollector
List(ctx context.Context, orgID valuer.UUID, offset, limit int, search string, isOverride *bool) ([]*llmpricingruletypes.LLMPricingRule, int, error)
Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*llmpricingruletypes.LLMPricingRule, error)
CreateOrUpdate(ctx context.Context, orgID valuer.UUID, userEmail string, rules []*llmpricingruletypes.UpdatableLLMPricingRule) (err error)

View File

@@ -1,49 +0,0 @@
token literal names:
null
'('
')'
'['
']'
','
'+'
'-'
'*'
'/'
'%'
null
null
null
null
null
null
token symbolic names:
null
LPAREN
RPAREN
LBRACK
RBRACK
COMMA
PLUS
MINUS
STAR
SLASH
PERCENT
OPERATOR
NUMBER
QUOTED_TEXT
KEY
WS
OTHER
rule names:
query
expression
functionCall
argumentList
argument
argumentToken
atn:
[4, 1, 16, 95, 2, 0, 7, 0, 2, 1, 7, 1, 2, 2, 7, 2, 2, 3, 7, 3, 2, 4, 7, 4, 2, 5, 7, 5, 1, 0, 1, 0, 1, 0, 5, 0, 16, 8, 0, 10, 0, 12, 0, 19, 9, 0, 1, 0, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 1, 32, 8, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 5, 1, 40, 8, 1, 10, 1, 12, 1, 43, 9, 1, 1, 2, 1, 2, 1, 2, 3, 2, 48, 8, 2, 1, 2, 1, 2, 1, 3, 1, 3, 1, 3, 5, 3, 55, 8, 3, 10, 3, 12, 3, 58, 9, 3, 1, 4, 4, 4, 61, 8, 4, 11, 4, 12, 4, 62, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 5, 5, 77, 8, 5, 10, 5, 12, 5, 80, 9, 5, 1, 5, 1, 5, 1, 5, 1, 5, 5, 5, 86, 8, 5, 10, 5, 12, 5, 89, 9, 5, 1, 5, 1, 5, 3, 5, 93, 8, 5, 1, 5, 0, 1, 2, 6, 0, 2, 4, 6, 8, 10, 0, 2, 1, 0, 6, 7, 1, 0, 8, 10, 112, 0, 12, 1, 0, 0, 0, 2, 31, 1, 0, 0, 0, 4, 44, 1, 0, 0, 0, 6, 51, 1, 0, 0, 0, 8, 60, 1, 0, 0, 0, 10, 92, 1, 0, 0, 0, 12, 17, 3, 2, 1, 0, 13, 14, 5, 5, 0, 0, 14, 16, 3, 2, 1, 0, 15, 13, 1, 0, 0, 0, 16, 19, 1, 0, 0, 0, 17, 15, 1, 0, 0, 0, 17, 18, 1, 0, 0, 0, 18, 20, 1, 0, 0, 0, 19, 17, 1, 0, 0, 0, 20, 21, 5, 0, 0, 1, 21, 1, 1, 0, 0, 0, 22, 23, 6, 1, -1, 0, 23, 24, 7, 0, 0, 0, 24, 32, 3, 2, 1, 6, 25, 26, 5, 1, 0, 0, 26, 27, 3, 2, 1, 0, 27, 28, 5, 2, 0, 0, 28, 32, 1, 0, 0, 0, 29, 32, 3, 4, 2, 0, 30, 32, 5, 12, 0, 0, 31, 22, 1, 0, 0, 0, 31, 25, 1, 0, 0, 0, 31, 29, 1, 0, 0, 0, 31, 30, 1, 0, 0, 0, 32, 41, 1, 0, 0, 0, 33, 34, 10, 5, 0, 0, 34, 35, 7, 1, 0, 0, 35, 40, 3, 2, 1, 6, 36, 37, 10, 4, 0, 0, 37, 38, 7, 0, 0, 0, 38, 40, 3, 2, 1, 5, 39, 33, 1, 0, 0, 0, 39, 36, 1, 0, 0, 0, 40, 43, 1, 0, 0, 0, 41, 39, 1, 0, 0, 0, 41, 42, 1, 0, 0, 0, 42, 3, 1, 0, 0, 0, 43, 41, 1, 0, 0, 0, 44, 45, 5, 14, 0, 0, 45, 47, 5, 1, 0, 0, 46, 48, 3, 6, 3, 0, 47, 46, 1, 0, 0, 0, 47, 48, 1, 0, 0, 0, 48, 49, 1, 0, 0, 0, 49, 50, 5, 2, 0, 0, 50, 5, 1, 0, 0, 0, 51, 56, 3, 8, 4, 0, 52, 53, 5, 5, 0, 0, 53, 55, 3, 8, 4, 0, 54, 52, 1, 0, 0, 0, 55, 58, 1, 0, 0, 0, 56, 54, 1, 0, 0, 0, 56, 57, 1, 0, 0, 0, 57, 7, 1, 0, 0, 0, 58, 56, 1, 0, 0, 0, 59, 61, 3, 10, 5, 0, 60, 59, 1, 0, 0, 0, 61, 62, 1, 0, 0, 0, 62, 60, 1, 0, 0, 0, 62, 63, 1, 0, 0, 0, 63, 9, 1, 0, 0, 0, 64, 93, 5, 14, 0, 0, 65, 93, 5, 12, 0, 0, 66, 93, 5, 13, 0, 0, 67, 93, 5, 11, 0, 0, 68, 93, 5, 6, 0, 0, 69, 93, 5, 7, 0, 0, 70, 93, 5, 8, 0, 0, 71, 93, 5, 9, 0, 0, 72, 93, 5, 10, 0, 0, 73, 78, 5, 1, 0, 0, 74, 77, 3, 10, 5, 0, 75, 77, 5, 5, 0, 0, 76, 74, 1, 0, 0, 0, 76, 75, 1, 0, 0, 0, 77, 80, 1, 0, 0, 0, 78, 76, 1, 0, 0, 0, 78, 79, 1, 0, 0, 0, 79, 81, 1, 0, 0, 0, 80, 78, 1, 0, 0, 0, 81, 93, 5, 2, 0, 0, 82, 87, 5, 3, 0, 0, 83, 86, 3, 10, 5, 0, 84, 86, 5, 5, 0, 0, 85, 83, 1, 0, 0, 0, 85, 84, 1, 0, 0, 0, 86, 89, 1, 0, 0, 0, 87, 85, 1, 0, 0, 0, 87, 88, 1, 0, 0, 0, 88, 90, 1, 0, 0, 0, 89, 87, 1, 0, 0, 0, 90, 93, 5, 4, 0, 0, 91, 93, 5, 16, 0, 0, 92, 64, 1, 0, 0, 0, 92, 65, 1, 0, 0, 0, 92, 66, 1, 0, 0, 0, 92, 67, 1, 0, 0, 0, 92, 68, 1, 0, 0, 0, 92, 69, 1, 0, 0, 0, 92, 70, 1, 0, 0, 0, 92, 71, 1, 0, 0, 0, 92, 72, 1, 0, 0, 0, 92, 73, 1, 0, 0, 0, 92, 82, 1, 0, 0, 0, 92, 91, 1, 0, 0, 0, 93, 11, 1, 0, 0, 0, 12, 17, 31, 39, 41, 47, 56, 62, 76, 78, 85, 87, 92]

View File

@@ -1,26 +0,0 @@
LPAREN=1
RPAREN=2
LBRACK=3
RBRACK=4
COMMA=5
PLUS=6
MINUS=7
STAR=8
SLASH=9
PERCENT=10
OPERATOR=11
NUMBER=12
QUOTED_TEXT=13
KEY=14
WS=15
OTHER=16
'('=1
')'=2
'['=3
']'=4
','=5
'+'=6
'-'=7
'*'=8
'/'=9
'%'=10

File diff suppressed because one or more lines are too long

View File

@@ -1,26 +0,0 @@
LPAREN=1
RPAREN=2
LBRACK=3
RBRACK=4
COMMA=5
PLUS=6
MINUS=7
STAR=8
SLASH=9
PERCENT=10
OPERATOR=11
NUMBER=12
QUOTED_TEXT=13
KEY=14
WS=15
OTHER=16
'('=1
')'=2
'['=3
']'=4
','=5
'+'=6
'-'=7
'*'=8
'/'=9
'%'=10

View File

@@ -1,92 +0,0 @@
// Code generated from grammar/AggregationExpression.g4 by ANTLR 4.13.2. DO NOT EDIT.
package parser // AggregationExpression
import "github.com/antlr4-go/antlr/v4"
// BaseAggregationExpressionListener is a complete listener for a parse tree produced by AggregationExpressionParser.
type BaseAggregationExpressionListener struct{}
var _ AggregationExpressionListener = &BaseAggregationExpressionListener{}
// VisitTerminal is called when a terminal node is visited.
func (s *BaseAggregationExpressionListener) VisitTerminal(node antlr.TerminalNode) {}
// VisitErrorNode is called when an error node is visited.
func (s *BaseAggregationExpressionListener) VisitErrorNode(node antlr.ErrorNode) {}
// EnterEveryRule is called when any rule is entered.
func (s *BaseAggregationExpressionListener) EnterEveryRule(ctx antlr.ParserRuleContext) {}
// ExitEveryRule is called when any rule is exited.
func (s *BaseAggregationExpressionListener) ExitEveryRule(ctx antlr.ParserRuleContext) {}
// EnterQuery is called when production query is entered.
func (s *BaseAggregationExpressionListener) EnterQuery(ctx *QueryContext) {}
// ExitQuery is called when production query is exited.
func (s *BaseAggregationExpressionListener) ExitQuery(ctx *QueryContext) {}
// EnterParenthesizedExpression is called when production parenthesizedExpression is entered.
func (s *BaseAggregationExpressionListener) EnterParenthesizedExpression(ctx *ParenthesizedExpressionContext) {
}
// ExitParenthesizedExpression is called when production parenthesizedExpression is exited.
func (s *BaseAggregationExpressionListener) ExitParenthesizedExpression(ctx *ParenthesizedExpressionContext) {
}
// EnterAdditiveExpression is called when production additiveExpression is entered.
func (s *BaseAggregationExpressionListener) EnterAdditiveExpression(ctx *AdditiveExpressionContext) {}
// ExitAdditiveExpression is called when production additiveExpression is exited.
func (s *BaseAggregationExpressionListener) ExitAdditiveExpression(ctx *AdditiveExpressionContext) {}
// EnterNumberExpression is called when production numberExpression is entered.
func (s *BaseAggregationExpressionListener) EnterNumberExpression(ctx *NumberExpressionContext) {}
// ExitNumberExpression is called when production numberExpression is exited.
func (s *BaseAggregationExpressionListener) ExitNumberExpression(ctx *NumberExpressionContext) {}
// EnterMultiplicativeExpression is called when production multiplicativeExpression is entered.
func (s *BaseAggregationExpressionListener) EnterMultiplicativeExpression(ctx *MultiplicativeExpressionContext) {
}
// ExitMultiplicativeExpression is called when production multiplicativeExpression is exited.
func (s *BaseAggregationExpressionListener) ExitMultiplicativeExpression(ctx *MultiplicativeExpressionContext) {
}
// EnterUnaryExpression is called when production unaryExpression is entered.
func (s *BaseAggregationExpressionListener) EnterUnaryExpression(ctx *UnaryExpressionContext) {}
// ExitUnaryExpression is called when production unaryExpression is exited.
func (s *BaseAggregationExpressionListener) ExitUnaryExpression(ctx *UnaryExpressionContext) {}
// EnterFunctionExpression is called when production functionExpression is entered.
func (s *BaseAggregationExpressionListener) EnterFunctionExpression(ctx *FunctionExpressionContext) {}
// ExitFunctionExpression is called when production functionExpression is exited.
func (s *BaseAggregationExpressionListener) ExitFunctionExpression(ctx *FunctionExpressionContext) {}
// EnterFunctionCall is called when production functionCall is entered.
func (s *BaseAggregationExpressionListener) EnterFunctionCall(ctx *FunctionCallContext) {}
// ExitFunctionCall is called when production functionCall is exited.
func (s *BaseAggregationExpressionListener) ExitFunctionCall(ctx *FunctionCallContext) {}
// EnterArgumentList is called when production argumentList is entered.
func (s *BaseAggregationExpressionListener) EnterArgumentList(ctx *ArgumentListContext) {}
// ExitArgumentList is called when production argumentList is exited.
func (s *BaseAggregationExpressionListener) ExitArgumentList(ctx *ArgumentListContext) {}
// EnterArgument is called when production argument is entered.
func (s *BaseAggregationExpressionListener) EnterArgument(ctx *ArgumentContext) {}
// ExitArgument is called when production argument is exited.
func (s *BaseAggregationExpressionListener) ExitArgument(ctx *ArgumentContext) {}
// EnterArgumentToken is called when production argumentToken is entered.
func (s *BaseAggregationExpressionListener) EnterArgumentToken(ctx *ArgumentTokenContext) {}
// ExitArgumentToken is called when production argumentToken is exited.
func (s *BaseAggregationExpressionListener) ExitArgumentToken(ctx *ArgumentTokenContext) {}

View File

@@ -1,53 +0,0 @@
// Code generated from grammar/AggregationExpression.g4 by ANTLR 4.13.2. DO NOT EDIT.
package parser // AggregationExpression
import "github.com/antlr4-go/antlr/v4"
type BaseAggregationExpressionVisitor struct {
*antlr.BaseParseTreeVisitor
}
func (v *BaseAggregationExpressionVisitor) VisitQuery(ctx *QueryContext) interface{} {
return v.VisitChildren(ctx)
}
func (v *BaseAggregationExpressionVisitor) VisitParenthesizedExpression(ctx *ParenthesizedExpressionContext) interface{} {
return v.VisitChildren(ctx)
}
func (v *BaseAggregationExpressionVisitor) VisitAdditiveExpression(ctx *AdditiveExpressionContext) interface{} {
return v.VisitChildren(ctx)
}
func (v *BaseAggregationExpressionVisitor) VisitNumberExpression(ctx *NumberExpressionContext) interface{} {
return v.VisitChildren(ctx)
}
func (v *BaseAggregationExpressionVisitor) VisitMultiplicativeExpression(ctx *MultiplicativeExpressionContext) interface{} {
return v.VisitChildren(ctx)
}
func (v *BaseAggregationExpressionVisitor) VisitUnaryExpression(ctx *UnaryExpressionContext) interface{} {
return v.VisitChildren(ctx)
}
func (v *BaseAggregationExpressionVisitor) VisitFunctionExpression(ctx *FunctionExpressionContext) interface{} {
return v.VisitChildren(ctx)
}
func (v *BaseAggregationExpressionVisitor) VisitFunctionCall(ctx *FunctionCallContext) interface{} {
return v.VisitChildren(ctx)
}
func (v *BaseAggregationExpressionVisitor) VisitArgumentList(ctx *ArgumentListContext) interface{} {
return v.VisitChildren(ctx)
}
func (v *BaseAggregationExpressionVisitor) VisitArgument(ctx *ArgumentContext) interface{} {
return v.VisitChildren(ctx)
}
func (v *BaseAggregationExpressionVisitor) VisitArgumentToken(ctx *ArgumentTokenContext) interface{} {
return v.VisitChildren(ctx)
}

View File

@@ -1,204 +0,0 @@
// Code generated from grammar/AggregationExpression.g4 by ANTLR 4.13.2. DO NOT EDIT.
package parser
import (
"fmt"
"github.com/antlr4-go/antlr/v4"
"sync"
"unicode"
)
// Suppress unused import error
var _ = fmt.Printf
var _ = sync.Once{}
var _ = unicode.IsLetter
type AggregationExpressionLexer struct {
*antlr.BaseLexer
channelNames []string
modeNames []string
// TODO: EOF string
}
var AggregationExpressionLexerLexerStaticData struct {
once sync.Once
serializedATN []int32
ChannelNames []string
ModeNames []string
LiteralNames []string
SymbolicNames []string
RuleNames []string
PredictionContextCache *antlr.PredictionContextCache
atn *antlr.ATN
decisionToDFA []*antlr.DFA
}
func aggregationexpressionlexerLexerInit() {
staticData := &AggregationExpressionLexerLexerStaticData
staticData.ChannelNames = []string{
"DEFAULT_TOKEN_CHANNEL", "HIDDEN",
}
staticData.ModeNames = []string{
"DEFAULT_MODE",
}
staticData.LiteralNames = []string{
"", "'('", "')'", "'['", "']'", "','", "'+'", "'-'", "'*'", "'/'", "'%'",
}
staticData.SymbolicNames = []string{
"", "LPAREN", "RPAREN", "LBRACK", "RBRACK", "COMMA", "PLUS", "MINUS",
"STAR", "SLASH", "PERCENT", "OPERATOR", "NUMBER", "QUOTED_TEXT", "KEY",
"WS", "OTHER",
}
staticData.RuleNames = []string{
"LPAREN", "RPAREN", "LBRACK", "RBRACK", "COMMA", "PLUS", "MINUS", "STAR",
"SLASH", "PERCENT", "OPERATOR", "NUMBER", "QUOTED_TEXT", "SEGMENT",
"EMPTY_BRACKS", "OLD_JSON_BRACKS", "KEY", "WS", "DIGIT", "OTHER",
}
staticData.PredictionContextCache = antlr.NewPredictionContextCache()
staticData.serializedATN = []int32{
4, 0, 16, 183, 6, -1, 2, 0, 7, 0, 2, 1, 7, 1, 2, 2, 7, 2, 2, 3, 7, 3, 2,
4, 7, 4, 2, 5, 7, 5, 2, 6, 7, 6, 2, 7, 7, 7, 2, 8, 7, 8, 2, 9, 7, 9, 2,
10, 7, 10, 2, 11, 7, 11, 2, 12, 7, 12, 2, 13, 7, 13, 2, 14, 7, 14, 2, 15,
7, 15, 2, 16, 7, 16, 2, 17, 7, 17, 2, 18, 7, 18, 2, 19, 7, 19, 1, 0, 1,
0, 1, 1, 1, 1, 1, 2, 1, 2, 1, 3, 1, 3, 1, 4, 1, 4, 1, 5, 1, 5, 1, 6, 1,
6, 1, 7, 1, 7, 1, 8, 1, 8, 1, 9, 1, 9, 1, 10, 1, 10, 1, 10, 1, 10, 1, 10,
1, 10, 1, 10, 1, 10, 1, 10, 1, 10, 1, 10, 1, 10, 1, 10, 3, 10, 75, 8, 10,
1, 11, 4, 11, 78, 8, 11, 11, 11, 12, 11, 79, 1, 11, 1, 11, 5, 11, 84, 8,
11, 10, 11, 12, 11, 87, 9, 11, 3, 11, 89, 8, 11, 1, 11, 1, 11, 3, 11, 93,
8, 11, 1, 11, 4, 11, 96, 8, 11, 11, 11, 12, 11, 97, 3, 11, 100, 8, 11,
1, 11, 1, 11, 4, 11, 104, 8, 11, 11, 11, 12, 11, 105, 1, 11, 1, 11, 3,
11, 110, 8, 11, 1, 11, 4, 11, 113, 8, 11, 11, 11, 12, 11, 114, 3, 11, 117,
8, 11, 3, 11, 119, 8, 11, 1, 12, 1, 12, 1, 12, 1, 12, 5, 12, 125, 8, 12,
10, 12, 12, 12, 128, 9, 12, 1, 12, 1, 12, 1, 12, 1, 12, 1, 12, 5, 12, 135,
8, 12, 10, 12, 12, 12, 138, 9, 12, 1, 12, 3, 12, 141, 8, 12, 1, 13, 1,
13, 5, 13, 145, 8, 13, 10, 13, 12, 13, 148, 9, 13, 1, 14, 1, 14, 1, 14,
1, 15, 1, 15, 1, 15, 1, 15, 1, 16, 1, 16, 1, 16, 1, 16, 1, 16, 1, 16, 1,
16, 4, 16, 164, 8, 16, 11, 16, 12, 16, 165, 5, 16, 168, 8, 16, 10, 16,
12, 16, 171, 9, 16, 1, 17, 4, 17, 174, 8, 17, 11, 17, 12, 17, 175, 1, 17,
1, 17, 1, 18, 1, 18, 1, 19, 1, 19, 0, 0, 20, 1, 1, 3, 2, 5, 3, 7, 4, 9,
5, 11, 6, 13, 7, 15, 8, 17, 9, 19, 10, 21, 11, 23, 12, 25, 13, 27, 0, 29,
0, 31, 0, 33, 14, 35, 15, 37, 0, 39, 16, 1, 0, 8, 2, 0, 69, 69, 101, 101,
2, 0, 43, 43, 45, 45, 2, 0, 34, 34, 92, 92, 2, 0, 39, 39, 92, 92, 4, 0,
35, 36, 64, 90, 95, 95, 97, 123, 7, 0, 35, 36, 45, 45, 47, 58, 64, 90,
95, 95, 97, 123, 125, 125, 3, 0, 9, 10, 13, 13, 32, 32, 1, 0, 48, 57, 208,
0, 1, 1, 0, 0, 0, 0, 3, 1, 0, 0, 0, 0, 5, 1, 0, 0, 0, 0, 7, 1, 0, 0, 0,
0, 9, 1, 0, 0, 0, 0, 11, 1, 0, 0, 0, 0, 13, 1, 0, 0, 0, 0, 15, 1, 0, 0,
0, 0, 17, 1, 0, 0, 0, 0, 19, 1, 0, 0, 0, 0, 21, 1, 0, 0, 0, 0, 23, 1, 0,
0, 0, 0, 25, 1, 0, 0, 0, 0, 33, 1, 0, 0, 0, 0, 35, 1, 0, 0, 0, 0, 39, 1,
0, 0, 0, 1, 41, 1, 0, 0, 0, 3, 43, 1, 0, 0, 0, 5, 45, 1, 0, 0, 0, 7, 47,
1, 0, 0, 0, 9, 49, 1, 0, 0, 0, 11, 51, 1, 0, 0, 0, 13, 53, 1, 0, 0, 0,
15, 55, 1, 0, 0, 0, 17, 57, 1, 0, 0, 0, 19, 59, 1, 0, 0, 0, 21, 74, 1,
0, 0, 0, 23, 118, 1, 0, 0, 0, 25, 140, 1, 0, 0, 0, 27, 142, 1, 0, 0, 0,
29, 149, 1, 0, 0, 0, 31, 152, 1, 0, 0, 0, 33, 156, 1, 0, 0, 0, 35, 173,
1, 0, 0, 0, 37, 179, 1, 0, 0, 0, 39, 181, 1, 0, 0, 0, 41, 42, 5, 40, 0,
0, 42, 2, 1, 0, 0, 0, 43, 44, 5, 41, 0, 0, 44, 4, 1, 0, 0, 0, 45, 46, 5,
91, 0, 0, 46, 6, 1, 0, 0, 0, 47, 48, 5, 93, 0, 0, 48, 8, 1, 0, 0, 0, 49,
50, 5, 44, 0, 0, 50, 10, 1, 0, 0, 0, 51, 52, 5, 43, 0, 0, 52, 12, 1, 0,
0, 0, 53, 54, 5, 45, 0, 0, 54, 14, 1, 0, 0, 0, 55, 56, 5, 42, 0, 0, 56,
16, 1, 0, 0, 0, 57, 58, 5, 47, 0, 0, 58, 18, 1, 0, 0, 0, 59, 60, 5, 37,
0, 0, 60, 20, 1, 0, 0, 0, 61, 75, 5, 61, 0, 0, 62, 63, 5, 61, 0, 0, 63,
75, 5, 61, 0, 0, 64, 65, 5, 33, 0, 0, 65, 75, 5, 61, 0, 0, 66, 67, 5, 60,
0, 0, 67, 75, 5, 62, 0, 0, 68, 75, 5, 60, 0, 0, 69, 70, 5, 60, 0, 0, 70,
75, 5, 61, 0, 0, 71, 75, 5, 62, 0, 0, 72, 73, 5, 62, 0, 0, 73, 75, 5, 61,
0, 0, 74, 61, 1, 0, 0, 0, 74, 62, 1, 0, 0, 0, 74, 64, 1, 0, 0, 0, 74, 66,
1, 0, 0, 0, 74, 68, 1, 0, 0, 0, 74, 69, 1, 0, 0, 0, 74, 71, 1, 0, 0, 0,
74, 72, 1, 0, 0, 0, 75, 22, 1, 0, 0, 0, 76, 78, 3, 37, 18, 0, 77, 76, 1,
0, 0, 0, 78, 79, 1, 0, 0, 0, 79, 77, 1, 0, 0, 0, 79, 80, 1, 0, 0, 0, 80,
88, 1, 0, 0, 0, 81, 85, 5, 46, 0, 0, 82, 84, 3, 37, 18, 0, 83, 82, 1, 0,
0, 0, 84, 87, 1, 0, 0, 0, 85, 83, 1, 0, 0, 0, 85, 86, 1, 0, 0, 0, 86, 89,
1, 0, 0, 0, 87, 85, 1, 0, 0, 0, 88, 81, 1, 0, 0, 0, 88, 89, 1, 0, 0, 0,
89, 99, 1, 0, 0, 0, 90, 92, 7, 0, 0, 0, 91, 93, 7, 1, 0, 0, 92, 91, 1,
0, 0, 0, 92, 93, 1, 0, 0, 0, 93, 95, 1, 0, 0, 0, 94, 96, 3, 37, 18, 0,
95, 94, 1, 0, 0, 0, 96, 97, 1, 0, 0, 0, 97, 95, 1, 0, 0, 0, 97, 98, 1,
0, 0, 0, 98, 100, 1, 0, 0, 0, 99, 90, 1, 0, 0, 0, 99, 100, 1, 0, 0, 0,
100, 119, 1, 0, 0, 0, 101, 103, 5, 46, 0, 0, 102, 104, 3, 37, 18, 0, 103,
102, 1, 0, 0, 0, 104, 105, 1, 0, 0, 0, 105, 103, 1, 0, 0, 0, 105, 106,
1, 0, 0, 0, 106, 116, 1, 0, 0, 0, 107, 109, 7, 0, 0, 0, 108, 110, 7, 1,
0, 0, 109, 108, 1, 0, 0, 0, 109, 110, 1, 0, 0, 0, 110, 112, 1, 0, 0, 0,
111, 113, 3, 37, 18, 0, 112, 111, 1, 0, 0, 0, 113, 114, 1, 0, 0, 0, 114,
112, 1, 0, 0, 0, 114, 115, 1, 0, 0, 0, 115, 117, 1, 0, 0, 0, 116, 107,
1, 0, 0, 0, 116, 117, 1, 0, 0, 0, 117, 119, 1, 0, 0, 0, 118, 77, 1, 0,
0, 0, 118, 101, 1, 0, 0, 0, 119, 24, 1, 0, 0, 0, 120, 126, 5, 34, 0, 0,
121, 125, 8, 2, 0, 0, 122, 123, 5, 92, 0, 0, 123, 125, 9, 0, 0, 0, 124,
121, 1, 0, 0, 0, 124, 122, 1, 0, 0, 0, 125, 128, 1, 0, 0, 0, 126, 124,
1, 0, 0, 0, 126, 127, 1, 0, 0, 0, 127, 129, 1, 0, 0, 0, 128, 126, 1, 0,
0, 0, 129, 141, 5, 34, 0, 0, 130, 136, 5, 39, 0, 0, 131, 135, 8, 3, 0,
0, 132, 133, 5, 92, 0, 0, 133, 135, 9, 0, 0, 0, 134, 131, 1, 0, 0, 0, 134,
132, 1, 0, 0, 0, 135, 138, 1, 0, 0, 0, 136, 134, 1, 0, 0, 0, 136, 137,
1, 0, 0, 0, 137, 139, 1, 0, 0, 0, 138, 136, 1, 0, 0, 0, 139, 141, 5, 39,
0, 0, 140, 120, 1, 0, 0, 0, 140, 130, 1, 0, 0, 0, 141, 26, 1, 0, 0, 0,
142, 146, 7, 4, 0, 0, 143, 145, 7, 5, 0, 0, 144, 143, 1, 0, 0, 0, 145,
148, 1, 0, 0, 0, 146, 144, 1, 0, 0, 0, 146, 147, 1, 0, 0, 0, 147, 28, 1,
0, 0, 0, 148, 146, 1, 0, 0, 0, 149, 150, 5, 91, 0, 0, 150, 151, 5, 93,
0, 0, 151, 30, 1, 0, 0, 0, 152, 153, 5, 91, 0, 0, 153, 154, 5, 42, 0, 0,
154, 155, 5, 93, 0, 0, 155, 32, 1, 0, 0, 0, 156, 169, 3, 27, 13, 0, 157,
158, 5, 46, 0, 0, 158, 168, 3, 27, 13, 0, 159, 168, 3, 29, 14, 0, 160,
168, 3, 31, 15, 0, 161, 163, 5, 46, 0, 0, 162, 164, 3, 37, 18, 0, 163,
162, 1, 0, 0, 0, 164, 165, 1, 0, 0, 0, 165, 163, 1, 0, 0, 0, 165, 166,
1, 0, 0, 0, 166, 168, 1, 0, 0, 0, 167, 157, 1, 0, 0, 0, 167, 159, 1, 0,
0, 0, 167, 160, 1, 0, 0, 0, 167, 161, 1, 0, 0, 0, 168, 171, 1, 0, 0, 0,
169, 167, 1, 0, 0, 0, 169, 170, 1, 0, 0, 0, 170, 34, 1, 0, 0, 0, 171, 169,
1, 0, 0, 0, 172, 174, 7, 6, 0, 0, 173, 172, 1, 0, 0, 0, 174, 175, 1, 0,
0, 0, 175, 173, 1, 0, 0, 0, 175, 176, 1, 0, 0, 0, 176, 177, 1, 0, 0, 0,
177, 178, 6, 17, 0, 0, 178, 36, 1, 0, 0, 0, 179, 180, 7, 7, 0, 0, 180,
38, 1, 0, 0, 0, 181, 182, 9, 0, 0, 0, 182, 40, 1, 0, 0, 0, 23, 0, 74, 79,
85, 88, 92, 97, 99, 105, 109, 114, 116, 118, 124, 126, 134, 136, 140, 146,
165, 167, 169, 175, 1, 6, 0, 0,
}
deserializer := antlr.NewATNDeserializer(nil)
staticData.atn = deserializer.Deserialize(staticData.serializedATN)
atn := staticData.atn
staticData.decisionToDFA = make([]*antlr.DFA, len(atn.DecisionToState))
decisionToDFA := staticData.decisionToDFA
for index, state := range atn.DecisionToState {
decisionToDFA[index] = antlr.NewDFA(state, index)
}
}
// AggregationExpressionLexerInit initializes any static state used to implement AggregationExpressionLexer. By default the
// static state used to implement the lexer is lazily initialized during the first call to
// NewAggregationExpressionLexer(). You can call this function if you wish to initialize the static state ahead
// of time.
func AggregationExpressionLexerInit() {
staticData := &AggregationExpressionLexerLexerStaticData
staticData.once.Do(aggregationexpressionlexerLexerInit)
}
// NewAggregationExpressionLexer produces a new lexer instance for the optional input antlr.CharStream.
func NewAggregationExpressionLexer(input antlr.CharStream) *AggregationExpressionLexer {
AggregationExpressionLexerInit()
l := new(AggregationExpressionLexer)
l.BaseLexer = antlr.NewBaseLexer(input)
staticData := &AggregationExpressionLexerLexerStaticData
l.Interpreter = antlr.NewLexerATNSimulator(l, staticData.atn, staticData.decisionToDFA, staticData.PredictionContextCache)
l.channelNames = staticData.ChannelNames
l.modeNames = staticData.ModeNames
l.RuleNames = staticData.RuleNames
l.LiteralNames = staticData.LiteralNames
l.SymbolicNames = staticData.SymbolicNames
l.GrammarFileName = "AggregationExpression.g4"
// TODO: l.EOF = antlr.TokenEOF
return l
}
// AggregationExpressionLexer tokens.
const (
AggregationExpressionLexerLPAREN = 1
AggregationExpressionLexerRPAREN = 2
AggregationExpressionLexerLBRACK = 3
AggregationExpressionLexerRBRACK = 4
AggregationExpressionLexerCOMMA = 5
AggregationExpressionLexerPLUS = 6
AggregationExpressionLexerMINUS = 7
AggregationExpressionLexerSTAR = 8
AggregationExpressionLexerSLASH = 9
AggregationExpressionLexerPERCENT = 10
AggregationExpressionLexerOPERATOR = 11
AggregationExpressionLexerNUMBER = 12
AggregationExpressionLexerQUOTED_TEXT = 13
AggregationExpressionLexerKEY = 14
AggregationExpressionLexerWS = 15
AggregationExpressionLexerOTHER = 16
)

View File

@@ -1,76 +0,0 @@
// Code generated from grammar/AggregationExpression.g4 by ANTLR 4.13.2. DO NOT EDIT.
package parser // AggregationExpression
import "github.com/antlr4-go/antlr/v4"
// AggregationExpressionListener is a complete listener for a parse tree produced by AggregationExpressionParser.
type AggregationExpressionListener interface {
antlr.ParseTreeListener
// EnterQuery is called when entering the query production.
EnterQuery(c *QueryContext)
// EnterParenthesizedExpression is called when entering the parenthesizedExpression production.
EnterParenthesizedExpression(c *ParenthesizedExpressionContext)
// EnterAdditiveExpression is called when entering the additiveExpression production.
EnterAdditiveExpression(c *AdditiveExpressionContext)
// EnterNumberExpression is called when entering the numberExpression production.
EnterNumberExpression(c *NumberExpressionContext)
// EnterMultiplicativeExpression is called when entering the multiplicativeExpression production.
EnterMultiplicativeExpression(c *MultiplicativeExpressionContext)
// EnterUnaryExpression is called when entering the unaryExpression production.
EnterUnaryExpression(c *UnaryExpressionContext)
// EnterFunctionExpression is called when entering the functionExpression production.
EnterFunctionExpression(c *FunctionExpressionContext)
// EnterFunctionCall is called when entering the functionCall production.
EnterFunctionCall(c *FunctionCallContext)
// EnterArgumentList is called when entering the argumentList production.
EnterArgumentList(c *ArgumentListContext)
// EnterArgument is called when entering the argument production.
EnterArgument(c *ArgumentContext)
// EnterArgumentToken is called when entering the argumentToken production.
EnterArgumentToken(c *ArgumentTokenContext)
// ExitQuery is called when exiting the query production.
ExitQuery(c *QueryContext)
// ExitParenthesizedExpression is called when exiting the parenthesizedExpression production.
ExitParenthesizedExpression(c *ParenthesizedExpressionContext)
// ExitAdditiveExpression is called when exiting the additiveExpression production.
ExitAdditiveExpression(c *AdditiveExpressionContext)
// ExitNumberExpression is called when exiting the numberExpression production.
ExitNumberExpression(c *NumberExpressionContext)
// ExitMultiplicativeExpression is called when exiting the multiplicativeExpression production.
ExitMultiplicativeExpression(c *MultiplicativeExpressionContext)
// ExitUnaryExpression is called when exiting the unaryExpression production.
ExitUnaryExpression(c *UnaryExpressionContext)
// ExitFunctionExpression is called when exiting the functionExpression production.
ExitFunctionExpression(c *FunctionExpressionContext)
// ExitFunctionCall is called when exiting the functionCall production.
ExitFunctionCall(c *FunctionCallContext)
// ExitArgumentList is called when exiting the argumentList production.
ExitArgumentList(c *ArgumentListContext)
// ExitArgument is called when exiting the argument production.
ExitArgument(c *ArgumentContext)
// ExitArgumentToken is called when exiting the argumentToken production.
ExitArgumentToken(c *ArgumentTokenContext)
}

View File

@@ -1,43 +0,0 @@
// Code generated from grammar/AggregationExpression.g4 by ANTLR 4.13.2. DO NOT EDIT.
package parser // AggregationExpression
import "github.com/antlr4-go/antlr/v4"
// A complete Visitor for a parse tree produced by AggregationExpressionParser.
type AggregationExpressionVisitor interface {
antlr.ParseTreeVisitor
// Visit a parse tree produced by AggregationExpressionParser#query.
VisitQuery(ctx *QueryContext) interface{}
// Visit a parse tree produced by AggregationExpressionParser#parenthesizedExpression.
VisitParenthesizedExpression(ctx *ParenthesizedExpressionContext) interface{}
// Visit a parse tree produced by AggregationExpressionParser#additiveExpression.
VisitAdditiveExpression(ctx *AdditiveExpressionContext) interface{}
// Visit a parse tree produced by AggregationExpressionParser#numberExpression.
VisitNumberExpression(ctx *NumberExpressionContext) interface{}
// Visit a parse tree produced by AggregationExpressionParser#multiplicativeExpression.
VisitMultiplicativeExpression(ctx *MultiplicativeExpressionContext) interface{}
// Visit a parse tree produced by AggregationExpressionParser#unaryExpression.
VisitUnaryExpression(ctx *UnaryExpressionContext) interface{}
// Visit a parse tree produced by AggregationExpressionParser#functionExpression.
VisitFunctionExpression(ctx *FunctionExpressionContext) interface{}
// Visit a parse tree produced by AggregationExpressionParser#functionCall.
VisitFunctionCall(ctx *FunctionCallContext) interface{}
// Visit a parse tree produced by AggregationExpressionParser#argumentList.
VisitArgumentList(ctx *ArgumentListContext) interface{}
// Visit a parse tree produced by AggregationExpressionParser#argument.
VisitArgument(ctx *ArgumentContext) interface{}
// Visit a parse tree produced by AggregationExpressionParser#argumentToken.
VisitArgumentToken(ctx *ArgumentTokenContext) interface{}
}

View File

@@ -1,101 +0,0 @@
package querybuilder
import (
"strings"
"github.com/SigNoz/signoz/pkg/errors"
grammar "github.com/SigNoz/signoz/pkg/parser/aggregationexpression/grammar"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/antlr4-go/antlr/v4"
)
// AggregationKeySelectors returns the keys an aggregation expression reads: its
// field arguments and the keys in a combinator's predicate. An expression the
// grammar rejects falls back to every key token in it.
func AggregationKeySelectors(expression string) []*telemetrytypes.FieldKeySelector {
calls, err := parseAggregationExpression(expression)
if err != nil {
return QueryStringToKeysSelectors(expression)
}
var selectors []*telemetrytypes.FieldKeySelector
for _, args := range calls {
for _, arg := range args {
selectors = append(selectors, QueryStringToKeysSelectors(arg)...)
}
}
return selectors
}
// parseAggregationExpression parses an aggregation expression into the
// arguments of each function call, as written. It checks each call as the
// rewriter does: a known function and, for one that requires arguments, an
// argument count in range. Only the first of comma-separated expressions is
// read, since it is the only one the rewriter renders.
func parseAggregationExpression(expression string) ([][]string, error) {
input := antlr.NewInputStream(expression)
lexer := grammar.NewAggregationExpressionLexer(input)
lexerErrors := NewErrorListener()
lexer.RemoveErrorListeners()
lexer.AddErrorListener(lexerErrors)
parser := grammar.NewAggregationExpressionParser(antlr.NewCommonTokenStream(lexer, antlr.TokenDefaultChannel))
parserErrors := NewErrorListener()
parser.RemoveErrorListeners()
parser.AddErrorListener(parserErrors)
query := parser.Query()
if syntaxErrors := append(lexerErrors.SyntaxErrors, parserErrors.SyntaxErrors...); len(syntaxErrors) > 0 {
messages := make([]string, 0, len(syntaxErrors))
for _, syntaxError := range syntaxErrors {
messages = append(messages, syntaxError.Error())
}
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "syntax error in aggregation expression %q", expression).
WithAdditional(messages...)
}
var calls [][]string
for _, functionCall := range collectFunctionCalls(query.Expression(0), nil) {
name := strings.ToLower(functionCall.KEY().GetText())
function, ok := AggreFuncMap[valuer.NewString(name)]
if !ok {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unrecognized function: %s", name)
}
var args []string
if argumentList := functionCall.ArgumentList(); argumentList != nil {
for _, argument := range argumentList.AllArgument() {
args = append(args, input.GetText(argument.GetStart().GetStart(), argument.GetStop().GetStop()))
}
}
if function.RequireArgs && (len(args) < function.MinArgs || len(args) > function.MaxArgs) {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid number of arguments for %q: %d", name, len(args))
}
calls = append(calls, args)
}
return calls, nil
}
func collectFunctionCalls(expression grammar.IExpressionContext, calls []grammar.IFunctionCallContext) []grammar.IFunctionCallContext {
switch e := expression.(type) {
case *grammar.FunctionExpressionContext:
calls = append(calls, e.FunctionCall())
case *grammar.UnaryExpressionContext:
calls = collectFunctionCalls(e.Expression(), calls)
case *grammar.ParenthesizedExpressionContext:
calls = collectFunctionCalls(e.Expression(), calls)
case *grammar.AdditiveExpressionContext:
for _, operand := range e.AllExpression() {
calls = collectFunctionCalls(operand, calls)
}
case *grammar.MultiplicativeExpressionContext:
for _, operand := range e.AllExpression() {
calls = collectFunctionCalls(operand, calls)
}
}
return calls
}

View File

@@ -57,10 +57,6 @@ func (r *aggExprRewriter) Rewrite(
keys map[string][]*telemetrytypes.TelemetryFieldKey,
) (string, []any, error) {
if _, err := parseAggregationExpression(expr); err != nil {
r.logger.WarnContext(ctx, "aggregation expression failed grammar validation", slog.String("expression", expr), errors.Attr(err))
}
wrapped := fmt.Sprintf("SELECT %s", expr)
p := chparser.NewParser(wrapped)
stmts, err := p.ParseStmts()

View File

@@ -164,7 +164,7 @@ func NewModules(
CloudIntegration: cloudIntegrationModule,
TraceDetail: impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore, telemetryMetadataStore, schemaMetadataStore, fl), providerSettings, config.TraceDetail),
SpanMapper: spanMapper,
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore), querier),
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore), querier, telemetryMetadataStore, providerSettings),
Tag: tagModule,
}
}

View File

@@ -572,6 +572,7 @@ func New(
cloudIntegrationModule,
modules.LogsPipeline,
modules.InfraMonitoring,
modules.LLMPricingRule,
querier,
authz,
}

View File

@@ -128,7 +128,7 @@ func getKeySelectors(query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]) []
for idx := range query.Aggregations {
aggExpr := query.Aggregations[idx]
selectors := querybuilder.AggregationKeySelectors(aggExpr.Expression)
selectors := querybuilder.QueryStringToKeysSelectors(aggExpr.Expression)
keySelectors = append(keySelectors, selectors...)
}

View File

@@ -179,7 +179,7 @@ func getKeySelectors(query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation], bo
for idx := range query.Aggregations {
aggExpr := query.Aggregations[idx]
selectors := querybuilder.AggregationKeySelectors(aggExpr.Expression)
selectors := querybuilder.QueryStringToKeysSelectors(aggExpr.Expression)
keySelectors = append(keySelectors, selectors...)
}

View File

@@ -192,7 +192,7 @@ func getKeySelectors(query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation])
for idx := range query.Aggregations {
aggExpr := query.Aggregations[idx]
selectors := querybuilder.AggregationKeySelectors(aggExpr.Expression)
selectors := querybuilder.QueryStringToKeysSelectors(aggExpr.Expression)
keySelectors = append(keySelectors, selectors...)
}

View File

@@ -582,7 +582,7 @@ func (b *traceOperatorCTEBuilder) getKeySelectors() []*telemetrytypes.FieldKeySe
var keySelectors []*telemetrytypes.FieldKeySelector
for _, agg := range b.operator.Aggregations {
selectors := querybuilder.AggregationKeySelectors(agg.Expression)
selectors := querybuilder.QueryStringToKeysSelectors(agg.Expression)
keySelectors = append(keySelectors, selectors...)
}

View File

@@ -20,6 +20,9 @@ var (
ErrCodeUnknownIdentifier = errors.MustNewCode("unknown_identifier")
ErrCodeIllegalArgument = errors.MustNewCode("illegal_argument")
ErrCodeDistributedSubqueryDenied = errors.MustNewCode("distributed_subquery_denied")
ErrCodeDecimalOverflow = errors.MustNewCode("decimal_overflow")
ErrCodeQueryCanceled = errors.MustNewCode("query_canceled")
ErrCodeQueryTimeout = errors.MustNewCode("query_timeout")
ErrCodeExecutionFailed = errors.MustNewCode("execution_failed")
@@ -72,6 +75,18 @@ var clickHouseExceptionWrappers = map[chproto.Error]func(cause error, ex *clickh
chproto.ErrTooLessArgumentsForFunction: func(cause error, ex *clickhouse.Exception) error {
return errors.WrapInvalidInputf(cause, ErrCodeIllegalArgument, "too few arguments to function: %s", ex.Message)
},
chproto.ErrBadGet: func(cause error, ex *clickhouse.Exception) error {
return errors.WrapInvalidInputf(cause, ErrCodeIllegalArgument, "parameter of the wrong type: %s", ex.Message).
WithSuggestions("write numeric function parameters in the type the function expects, for example 1.0 rather than 1 where it takes a Float64")
},
chproto.ErrDistributedInJoinSubqueryDenied: func(cause error, ex *clickhouse.Exception) error {
return errors.WrapInvalidInputf(cause, ErrCodeDistributedSubqueryDenied, "IN or JOIN subquery over a distributed table is not allowed: %s", ex.Message).
WithSuggestions("use GLOBAL IN or GLOBAL JOIN for the subquery")
},
chproto.ErrDecimalOverflow: func(cause error, ex *clickhouse.Exception) error {
return errors.WrapInvalidInputf(cause, ErrCodeDecimalOverflow, "decimal overflow: %s", ex.Message).
WithSuggestions("cast the Decimal values to Float64, or to a wider Decimal such as Decimal128, before the arithmetic")
},
}
type provider struct {

View File

@@ -0,0 +1,55 @@
package aiobservabilitytypes
import (
"time"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
const (
// statsLookback reaches into the previous day; the metadata store rounds
// the start down to a day boundary.
statsLookback = 24 * time.Hour
// statsModelValuesLimit caps the model list, which also feeds the
// unmapped model count. One value is enough for the other keys.
statsModelValuesLimit = 100
)
// statsAttributeKeys are the gen_ai span attributes whose recent presence the
// stats reporter tracks under telemetry.traces.<key>.exists.
var statsAttributeKeys = []string{GenAIRequestModel, GenAIToolName, GenAIAgentName}
// StatsFieldValueSelectors look up each tracked attribute's string values
// since the start of the previous day.
func StatsFieldValueSelectors(now time.Time) []*telemetrytypes.FieldValueSelector {
selectors := make([]*telemetrytypes.FieldValueSelector, 0, len(statsAttributeKeys))
for _, key := range statsAttributeKeys {
limit := 1
if key == GenAIRequestModel {
limit = statsModelValuesLimit
}
selectors = append(selectors, &telemetrytypes.FieldValueSelector{
FieldKeySelector: &telemetrytypes.FieldKeySelector{
StartUnixMilli: now.Add(-statsLookback).UnixMilli(),
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeString,
Name: key,
},
Limit: limit,
})
}
return selectors
}
// NewStatsFromFieldValues reports whether each looked-up key had a value. Keys
// absent from values are left out, so a failed lookup never reports false.
func NewStatsFromFieldValues(values map[string]*telemetrytypes.TelemetryFieldValues) map[string]any {
stats := make(map[string]any, len(values))
for _, key := range statsAttributeKeys {
if fieldValues := values[key]; fieldValues != nil {
stats["telemetry.traces."+key+".exists"] = len(fieldValues.StringValues) > 0
}
}
return stats
}

View File

@@ -0,0 +1,73 @@
package aiobservabilitytypes
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
func TestNewStatsFromFieldValues(t *testing.T) {
testCases := []struct {
name string
values map[string]*telemetrytypes.TelemetryFieldValues
want map[string]any
}{
{
name: "NoLookups_NothingReported",
want: map[string]any{},
},
{
name: "EveryKeySeen_AllTrue",
values: map[string]*telemetrytypes.TelemetryFieldValues{
GenAIRequestModel: {StringValues: []string{"gpt-5-nano"}},
GenAIToolName: {StringValues: []string{"search"}},
GenAIAgentName: {StringValues: []string{"planner"}},
},
want: map[string]any{
"telemetry.traces.gen_ai.request.model.exists": true,
"telemetry.traces.gen_ai.tool.name.exists": true,
"telemetry.traces.gen_ai.agent.name.exists": true,
},
},
{
name: "EmptyAndMissingLookups_OnlyLookedUpKeysReported",
values: map[string]*telemetrytypes.TelemetryFieldValues{
GenAIRequestModel: {StringValues: []string{"gpt-5-nano"}},
GenAIToolName: {},
GenAIOperationName: {StringValues: []string{"chat"}},
},
want: map[string]any{
"telemetry.traces.gen_ai.request.model.exists": true,
"telemetry.traces.gen_ai.tool.name.exists": false,
},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.want, NewStatsFromFieldValues(testCase.values))
})
}
}
func TestStatsFieldValueSelectors(t *testing.T) {
now := time.Date(2026, 11, 3, 10, 0, 0, 0, time.UTC)
selectors := StatsFieldValueSelectors(now)
require.Len(t, selectors, 3)
for _, selector := range selectors {
assert.Equal(t, now.Add(-24*time.Hour).UnixMilli(), selector.StartUnixMilli, selector.Name)
assert.Equal(t, telemetrytypes.SignalTraces, selector.Signal, selector.Name)
assert.Equal(t, telemetrytypes.FieldContextAttribute, selector.FieldContext, selector.Name)
assert.Equal(t, telemetrytypes.FieldDataTypeString, selector.FieldDataType, selector.Name)
}
assert.Equal(t, GenAIRequestModel, selectors[0].Name)
assert.Equal(t, 100, selectors[0].Limit)
assert.Equal(t, 1, selectors[1].Limit)
assert.Equal(t, 1, selectors[2].Limit)
}

View File

@@ -1,11 +1,14 @@
package cloudintegrationtypes
import (
"strings"
"text/template"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
)
type CloudProviderType struct{ valuer.String }
const foundryScriptURL = "https://signoz.io/foundry.sh"
var (
// cloud providers.
@@ -14,8 +17,16 @@ var (
CloudProviderTypeGCP = CloudProviderType{valuer.NewString("gcp")}
ErrCodeCloudProviderInvalidInput = errors.MustNewCode("cloud_integration_invalid_cloud_provider")
foundryTemplateFuncs = template.FuncMap{
"shellQuote": func(value string) string {
return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'"
},
}
)
type CloudProviderType struct{ valuer.String }
func NewCloudProvider(provider string) (CloudProviderType, error) {
switch provider {
case CloudProviderTypeAWS.StringValue():
@@ -28,3 +39,11 @@ func NewCloudProvider(provider string) (CloudProviderType, error) {
return CloudProviderType{}, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider)
}
}
type foundryTemplateData struct {
FoundryScriptURL string
Region string
IntegrationAccountID string
AgentVersion string
Credentials *Credentials
}

View File

@@ -1,14 +1,13 @@
package cloudintegrationtypes
import (
"bytes"
"text/template"
"github.com/SigNoz/signoz/pkg/valuer"
)
var (
CloudFormationQuickCreateBaseURL = valuer.NewString("https://%s.console.aws.amazon.com/cloudformation/home")
AgentCloudFormationTemplateS3Path = valuer.NewString("https://signoz-integrations.s3.us-east-1.amazonaws.com/aws-quickcreate-template-%s.json")
AgentCloudFormationBaseStackName = valuer.NewString("signoz-integration")
)
var awsCLITemplate = template.Must(template.New("awsCLI").Funcs(foundryTemplateFuncs).Parse(awsCLITemplateStr()))
type AWSPostableAccountConfig struct {
DeploymentRegion string `json:"deploymentRegion" required:"true"`
@@ -16,7 +15,7 @@ type AWSPostableAccountConfig struct {
}
type AWSConnectionArtifact struct {
ConnectionURL string `json:"connectionUrl" required:"true"`
CLICommand string `json:"cliCommand" required:"true"`
}
type AWSAccountConfig struct {
@@ -105,10 +104,23 @@ type AWSServiceMetricsConfig struct {
Enabled bool `json:"enabled"`
}
func NewAWSConnectionArtifact(connectionURL string) *AWSConnectionArtifact {
return &AWSConnectionArtifact{
ConnectionURL: connectionURL,
func NewAWSConnectionArtifact(integrationAccountID valuer.UUID, agentVersion string, creds *Credentials, deploymentRegion string) (*AWSConnectionArtifact, error) {
data := foundryTemplateData{
FoundryScriptURL: foundryScriptURL,
Region: deploymentRegion,
IntegrationAccountID: integrationAccountID.StringValue(),
AgentVersion: agentVersion,
Credentials: creds,
}
var command bytes.Buffer
if err := awsCLITemplate.Execute(&command, data); err != nil {
return nil, err
}
return &AWSConnectionArtifact{
CLICommand: command.String(),
}, nil
}
func NewAWSIntegrationConfig(enabledRegions []string, telemetryCollectionStrategy *AWSTelemetryCollectionStrategy) *AWSIntegrationConfig {
@@ -167,3 +179,14 @@ func awsOlderIntegrationConfig(cfg *ProviderIntegrationConfig) *IntegrationConfi
older.Telemetry = oldTelemetry
return older
}
func awsCLITemplateStr() string {
return `curl -fsSL {{.FoundryScriptURL | shellQuote}} | bash -s -- cloudintegration aws cloudformation \
--region {{.Region | shellQuote}} \
--integration-account-id {{.IntegrationAccountID | shellQuote}} \
--signoz-api-url {{.Credentials.SigNozAPIURL | shellQuote}} \
--signoz-api-key {{.Credentials.SigNozAPIKey | shellQuote}} \
--ingestion-url {{.Credentials.IngestionURL | shellQuote}} \
--ingestion-key {{.Credentials.IngestionKey | shellQuote}} \
--agent-version {{.AgentVersion | shellQuote}}`
}

View File

@@ -2,37 +2,12 @@ package cloudintegrationtypes
import (
"bytes"
"fmt"
"text/template"
"github.com/SigNoz/signoz/pkg/valuer"
)
var (
AgentArmTemplateStorePath = "https://signoz-integrations.s3.us-east-1.amazonaws.com/azure-arm-template-%s.json"
AgentDeploymentStackName = "signoz-integration"
// Default values for fixed ARM template parameters.
armDefaultRgName = "signoz-integration-rg"
armDefaultContainerEnvName = "signoz-integration-agent-env"
armDefaultDeploymentEnv = "production"
// ARM template parameter key names used in both CLI and PowerShell deployment commands.
armParamLocation = "location"
armParamSignozAPIKey = "signozApiKey"
armParamSignozAPIUrl = "signozApiUrl"
armParamSignozIngestionURL = "signozIngestionUrl"
armParamSignozIngestionKey = "signozIngestionKey"
armParamAccountID = "signozIntegrationAccountId"
armParamAgentVersion = "signozIntegrationAgentVersion"
armParamRgName = "rgName"
armParamContainerEnvName = "containerEnvName"
armParamDeploymentEnv = "deploymentEnv"
// command templates.
azureCLITemplate = template.Must(template.New("azureCLI").Parse(azureCLITemplateStr()))
azurePowerShellTemplate = template.Must(template.New("azurePS").Parse(azurePowerShellTemplateStr()))
)
var azureCLITemplate = template.Must(template.New("azureCLI").Funcs(foundryTemplateFuncs).Parse(azureCLITemplateStr()))
type AzureAccountConfig struct {
DeploymentRegion string `json:"deploymentRegion" required:"true"`
@@ -46,8 +21,7 @@ type UpdatableAzureAccountConfig struct {
type AzurePostableAccountConfig = AzureAccountConfig
type AzureConnectionArtifact struct {
CLICommand string `json:"cliCommand" required:"true"`
CloudPowerShellCommand string `json:"cloudPowerShellCommand" required:"true"`
CLICommand string `json:"cliCommand" required:"true"`
}
type AzureServiceConfig struct {
@@ -85,36 +59,6 @@ type AzureIntegrationConfig struct {
TelemetryCollectionStrategy []*AzureTelemetryCollectionStrategy `json:"telemetryCollectionStrategy" required:"true" nullable:"false"`
}
// azureTemplateData is the data struct passed to both command templates.
// All fields are exported so text/template can access them.
type azureTemplateData struct {
// Deploy parameter values.
TemplateURL string
Location string
SignozAPIKey string
SignozAPIUrl string
SignozIngestionURL string
SignozIngestionKey string
AccountID string
AgentVersion string
// ARM parameter key names (from package-level vars).
StackName string
ParamLocation string
ParamSignozAPIKey string
ParamSignozAPIUrl string
ParamSignozIngestionURL string
ParamSignozIngestionKey string
ParamAccountID string
ParamAgentVersion string
ParamRgName string
ParamContainerEnvName string
ParamDeploymentEnv string
// Fixed default values.
DefaultRgName string
DefaultContainerEnvName string
DefaultDeploymentEnv string
}
func NewAzureIntegrationConfig(
deploymentRegion string,
resourceGroups []string,
@@ -127,104 +71,32 @@ func NewAzureIntegrationConfig(
}
}
func NewAzureConnectionArtifact(
accountID valuer.UUID,
agentVersion string,
creds *Credentials,
cfg *AzurePostableAccountConfig,
) (*AzureConnectionArtifact, error) {
data := azureTemplateData{
TemplateURL: fmt.Sprintf(AgentArmTemplateStorePath, agentVersion),
Location: cfg.DeploymentRegion,
SignozAPIKey: creds.SigNozAPIKey,
SignozAPIUrl: creds.SigNozAPIURL,
SignozIngestionURL: creds.IngestionURL,
SignozIngestionKey: creds.IngestionKey,
AccountID: accountID.StringValue(),
AgentVersion: agentVersion,
StackName: AgentDeploymentStackName,
ParamLocation: armParamLocation,
ParamSignozAPIKey: armParamSignozAPIKey,
ParamSignozAPIUrl: armParamSignozAPIUrl,
ParamSignozIngestionURL: armParamSignozIngestionURL,
ParamSignozIngestionKey: armParamSignozIngestionKey,
ParamAccountID: armParamAccountID,
ParamAgentVersion: armParamAgentVersion,
ParamRgName: armParamRgName,
ParamContainerEnvName: armParamContainerEnvName,
ParamDeploymentEnv: armParamDeploymentEnv,
DefaultRgName: armDefaultRgName,
DefaultContainerEnvName: armDefaultContainerEnvName,
DefaultDeploymentEnv: armDefaultDeploymentEnv,
func NewAzureConnectionArtifact(integrationAccountID valuer.UUID, agentVersion string, creds *Credentials, cfg *AzurePostableAccountConfig) (*AzureConnectionArtifact, error) {
data := foundryTemplateData{
FoundryScriptURL: foundryScriptURL,
Region: cfg.DeploymentRegion,
IntegrationAccountID: integrationAccountID.StringValue(),
AgentVersion: agentVersion,
Credentials: creds,
}
cliCommand, err := newAzureConnectionCLICommand(data)
if err != nil {
var command bytes.Buffer
if err := azureCLITemplate.Execute(&command, data); err != nil {
return nil, err
}
psCommand, err := newAzureConnectionPowerShellCommand(data)
if err != nil {
return nil, err
}
return &AzureConnectionArtifact{
CLICommand: cliCommand,
CloudPowerShellCommand: psCommand,
CLICommand: command.String(),
}, nil
}
func newAzureConnectionCLICommand(data azureTemplateData) (string, error) {
var buf bytes.Buffer
err := azureCLITemplate.Execute(&buf, data)
if err != nil {
return "", err
}
return buf.String(), nil
}
func newAzureConnectionPowerShellCommand(data azureTemplateData) (string, error) {
var buf bytes.Buffer
err := azurePowerShellTemplate.Execute(&buf, data)
if err != nil {
return "", err
}
return buf.String(), nil
}
func azureCLITemplateStr() string {
return `az stack sub create \
--name {{.StackName}} \
--location {{.Location}} \
--template-uri {{.TemplateURL}} \
--parameters \
{{.ParamLocation}}='{{.Location}}' \
{{.ParamSignozAPIKey}}='{{.SignozAPIKey}}' \
{{.ParamSignozAPIUrl}}='{{.SignozAPIUrl}}' \
{{.ParamSignozIngestionURL}}='{{.SignozIngestionURL}}' \
{{.ParamSignozIngestionKey}}='{{.SignozIngestionKey}}' \
{{.ParamAccountID}}='{{.AccountID}}' \
{{.ParamAgentVersion}}='{{.AgentVersion}}' \
--action-on-unmanage deleteAll \
--deny-settings-mode denyDelete`
}
func azurePowerShellTemplateStr() string {
return "New-AzSubscriptionDeploymentStack `\n" +
" -Name \"{{.StackName}}\" `\n" +
" -Location \"{{.Location}}\" `\n" +
" -TemplateUri \"{{.TemplateURL}}\" `\n" +
" -TemplateParameterObject @{\n" +
" {{.ParamLocation}} = \"{{.Location}}\"\n" +
" {{.ParamSignozAPIKey}} = \"{{.SignozAPIKey}}\"\n" +
" {{.ParamSignozAPIUrl}} = \"{{.SignozAPIUrl}}\"\n" +
" {{.ParamSignozIngestionURL}} = \"{{.SignozIngestionURL}}\"\n" +
" {{.ParamSignozIngestionKey}} = \"{{.SignozIngestionKey}}\"\n" +
" {{.ParamAccountID}} = \"{{.AccountID}}\"\n" +
" {{.ParamAgentVersion}} = \"{{.AgentVersion}}\"\n" +
" {{.ParamRgName}} = \"{{.DefaultRgName}}\"\n" +
" {{.ParamContainerEnvName}} = \"{{.DefaultContainerEnvName}}\"\n" +
" {{.ParamDeploymentEnv}} = \"{{.DefaultDeploymentEnv}}\"\n" +
" } `\n" +
" -ActionOnUnmanage \"deleteAll\" `\n" +
" -DenySettingsMode \"denyDelete\""
return `curl -fsSL {{.FoundryScriptURL | shellQuote}} | bash -s -- cloudintegration azure bicep \
--region {{.Region | shellQuote}} \
--integration-account-id {{.IntegrationAccountID | shellQuote}} \
--signoz-api-url {{.Credentials.SigNozAPIURL | shellQuote}} \
--signoz-api-key {{.Credentials.SigNozAPIKey | shellQuote}} \
--ingestion-url {{.Credentials.IngestionURL | shellQuote}} \
--ingestion-key {{.Credentials.IngestionKey | shellQuote}} \
--agent-version {{.AgentVersion | shellQuote}}`
}

View File

@@ -1,142 +0,0 @@
// file is generated by AI
package cloudintegrationtypes
import (
"fmt"
"strings"
"testing"
"github.com/SigNoz/signoz/pkg/valuer"
)
var testTemplateData = azureTemplateData{
TemplateURL: fmt.Sprintf(AgentArmTemplateStorePath, "v0.1.0"),
Location: "eastus",
SignozAPIKey: "test-api-key",
SignozAPIUrl: "https://signoz.example.com",
SignozIngestionURL: "https://ingest.example.com",
SignozIngestionKey: "test-ingest-key",
AccountID: "acct-123",
AgentVersion: "v0.1.0",
StackName: AgentDeploymentStackName,
ParamLocation: armParamLocation,
ParamSignozAPIKey: armParamSignozAPIKey,
ParamSignozAPIUrl: armParamSignozAPIUrl,
ParamSignozIngestionURL: armParamSignozIngestionURL,
ParamSignozIngestionKey: armParamSignozIngestionKey,
ParamAccountID: armParamAccountID,
ParamAgentVersion: armParamAgentVersion,
ParamRgName: armParamRgName,
ParamContainerEnvName: armParamContainerEnvName,
ParamDeploymentEnv: armParamDeploymentEnv,
DefaultRgName: armDefaultRgName,
DefaultContainerEnvName: armDefaultContainerEnvName,
DefaultDeploymentEnv: armDefaultDeploymentEnv,
}
func TestNewAzureConnectionCLICommand(t *testing.T) {
cmd, err := newAzureConnectionCLICommand(testTemplateData)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
mustContain := []string{
"az stack sub create",
fmt.Sprintf("--name %s", AgentDeploymentStackName),
fmt.Sprintf("--location %s", testTemplateData.Location),
fmt.Sprintf("--template-uri %s", testTemplateData.TemplateURL),
fmt.Sprintf("%s='%s'", armParamLocation, testTemplateData.Location),
fmt.Sprintf("%s='%s'", armParamSignozAPIKey, testTemplateData.SignozAPIKey),
fmt.Sprintf("%s='%s'", armParamSignozAPIUrl, testTemplateData.SignozAPIUrl),
fmt.Sprintf("%s='%s'", armParamSignozIngestionURL, testTemplateData.SignozIngestionURL),
fmt.Sprintf("%s='%s'", armParamSignozIngestionKey, testTemplateData.SignozIngestionKey),
fmt.Sprintf("%s='%s'", armParamAccountID, testTemplateData.AccountID),
fmt.Sprintf("%s='%s'", armParamAgentVersion, testTemplateData.AgentVersion),
"--action-on-unmanage deleteAll",
"--deny-settings-mode denyDelete",
}
for _, fragment := range mustContain {
if !strings.Contains(cmd, fragment) {
t.Errorf("CLI command missing %q\ngot:\n%s", fragment, cmd)
}
}
// Lines must be joined with the bash line-continuation separator.
if !strings.Contains(cmd, " \\\n") {
t.Errorf("CLI command missing line-continuation separator ' \\\\\\n'\ngot:\n%s", cmd)
}
}
func TestNewAzureConnectionPowerShellCommand(t *testing.T) {
cmd, err := newAzureConnectionPowerShellCommand(testTemplateData)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
mustContain := []string{
"New-AzSubscriptionDeploymentStack",
fmt.Sprintf("-Name \"%s\"", AgentDeploymentStackName),
fmt.Sprintf("-Location \"%s\"", testTemplateData.Location),
fmt.Sprintf("-TemplateUri \"%s\"", testTemplateData.TemplateURL),
armParamLocation,
armParamSignozAPIKey,
armParamSignozAPIUrl,
armParamSignozIngestionURL,
armParamSignozIngestionKey,
armParamAccountID,
armParamAgentVersion,
armParamRgName,
armParamContainerEnvName,
armParamDeploymentEnv,
armDefaultRgName,
armDefaultContainerEnvName,
armDefaultDeploymentEnv,
"-ActionOnUnmanage \"deleteAll\"",
"-DenySettingsMode \"denyDelete\"",
}
for _, fragment := range mustContain {
if !strings.Contains(cmd, fragment) {
t.Errorf("PowerShell command missing %q\ngot:\n%s", fragment, cmd)
}
}
// Final command must not end with a line-continuation backtick.
if strings.HasSuffix(strings.TrimSpace(cmd), "`") {
t.Errorf("PowerShell command must not end with a backtick\ngot:\n%s", cmd)
}
}
func TestNewAzureConnectionArtifact(t *testing.T) {
accountID := valuer.GenerateUUID()
agentVersion := "v0.1.0"
creds := &Credentials{
SigNozAPIURL: "https://signoz.example.com",
SigNozAPIKey: "test-api-key",
IngestionURL: "https://ingest.example.com",
IngestionKey: "test-ingest-key",
}
cfg := &AzurePostableAccountConfig{
DeploymentRegion: "eastus",
ResourceGroups: []string{"rg1"},
}
artifact, err := NewAzureConnectionArtifact(accountID, agentVersion, creds, cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if artifact.CLICommand == "" {
t.Error("CLICommand must not be empty")
}
if artifact.CloudPowerShellCommand == "" {
t.Error("CloudPowerShellCommand must not be empty")
}
if !strings.Contains(artifact.CLICommand, accountID.StringValue()) {
t.Errorf("CLICommand must contain accountID %q", accountID.StringValue())
}
if !strings.Contains(artifact.CloudPowerShellCommand, accountID.StringValue()) {
t.Errorf("CloudPowerShellCommand must contain accountID %q", accountID.StringValue())
}
}

View File

@@ -14,13 +14,7 @@ var (
GCPConnectionMethodManual = GCPConnectionMethod{valuer.NewString("manual")}
GCPConnectionMethodAgent = GCPConnectionMethod{valuer.NewString("agent")}
foundryScriptURL = "https://signoz.io/foundry.sh"
gcpCLITemplate = template.Must(template.New("gcpCLI").Funcs(template.FuncMap{
"shellQuote": func(value string) string {
return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'"
},
}).Parse(gcpCLITemplateStr()))
gcpCLITemplate = template.Must(template.New("gcpCLI").Funcs(foundryTemplateFuncs).Parse(gcpCLITemplateStr()))
)
type GCPConnectionMethod struct{ valuer.String }
@@ -112,12 +106,14 @@ func (config *GCPAccountConfig) ErrIfInvalid() error {
func NewGCPConnectionArtifact(integrationAccountID valuer.UUID, agentVersion string, creds *Credentials, cfg *GCPAccountConfig) (*GCPConnectionArtifact, error) {
data := gcpTemplateData{
FoundryScriptURL: foundryScriptURL,
ProjectID: cfg.DeploymentProjectID,
Region: cfg.DeploymentRegion,
IntegrationAccountID: integrationAccountID.StringValue(),
AgentVersion: agentVersion,
Credentials: creds,
foundryTemplateData: foundryTemplateData{
FoundryScriptURL: foundryScriptURL,
Region: cfg.DeploymentRegion,
IntegrationAccountID: integrationAccountID.StringValue(),
AgentVersion: agentVersion,
Credentials: creds,
},
ProjectID: cfg.DeploymentProjectID,
}
var command bytes.Buffer
@@ -158,12 +154,8 @@ func NewGCPIntegrationConfig(config *GCPAccountConfig, metricNames, sinkFilters
}
type gcpTemplateData struct {
FoundryScriptURL string
ProjectID string
Region string
IntegrationAccountID string
AgentVersion string
Credentials *Credentials
foundryTemplateData
ProjectID string
}
func gcpCLITemplateStr() string {

View File

@@ -1,6 +1,7 @@
package dashboardtypes
import (
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
@@ -10,6 +11,7 @@ const (
statKeyPanelTracesCount = "dashboard.panels.traces.count"
statKeyPanelMetricsCount = "dashboard.panels.metrics.count"
statKeyPanelLogsCount = "dashboard.panels.logs.count"
statKeyPanelAICount = "dashboard.panels.ai.count"
)
// panelSignalStatKeys maps a builder query's signal to the stat it contributes
@@ -22,14 +24,17 @@ var panelSignalStatKeys = map[telemetrytypes.Signal]string{
}
// NewStatsFromStorableDashboards reports the stats of stored dashboards. Rows that
// do not decode as v2 contribute to dashboard.count only.
// do not decode as v2 contribute to dashboard.count only. System dashboards are
// provisioned into every org, so they are left out of every count.
func NewStatsFromStorableDashboards(dashboards []*StorableDashboard) map[string]any {
stats := newPanelStats()
stats := newStats()
for _, dashboard := range dashboards {
if dashboard == nil {
if dashboard == nil || dashboard.Source == SourceSystem {
continue
}
incrementStat(stats, statKeyDashboardCount)
dashboardV2, err := dashboard.ToDashboardV2(nil)
if err != nil {
continue
@@ -37,7 +42,6 @@ func NewStatsFromStorableDashboards(dashboards []*StorableDashboard) map[string]
addPanelStats(&dashboardV2.Spec, stats)
}
stats[statKeyDashboardCount] = int64(len(dashboards))
return stats
}
@@ -45,24 +49,26 @@ func NewStatsFromStorableDashboards(dashboards []*StorableDashboard) map[string]
// created, straight off the postable spec — the create path has no reason to make
// a storable round-trip just to be counted.
func NewStatsFromPostableDashboardV2(postable PostableDashboardV2) map[string]any {
stats := newPanelStats()
stats := newStats()
stats[statKeyDashboardCount] = int64(1)
addPanelStats(&postable.Spec, stats)
stats[statKeyDashboardCount] = int64(1)
return stats
}
func newPanelStats() map[string]any {
func newStats() map[string]any {
return map[string]any{
statKeyDashboardCount: int64(0),
statKeyPanelCount: int64(0),
statKeyPanelTracesCount: int64(0),
statKeyPanelMetricsCount: int64(0),
statKeyPanelLogsCount: int64(0),
statKeyPanelAICount: int64(0),
}
}
// addPanelStats counts the panels of a v2 spec, and each panel's queries against
// the signal they read.
// the signal they read. AI queries count only towards the AI stat.
func addPanelStats(spec *DashboardSpec, stats map[string]any) {
for _, panel := range spec.Panels {
if panel == nil {
@@ -76,6 +82,10 @@ func addPanelStats(spec *DashboardSpec, stats map[string]any) {
continue
}
for _, envelope := range composite.Queries {
if envelope.Type == qb.QueryTypeBuilderAI {
incrementStat(stats, statKeyPanelAICount)
continue
}
if key, ok := panelSignalStatKeys[envelope.GetSignal()]; ok {
incrementStat(stats, key)
}

View File

@@ -117,20 +117,60 @@ func TestNewStatsFromStorableDashboardsCountsCompositeSubQueries(t *testing.T) {
assert.Equal(t, int64(1), stats[statKeyPanelLogsCount])
}
// An AI builder query is always a traces query, so it counts towards traces.
func TestNewStatsFromStorableDashboardsCountsAIBuilderQueries(t *testing.T) {
aiBuilder := `{
func statsAIBuilderQuery() string {
return `{
"kind": "time_series",
"spec": {"plugin": {"kind": "signoz/AIBuilderQuery", "spec": {"name": "A", "aggregations": [{"expression": "count()"}]}}}
}`
dashboard := newStatsStorableV2(t, `"p1": `+statsPanel(aiBuilder))
}
// An AI builder query reads traces but counts only towards the AI stat.
func TestNewStatsFromStorableDashboardsCountsAIBuilderQueries(t *testing.T) {
dashboard := newStatsStorableV2(t, `"p1": `+statsPanel(statsAIBuilderQuery()))
stats := NewStatsFromStorableDashboards([]*StorableDashboard{dashboard})
assert.Equal(t, int64(1), stats[statKeyPanelCount])
assert.Equal(t, int64(1), stats[statKeyPanelTracesCount])
assert.Equal(t, int64(0), stats[statKeyPanelTracesCount])
assert.Equal(t, int64(0), stats[statKeyPanelMetricsCount])
assert.Equal(t, int64(0), stats[statKeyPanelLogsCount])
assert.Equal(t, int64(1), stats[statKeyPanelAICount])
}
func TestNewStatsFromStorableDashboardsCountsAICompositeSubQueries(t *testing.T) {
composite := `{
"kind": "time_series",
"spec": {"plugin": {"kind": "signoz/CompositeQuery", "spec": {"queries": [
{"type": "builder_ai_query", "spec": {"name": "A", "aggregations": [{"expression": "count()"}]}},
{"type": "builder_query", "spec": ` + statsBuilderQuerySpec("B", "logs") + `}
]}}}
}`
dashboard := newStatsStorableV2(t, `
"p1": `+statsPanel(composite)+`,
"p2": `+statsPanel(statsAIBuilderQuery())+`
`)
stats := NewStatsFromStorableDashboards([]*StorableDashboard{dashboard})
assert.Equal(t, int64(2), stats[statKeyPanelCount])
assert.Equal(t, int64(2), stats[statKeyPanelAICount])
assert.Equal(t, int64(0), stats[statKeyPanelTracesCount])
assert.Equal(t, int64(1), stats[statKeyPanelLogsCount])
}
func TestNewStatsFromStorableDashboardsSkipsSystemDashboards(t *testing.T) {
system := newStatsStorableV2(t, `"p1": `+statsPanel(statsAIBuilderQuery()))
system.Source = SourceSystem
system.Name = SystemDashboardNamePrefix + "overview"
user := newStatsStorableV2(t, `"p1": `+statsPanel(statsBuilderQuery("logs")))
stats := NewStatsFromStorableDashboards([]*StorableDashboard{system, user})
assert.Equal(t, int64(1), stats[statKeyDashboardCount])
assert.Equal(t, int64(1), stats[statKeyPanelCount])
assert.Equal(t, int64(0), stats[statKeyPanelTracesCount])
assert.Equal(t, int64(0), stats[statKeyPanelAICount])
assert.Equal(t, int64(1), stats[statKeyPanelLogsCount])
}
// promql and clickhouse queries carry no signal, so they land in the panel total
@@ -215,6 +255,17 @@ func TestNewStatsFromPostableDashboardV2(t *testing.T) {
assert.Equal(t, int64(1), stats[statKeyPanelLogsCount])
assert.Equal(t, int64(1), stats[statKeyPanelTracesCount])
assert.Equal(t, int64(0), stats[statKeyPanelMetricsCount])
assert.Equal(t, int64(0), stats[statKeyPanelAICount])
}
func TestNewStatsFromPostableDashboardV2CountsAIBuilderQueries(t *testing.T) {
postable := newStatsPostableV2(t, `"p1": `+statsPanel(statsAIBuilderQuery()))
stats := NewStatsFromPostableDashboardV2(postable)
assert.Equal(t, int64(1), stats[statKeyDashboardCount])
assert.Equal(t, int64(1), stats[statKeyPanelCount])
assert.Equal(t, int64(1), stats[statKeyPanelAICount])
}
func TestNewStatsFromPostableDashboardV2WithNoPanels(t *testing.T) {
@@ -233,4 +284,5 @@ func TestNewStatsFromStorableDashboardsWithNoDashboards(t *testing.T) {
assert.Equal(t, int64(0), stats[statKeyPanelTracesCount])
assert.Equal(t, int64(0), stats[statKeyPanelMetricsCount])
assert.Equal(t, int64(0), stats[statKeyPanelLogsCount])
assert.Equal(t, int64(0), stats[statKeyPanelAICount])
}

View File

@@ -277,3 +277,13 @@ func ModelMatchesAnyRule(model string, rules []*LLMPricingRule) bool {
}
return false
}
func UnmappedModels(models []*UnmappedModel, rules []*LLMPricingRule) []*UnmappedModel {
unmapped := make([]*UnmappedModel, 0, len(models))
for _, model := range models {
if !ModelMatchesAnyRule(model.ModelName, rules) {
unmapped = append(unmapped, model)
}
}
return unmapped
}

View File

@@ -0,0 +1,31 @@
package llmpricingruletypes
// NewStatsFromRules reports the rules users added themselves and the synced
// defaults users pinned.
func NewStatsFromRules(rules []*LLMPricingRule) map[string]any {
custom, overrides := int64(0), int64(0)
for _, rule := range rules {
switch {
case rule.SourceID == nil:
custom++
case rule.IsOverride:
overrides++
}
}
return map[string]any{
"llmpricingrule.custom.count": custom,
"llmpricingrule.override.count": overrides,
}
}
func NewStatsFromModels(models []string, rules []*LLMPricingRule) map[string]any {
unmapped := int64(0)
for _, model := range models {
if !ModelMatchesAnyRule(model, rules) {
unmapped++
}
}
return map[string]any{"llmpricingrule.unmapped_model.count": unmapped}
}

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