Compare commits

...

34 Commits

Author SHA1 Message Date
Abhi Kumar
c92ef6079a Merge remote-tracking branch 'origin/main' into test/qb-e2e
# Conflicts:
#	tests/e2e/helpers/alert-forms/shared.ts
#	tests/e2e/helpers/alerts/seeding.ts
2026-11-04 15:15:57 +05:30
Gaurav Tewari
436a13430a feat: add nerve pod as codeowner for AI assistant (#13409)
#### Description
- Adds `@SigNoz/nerve` as code owner for the AI Assistant frontend
(`container/AIAssistant`, `hooks/aiAssistant`, `pages/AIAssistantPage`).

#### Issues closed by this PR

#### Screenshots / Screen Recordings

#### Additional Information
- `@SigNoz/nerve` has no write access to this repo, so GitHub ignores it
as an owner until an org admin grants it. // @therealpandey

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-11-04 09:02:15 +00:00
Aditya Singh
825991864d feat(traces-explorer): open list rows in a span details drawer (#13361)
#### Description

- A row click in the traces explorer list opens the span in a side
drawer. The trace page no longer opens for every span. The drawer sits
behind the `TRACES_SPAN_DRAWER_ENABLED` localStorage flag.
- The drawer reuses the trace details span panel, so the overview,
events and logs tabs match the trace page. The up and down buttons and
arrow keys step through the rows. A click outside the table closes the
drawer.
- The list rows have no trace start and end time. The drawer fetches the
trace summary in the background for them. The % exec time shows when the
summary loads. The logs wait for it and query the trace window ±5 min.
- When the summary fails, the panel falls back to the span. The logs
query the span window ±5 min, and the % exec time is hidden.
- The trace ID now has a copy button instead of a link or a
click-to-copy toast.

#### Issues closed by this PR

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

#### Screen recording



https://github.com/user-attachments/assets/6de21fc0-da64-4068-9f33-ee137b2de1d5

sbshot:
https://claude.ai/artifact/VBp6qqxdkTxR7ZUVXopY2X?sk=jsc4RO2UvZSzUXBJr1Yk6g



#### Additional Information

- The drawer adds one trace summary call per trace. React-query caches
it, so stepping between spans of one trace does not call it again.
- The drawer closes when the open span leaves the list, for example on a
refresh with a relative time or a page change. The infinite scroll PR
reworks the list query.
- Linked spans are matched by span ID.
- `useCloseOnOutsidePress` is a new shared hook. The logs drawer and
saved views will use it next.
- The explorer story moved to `Pages/Traces/Explorer/Overview`, so its
story IDs change.
2026-11-04 06:57:56 +00:00
Gaurav Tewari
d19b9afff4 feat(trace-details): show AI dot on thread tab and log empty thread with AI summary (#13380)
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
- 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
Abhi Kumar
9b976c81b1 test(query-builder): expect a negative limit to drop its sign 2026-11-03 14:51:03 +05:30
Abhi Kumar
cb48993c8d Merge remote-tracking branch 'origin/fix/qb-add-query-signal' into HEAD 2026-11-03 14:51:03 +05:30
Abhi Kumar
07a59a2560 Merge remote-tracking branch 'origin/fix/qb-legacy-having-and-limit' into HEAD 2026-11-03 14:50:51 +05:30
Abhi Kumar
c3e0de9eb0 fix(query-builder): keep limit inputs to digits so the shown value is what's sent 2026-11-03 14:48:59 +05:30
Abhi Kumar
c31cb99273 test(query-builder): split the qb driver into modules 2026-11-03 12:39:52 +05:30
Abhi Kumar
5497146f98 test(query-builder): an added panel-editor query keeps the previous signal 2026-11-03 12:39:52 +05:30
Abhi Kumar
13625708a0 test(query-builder): run the legacy having and negative limit cases 2026-11-03 12:39:52 +05:30
Abhi Kumar
a415902357 test(query-builder): scope selectors to the query block and wait on own requests
Run waits on a request it started; panel-editor structural edits re-run
until the newest request matches, since their draft can lag the DOM.
2026-11-03 12:39:52 +05:30
Abhi Kumar
8d97b8ef74 test(query-builder): cover trace operator, signal switching, query tabs and run/cancel
holdQueryRange ignores a request cancelled while held: there is nothing
left to answer.
2026-11-03 12:39:52 +05:30
Abhi Kumar
9dda0825bd test(query-builder): cover panel-kind add-ons, reduce-to, multi-query and formulas 2026-11-03 12:39:52 +05:30
Abhi Kumar
99f9e634eb test(query-builder): cover group by, having, order, limit and legend add-ons
Negative limit stays fixme: the input accepts it and the backend
rejects it with a 400.
2026-11-03 12:39:52 +05:30
Abhi Kumar
7f1b6bd594 test(query-builder): cover metric-type defaults, aggregations and step interval
Gauge and histogram metrics are seeded per worker: the golden dataset
only has sums.
2026-11-03 12:39:52 +05:30
Abhi Kumar
98e6975358 test(query-builder): cover explorer-to-dashboard and alert trace operator round trips
pickChannelByName re-clicks while the select is still closed: a condition
re-render could close it as the click opened it.
2026-11-03 12:39:52 +05:30
Abhi Kumar
5b646396a9 test(query-builder): cover legacy conversion, history, IN lists and quick filters
Legacy having stays fixme: convertHavingToExpression keeps the stored
COUNT() casing and the v5 backend rejects it with a 400.
2026-11-03 12:39:52 +05:30
Abhi Kumar
4ced3b0c13 test(query-builder): add qb driver with url and filter editor specs
runShortcut waits two frames: a keypress in the same frame as the last
keystroke reads the pre-commit React state, which no user can produce.
2026-11-03 12:39:52 +05:30
Abhi Kumar
3e20fb09f7 test(query-builder): split data-source tests and pin source to the last query 2026-11-03 12:39:51 +05:30
Abhi Kumar
dffb844621 fix(query-builder): added query follows the previous query's signal
Hosts with a fixed data source keep it; only the panel editor, which
has none, changes from always-metrics to the last query's signal.
2026-11-03 12:39:51 +05:30
Abhi Kumar
117183b172 test(query-builder): type the limit through userEvent 2026-11-03 12:39:50 +05:30
Abhi Kumar
6c490be536 fix(query-builder): legacy having casing and non-positive limits
Legacy having referenced COUNT(), which v5 rejects; only the function
name is lower-cased since attribute keys are case-sensitive. Limits
accept positive integers only, for queries and formulas.
2026-11-03 12:39:50 +05:30
150 changed files with 6963 additions and 1347 deletions

5
.github/CODEOWNERS vendored
View File

@@ -302,3 +302,8 @@ go.mod @therealpandey
/frontend/.storybook/ @H4ad
/frontend/src/storybook/ @H4ad
/.claude/skills/signoz-page-story/ @H4ad
## AI Assistant
/frontend/src/container/AIAssistant/ @SigNoz/nerve @SigNoz/frontend-maintainers
/frontend/src/hooks/aiAssistant/ @SigNoz/nerve @SigNoz/frontend-maintainers
/frontend/src/pages/AIAssistantPage/ @SigNoz/nerve @SigNoz/frontend-maintainers

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

@@ -143,7 +143,12 @@ const preview: Preview = {
'Logs',
['Explorer', 'Live Tail', 'Pipelines', 'Settings'],
'Traces',
['Explorer', 'Trace Details', 'Funnel Details'],
[
'Explorer',
['Overview', 'Span Drawer'],
'Trace Details',
'Funnel Details',
],
'Metrics',
['Explorer'],
'Infrastructure',

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

@@ -0,0 +1,17 @@
.drawer {
overflow: hidden;
border-color: var(--l2-border);
background: var(--l2-background);
box-shadow: -4px 10px 16px 2px var(--l1-shadow);
&:focus,
&:focus-visible {
outline: none;
}
}
.body {
display: flex;
flex-direction: column;
height: 100%;
}

View File

@@ -1,7 +0,0 @@
.details-panel-drawer {
&__body {
display: flex;
flex-direction: column;
height: 100%;
}
}

View File

@@ -1,12 +1,15 @@
import { DrawerWrapper } from '@signozhq/ui/drawer';
import { Drawer, DrawerContent } from '@signozhq/ui/drawer';
import cx from 'classnames';
import { AnimatePresence } from 'motion/react';
import './DetailsPanelDrawer.styles.scss';
import styles from './DetailsPanelDrawer.module.scss';
interface DetailsPanelDrawerProps {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
className?: string;
testId?: string;
}
function DetailsPanelDrawer({
@@ -14,22 +17,41 @@ function DetailsPanelDrawer({
onClose,
children,
className,
testId,
}: DetailsPanelDrawerProps): JSX.Element {
return (
<DrawerWrapper
<Drawer
open={isOpen}
onOpenChange={(open): void => {
if (!open) {
onClose();
}
}}
direction="right"
showOverlay={false}
className={`details-panel-drawer ${className || ''}`}
modal={false}
>
<div className="details-panel-drawer__body">{children}</div>
</DrawerWrapper>
<AnimatePresence>
{isOpen && (
<DrawerContent
forceMount
direction="right"
width="extra-wide"
showOverlay={false}
// Outside presses are left to the page, which knows what counts as inside.
onInteractOutside={(e): void => e.preventDefault()}
className={cx(styles.drawer, className)}
testId={testId}
>
<div className={styles.body}>{children}</div>
</DrawerContent>
)}
</AnimatePresence>
</Drawer>
);
}
DetailsPanelDrawer.defaultProps = {
className: undefined,
testId: undefined,
};
export default DetailsPanelDrawer;

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

@@ -0,0 +1,38 @@
import { useCallback } from 'react';
export type UseRowNavigationParams<TData> = {
rows: TData[];
activeIndex: number;
onNavigate: (row: TData, index: number) => void;
};
export type UseRowNavigationResult = {
goToPrev: () => void;
goToNext: () => void;
isPrevDisabled: boolean;
isNextDisabled: boolean;
};
/** Steps the active row through the loaded rows; stops at either end. */
export function useRowNavigation<TData>({
rows,
activeIndex,
onNavigate,
}: UseRowNavigationParams<TData>): UseRowNavigationResult {
const isPrevDisabled = activeIndex <= 0;
const isNextDisabled = activeIndex < 0 || activeIndex >= rows.length - 1;
const goToPrev = useCallback((): void => {
if (!isPrevDisabled) {
onNavigate(rows[activeIndex - 1], activeIndex - 1);
}
}, [isPrevDisabled, onNavigate, rows, activeIndex]);
const goToNext = useCallback((): void => {
if (!isNextDisabled) {
onNavigate(rows[activeIndex + 1], activeIndex + 1);
}
}, [isNextDisabled, onNavigate, rows, activeIndex]);
return { goToPrev, goToNext, isPrevDisabled, isNextDisabled };
}

View File

@@ -46,4 +46,5 @@ export enum LOCALSTORAGE {
DASHBOARDS_LIST_VIEWS = 'DASHBOARDS_LIST_VIEWS',
DASHBOARD_V2_PANEL_COLUMN_WIDTHS = 'DASHBOARD_V2_PANEL_COLUMN_WIDTHS',
LLM_ATTRIBUTE_MAPPING_TEST_SPAN = 'LLM_ATTRIBUTE_MAPPING_TEST_SPAN',
TRACES_SPAN_DRAWER_ENABLED = 'TRACES_SPAN_DRAWER_ENABLED',
}

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

@@ -6,3 +6,7 @@
height: calc(100vh - 240px);
min-height: 400px;
}
.drawerScope {
display: contents;
}

View File

@@ -0,0 +1,113 @@
import remove from 'api/browser/localstorage/remove';
import set from 'api/browser/localstorage/set';
import { LOCALSTORAGE } from 'constants/localStorage';
import { server } from 'mocks-server/server';
import { screen, userEvent, waitFor, within } from 'tests/test-utils';
import { mockSuccess, mockTraceSummary, renderListView } from './testUtils';
// globalTime starts with loading:true, which gates the list query. Force just that
// slice's loading to false so the query fires; every other selector is untouched.
jest.mock('react-redux', () => {
const actual = jest.requireActual('react-redux');
return {
...actual,
useSelector: (selector: (state: unknown) => unknown): unknown => {
const result = actual.useSelector(selector);
if (result && typeof result === 'object' && 'loading' in result) {
return { ...result, loading: false };
}
return result;
},
};
});
// List columns come from the options menu (server-synced preferences). Pin them
// so the expected columns render, independent of that API.
jest.mock('container/OptionsMenu/useOptionsMenu', () => ({
__esModule: true,
default: (): unknown => ({
options: {
selectColumns: [
{ name: 'service.name', fieldContext: 'resource' },
{ name: 'name', fieldContext: 'span' },
{ name: 'duration_nano', fieldContext: 'span' },
{ name: 'http_method', fieldContext: 'span' },
{ name: 'response_status_code', fieldContext: 'span' },
{ name: 'http.route', fieldContext: 'attribute' },
{ name: 'cache.hit', fieldContext: 'attribute' },
],
},
config: { addColumn: { onRemove: jest.fn() } },
}),
}));
describe('Traces ListView - span drawer navigation', () => {
beforeEach(() => {
set(LOCALSTORAGE.TRACES_SPAN_DRAWER_ENABLED, 'true');
mockSuccess();
mockTraceSummary({});
});
afterEach(() => {
remove(LOCALSTORAGE.TRACES_SPAN_DRAWER_ENABLED);
server.resetHandlers();
});
it('opens the clicked span and steps through rows with the arrow keys', async () => {
const user = userEvent.setup();
renderListView();
await user.click(await screen.findByText('HTTP GET'));
const drawer = await screen.findByTestId('span-details-drawer');
expect(within(drawer).getByText('HTTP GET')).toBeInTheDocument();
expect(within(drawer).getByTestId('span-details-trace-id')).toHaveTextContent(
'0000000000000000344ded1387b08a7e',
);
await user.keyboard('{ArrowDown}');
await expect(
within(drawer).findByText('authenticate_check_db'),
).resolves.toBeInTheDocument();
await user.keyboard('{ArrowUp}');
await expect(
within(drawer).findByText('HTTP GET'),
).resolves.toBeInTheDocument();
});
it('closes the drawer', async () => {
const user = userEvent.setup();
renderListView();
await user.click(await screen.findByText('HTTP GET'));
await screen.findByTestId('span-details-drawer');
await user.click(screen.getByTestId('span-drawer-close'));
await waitFor(() =>
expect(screen.queryByTestId('span-details-drawer')).not.toBeInTheDocument(),
);
});
it('stays open on a press in the drawer or the list, and closes on one outside', async () => {
const user = userEvent.setup();
renderListView();
await user.click(await screen.findByText('HTTP GET'));
const drawer = await screen.findByTestId('span-details-drawer');
await user.click(drawer);
// Hovering re-renders the row, so the press needs a fresh query.
await user.hover(screen.getByText('authenticate_check_db'));
await user.click(screen.getByText('authenticate_check_db'));
await expect(
within(drawer).findByText('authenticate_check_db'),
).resolves.toBeInTheDocument();
await user.click(document.body);
await waitFor(() =>
expect(screen.queryByTestId('span-details-drawer')).not.toBeInTheDocument(),
);
});
});

View File

@@ -0,0 +1,192 @@
import remove from 'api/browser/localstorage/remove';
import set from 'api/browser/localstorage/set';
import { LOCALSTORAGE } from 'constants/localStorage';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { screen, userEvent, waitFor, within } from 'tests/test-utils';
import { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
import {
FIVE_MINUTES_IN_MS,
listResponse,
listRows,
mockSuccess,
mockTraceSummary,
QUERY_RANGE_URL,
renderListView,
TraceBounds,
} from './testUtils';
// globalTime starts with loading:true, which gates the list query. Force just that
// slice's loading to false so the query fires; every other selector is untouched.
jest.mock('react-redux', () => {
const actual = jest.requireActual('react-redux');
return {
...actual,
useSelector: (selector: (state: unknown) => unknown): unknown => {
const result = actual.useSelector(selector);
if (result && typeof result === 'object' && 'loading' in result) {
return { ...result, loading: false };
}
return result;
},
};
});
// List columns come from the options menu (server-synced preferences). Pin them
// so the expected columns render, independent of that API.
jest.mock('container/OptionsMenu/useOptionsMenu', () => ({
__esModule: true,
default: (): unknown => ({
options: {
selectColumns: [
{ name: 'service.name', fieldContext: 'resource' },
{ name: 'name', fieldContext: 'span' },
{ name: 'duration_nano', fieldContext: 'span' },
{ name: 'http_method', fieldContext: 'span' },
{ name: 'response_status_code', fieldContext: 'span' },
{ name: 'http.route', fieldContext: 'attribute' },
{ name: 'cache.hit', fieldContext: 'attribute' },
],
},
config: { addColumn: { onRemove: jest.fn() } },
}),
}));
describe('Traces ListView - span drawer trace bounds', () => {
beforeEach(() => {
set(LOCALSTORAGE.TRACES_SPAN_DRAWER_ENABLED, 'true');
mockSuccess();
mockTraceSummary({});
});
afterEach(() => {
remove(LOCALSTORAGE.TRACES_SPAN_DRAWER_ENABLED);
server.resetHandlers();
});
it('requests span logs over whole milliseconds', async () => {
const logsRequests: QueryRangeRequestV5[] = [];
server.use(
rest.post(QUERY_RANGE_URL, async (req, res, ctx) => {
const body = await req.json<QueryRangeRequestV5>();
const spec = body.compositeQuery.queries[0]?.spec;
if (spec && 'signal' in spec && spec.signal === 'logs') {
logsRequests.push(body);
// No logs, so no context queries follow to outlive the test.
return res(ctx.status(200), ctx.json(listResponse([])));
}
return res(ctx.status(200), ctx.json(listResponse(listRows)));
}),
);
const user = userEvent.setup();
renderListView();
// Its 790.94939 ms duration makes the span window end mid-millisecond.
await user.click(await screen.findByText('authenticate_check_db'));
await waitFor(() => expect(logsRequests.length).toBeGreaterThan(0));
logsRequests.forEach(({ start, end }) => {
expect(Number.isInteger(start)).toBe(true);
expect(Number.isInteger(end)).toBe(true);
});
});
it('reads the trace bounds from the trace summary', async () => {
const bounds: TraceBounds = {
'0000000000000000344ded1387b08a7e': {
start: Date.parse('2024-07-19T08:39:58.700Z'),
end: Date.parse('2024-07-19T08:39:58.900Z'),
},
a364a8e15af3e9a8c866e0528db8b637: {
start: Date.parse('2024-07-19T08:39:59.900Z'),
end: Date.parse('2024-07-19T08:40:01.000Z'),
},
};
mockTraceSummary(bounds);
const logsRequests: {
traceId: string;
isTraceWide: boolean;
start: number;
end: number;
}[] = [];
server.use(
rest.post(QUERY_RANGE_URL, async (req, res, ctx) => {
const body = await req.json<QueryRangeRequestV5>();
const spec = body.compositeQuery.queries[0]?.spec;
if (spec && 'signal' in spec && spec.signal === 'logs') {
const traceId =
Object.keys(bounds).find((id) => spec.filter?.expression?.includes(id)) ??
'';
// The payload carries the window in µs.
logsRequests.push({
traceId,
isTraceWide: !spec.filter?.expression?.includes('span_id'),
start: body.start / 1e3,
end: body.end / 1e3,
});
return res(ctx.status(200), ctx.json(listResponse([])));
}
return res(ctx.status(200), ctx.json(listResponse(listRows)));
}),
);
const user = userEvent.setup();
renderListView();
await user.click(await screen.findByText('HTTP GET'));
const drawer = await screen.findByTestId('span-details-drawer');
await expect(
within(drawer).findByText(/of total exec time/),
).resolves.toBeInTheDocument();
// The next span is in another trace, so it needs that trace's bounds.
// Both its span logs and its trace-wide check must land before the test ends.
await user.keyboard('{ArrowDown}');
await waitFor(() =>
expect(
logsRequests
.filter(({ traceId }) => traceId === 'a364a8e15af3e9a8c866e0528db8b637')
.map(({ isTraceWide }) => isTraceWide),
).toStrictEqual(expect.arrayContaining([false, true])),
);
// Logs wait for the bounds: no request ever goes out with another window.
logsRequests.forEach(({ traceId, start, end }) => {
expect(start).toBe(bounds[traceId].start - FIVE_MINUTES_IN_MS);
expect(end).toBe(bounds[traceId].end + FIVE_MINUTES_IN_MS);
});
});
it('speaks only for the span when the trace has no summary', async () => {
const logsFilters: string[] = [];
server.use(
rest.post(QUERY_RANGE_URL, async (req, res, ctx) => {
const body = await req.json<QueryRangeRequestV5>();
const spec = body.compositeQuery.queries[0]?.spec;
if (spec && 'signal' in spec && spec.signal === 'logs') {
logsFilters.push(spec.filter?.expression ?? '');
return res(ctx.status(200), ctx.json(listResponse([])));
}
return res(ctx.status(200), ctx.json(listResponse(listRows)));
}),
);
const user = userEvent.setup();
renderListView();
await user.click(await screen.findByText('HTTP GET'));
const drawer = await screen.findByTestId('span-details-drawer');
await user.click(within(drawer).getByRole('tab', { name: /logs/i }));
await expect(
within(drawer).findByText('No logs found for this span.'),
).resolves.toBeInTheDocument();
// Checked once the 404 has settled; before it, exec time is absent anyway.
expect(
within(drawer).queryByText(/of total exec time/),
).not.toBeInTheDocument();
// No trace-wide check: every logs request is scoped to the span.
expect(logsFilters.length).toBeGreaterThan(0);
logsFilters.forEach((expression) => {
expect(expression).toContain('span_id');
});
});
});

View File

@@ -0,0 +1,110 @@
import { ENVIRONMENT } from 'constants/env';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { VirtuosoMockContext } from 'react-virtuoso';
import { render } from 'tests/test-utils';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import ListView from '../index';
const BASE_URL = ENVIRONMENT.baseURL;
export const QUERY_RANGE_URL = `${BASE_URL}/api/v5/query_range`;
export const listRows = [
{
timestamp: '2024-07-19T08:39:58.735245Z',
data: {
name: 'HTTP GET',
duration_nano: 55306000,
http_method: 'GET',
response_status_code: '200',
span_id: '772c4d29dd9076ac',
trace_id: '0000000000000000344ded1387b08a7e',
attributes: { 'http.route': '/api/cart', 'cache.hit': false },
resource: { 'service.name': 'frontend' },
},
},
{
timestamp: '2024-07-19T08:39:59.949129915Z',
data: {
name: 'authenticate_check_db',
duration_nano: 790949390,
// empty status fields to assert the "-" cell
http_method: '',
response_status_code: '',
span_id: '5704353737b6778e',
trace_id: 'a364a8e15af3e9a8c866e0528db8b637',
attributes: {},
resource: { 'service.name': 'demo-app' },
},
},
];
export const listResponse = (rows: unknown[]): Record<string, unknown> => ({
data: { type: 'raw', data: { results: [{ queryName: 'A', rows }] } },
});
export const TRACE_SUMMARY_URL = `${BASE_URL}/api/v1/traces/:traceId/summary`;
export const FIVE_MINUTES_IN_MS = 5 * 60 * 1000;
export type TraceBounds = Record<string, { start: number; end: number }>;
// Traces missing from `bounds` get a 404, like a trace with no summary row.
export const mockTraceSummary = (bounds: TraceBounds): void => {
server.use(
rest.get(TRACE_SUMMARY_URL, (req, res, ctx) => {
const traceBounds = bounds[req.params.traceId as string];
if (!traceBounds) {
return res(ctx.status(404), ctx.json({ status: 'error' }));
}
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
startTimestampMillis: traceBounds.start,
endTimestampMillis: traceBounds.end,
hasMissingSpans: false,
rootServiceEntryPoint: '',
rootServiceName: '',
rootSpanStatusCode: '',
totalErrorSpansCount: 0,
totalSpansCount: 1,
},
}),
);
}),
);
};
export const mockSuccess = (rows: unknown[] = listRows): void => {
server.use(
rest.post(QUERY_RANGE_URL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(listResponse(rows))),
),
);
};
export const renderListView = (
stagedQuery: Query = initialQueriesMap.traces,
): ReturnType<typeof render> =>
render(
<VirtuosoMockContext.Provider value={{ viewportHeight: 500, itemHeight: 54 }}>
<ListView
isFilterApplied={false}
setWarning={jest.fn()}
setIsLoadingQueries={jest.fn()}
/>
</VirtuosoMockContext.Provider>,
{},
{
initialRoute: '/traces-explorer',
queryBuilderOverrides: {
panelType: PANEL_TYPES.LIST,
stagedQuery,
currentQuery: stagedQuery,
redirectWithQueryBuilderData: jest.fn(),
} as any,
},
);

View File

@@ -1,17 +1,20 @@
import { ENVIRONMENT } from 'constants/env';
import {
initialQueriesMap,
initialQueryBuilderFormTraceOperatorValues,
PANEL_TYPES,
} from 'constants/queryBuilder';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { VirtuosoMockContext } from 'react-virtuoso';
import { render, screen } from 'tests/test-utils';
import { screen } from 'tests/test-utils';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
import ListView from './index';
import {
listResponse,
listRows,
mockSuccess,
QUERY_RANGE_URL,
renderListView,
} from './__tests__/testUtils';
// globalTime starts with loading:true, which gates the list query. Force just that
// slice's loading to false so the query fires; every other selector is untouched.
@@ -49,74 +52,6 @@ jest.mock('container/OptionsMenu/useOptionsMenu', () => ({
}),
}));
const BASE_URL = ENVIRONMENT.baseURL;
const QUERY_RANGE_URL = `${BASE_URL}/api/v5/query_range`;
const listRows = [
{
timestamp: '2024-07-19T08:39:58.735245Z',
data: {
name: 'HTTP GET',
duration_nano: 55306000,
http_method: 'GET',
response_status_code: '200',
span_id: '772c4d29dd9076ac',
trace_id: '0000000000000000344ded1387b08a7e',
attributes: { 'http.route': '/api/cart', 'cache.hit': false },
resource: { 'service.name': 'frontend' },
},
},
{
timestamp: '2024-07-19T08:39:59.949129915Z',
data: {
name: 'authenticate_check_db',
duration_nano: 790949390,
// empty status fields to assert the "-" cell
http_method: '',
response_status_code: '',
span_id: '5704353737b6778e',
trace_id: 'a364a8e15af3e9a8c866e0528db8b637',
attributes: {},
resource: { 'service.name': 'demo-app' },
},
},
];
const listResponse = (rows: unknown[]): Record<string, unknown> => ({
data: { type: 'raw', data: { results: [{ queryName: 'A', rows }] } },
});
const mockSuccess = (rows: unknown[] = listRows): void => {
server.use(
rest.post(QUERY_RANGE_URL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(listResponse(rows))),
),
);
};
const renderListView = (
stagedQuery: Query = initialQueriesMap.traces,
): ReturnType<typeof render> =>
render(
<VirtuosoMockContext.Provider value={{ viewportHeight: 500, itemHeight: 54 }}>
<ListView
isFilterApplied={false}
setWarning={jest.fn()}
setIsLoadingQueries={jest.fn()}
/>
</VirtuosoMockContext.Provider>,
{},
{
initialRoute: '/traces-explorer',
queryBuilderOverrides: {
panelType: PANEL_TYPES.LIST,
stagedQuery,
currentQuery: stagedQuery,
redirectWithQueryBuilderData: jest.fn(),
} as any,
},
);
describe('Traces ListView - Data Loaded', () => {
afterEach(() => {
server.resetHandlers();

View File

@@ -29,6 +29,8 @@ import { useOptionsMenu } from 'container/OptionsMenu';
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types';
import TraceExplorerControls from 'container/TracesExplorer/Controls';
import { getListViewQuery } from 'container/TracesExplorer/explorerUtils';
import SpanDrawer from 'container/TracesExplorer/SpanDrawer/SpanDrawer';
import { useSpanDrawer } from 'container/TracesExplorer/SpanDrawer/useSpanDrawer';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { Pagination } from 'hooks/queryPagination';
@@ -202,6 +204,8 @@ function ListView({
[queryTableData],
);
const { tableProps, drawerProps, onPressInside } = useSpanDrawer(rows);
const handleColumnOrderChange = useCallback(
(cols: TableColumnDef<TracesTableRow>[]): void => {
config?.addColumn?.onReorder(cols.map((c) => c.id));
@@ -253,20 +257,26 @@ function ListView({
/>
</div>
<TracesTable
data={rows}
columns={columns}
columnStorageKey={LOCALSTORAGE.TRACES_LIST_COLUMNS}
panelType="LIST"
getRowHref={getTraceLink}
isLoading={isLoading}
isFetching={isFetching}
isError={isError}
error={error}
isFilterApplied={isFilterApplied}
onColumnOrderChange={handleColumnOrderChange}
onColumnRemove={config?.addColumn?.onRemove}
/>
{/* A press in the table or the drawer (a portal) keeps the drawer open. */}
<div className={styles.drawerScope} onPointerDownCapture={onPressInside}>
<TracesTable
data={rows}
columns={columns}
columnStorageKey={LOCALSTORAGE.TRACES_LIST_COLUMNS}
panelType="LIST"
getRowHref={getTraceLink}
{...tableProps}
isLoading={isLoading}
isFetching={isFetching}
isError={isError}
error={error}
isFilterApplied={isFilterApplied}
onColumnOrderChange={handleColumnOrderChange}
onColumnRemove={config?.addColumn?.onRemove}
/>
{drawerProps && <SpanDrawer {...drawerProps} />}
</div>
</div>
);
}

View File

@@ -0,0 +1,112 @@
import { useMemo } from 'react';
import { Button } from '@signozhq/ui/button';
import { ButtonGroup } from '@signozhq/ui/button-group';
import { ChevronDown, ChevronUp, CornerUpRight } from '@signozhq/icons';
import { useGetTraceSummary } from 'api/generated/services/tracedetail';
import { HeaderAction } from 'components/DetailsPanel';
import type { UseRowNavigationResult } from 'components/TanStackTableView/useRowNavigation';
import { getTraceLink } from 'container/TracesExplorer/ListView/utils';
import { TracesTableRow } from 'container/TracesExplorer/TracesTable/getFieldColumn';
import { SpanDetailVariant } from 'pages/TraceDetailsV3/SpanDetailsPanel/constants';
import SpanDetailsPanel from 'pages/TraceDetailsV3/SpanDetailsPanel/SpanDetailsPanel';
import { openInNewTab } from 'utils/navigation';
import { useArrowKeyNavigation } from './useArrowKeyNavigation';
import { toSpanV3 } from './utils';
export type SpanDrawerProps = {
row: TracesTableRow | null;
onClose: () => void;
navigation: UseRowNavigationResult;
};
function SpanDrawer({
row,
onClose,
navigation,
}: SpanDrawerProps): JSX.Element {
const { goToPrev, goToNext, isPrevDisabled, isNextDisabled } = navigation;
const isOpen = row !== null;
useArrowKeyNavigation({ enabled: isOpen, onPrev: goToPrev, onNext: goToNext });
const panelState = useMemo(
() => ({ isOpen, open: (): void => {}, close: onClose }),
[isOpen, onClose],
);
const headerActions = useMemo((): HeaderAction[] => {
if (!row) {
return [];
}
return [
{
key: 'view-full-trace',
component: (
<Button
variant="outlined"
color="secondary"
size="md"
prefix={<CornerUpRight />}
onClick={(): void => openInNewTab(getTraceLink(row))}
testId="span-drawer-view-full-trace"
>
View full trace
</Button>
),
},
{
key: 'nav',
component: (
<ButtonGroup
variant="outlined"
color="secondary"
size="md"
testId="span-drawer-nav"
items={[
{
value: 'prev',
icon: <ChevronUp />,
ariaLabel: 'Previous span',
onClick: goToPrev,
disabled: isPrevDisabled,
disabledTooltip: 'No previous span on this page',
},
{
value: 'next',
icon: <ChevronDown />,
ariaLabel: 'Next span',
onClick: goToNext,
disabled: isNextDisabled,
disabledTooltip: 'No next span on this page',
},
]}
/>
),
},
];
}, [row, goToPrev, goToNext, isPrevDisabled, isNextDisabled]);
const span = useMemo(() => (row ? toSpanV3(row) : undefined), [row]);
const traceId = span?.trace_id ?? '';
const { data: traceSummary, isLoading: isTraceBoundsLoading } =
useGetTraceSummary(
{ traceID: traceId },
{ query: { enabled: !!traceId, retry: false } },
);
return (
<SpanDetailsPanel
panelState={panelState}
selectedSpan={span}
variant={SpanDetailVariant.DRAWER}
headerActions={headerActions}
traceStartTime={traceSummary?.data?.startTimestampMillis}
traceEndTime={traceSummary?.data?.endTimestampMillis}
isTraceBoundsLoading={isTraceBoundsLoading}
/>
);
}
export default SpanDrawer;

View File

@@ -0,0 +1,39 @@
import { renderHook } from 'tests/test-utils';
import { useArrowKeyNavigation } from '../useArrowKeyNavigation';
const pressArrowDown = ({ handled }: { handled: boolean }): void => {
const event = new KeyboardEvent('keydown', {
key: 'ArrowDown',
bubbles: true,
cancelable: true,
});
if (handled) {
event.preventDefault();
}
document.body.dispatchEvent(event);
};
describe('useArrowKeyNavigation', () => {
it('steps to the next span on ArrowDown', () => {
const onNext = jest.fn() as jest.MockedFunction<() => void>;
renderHook(() =>
useArrowKeyNavigation({ enabled: true, onPrev: jest.fn(), onNext }),
);
pressArrowDown({ handled: false });
expect(onNext).toHaveBeenCalledTimes(1);
});
it('ignores a key that a focused menu already handled', () => {
const onNext = jest.fn() as jest.MockedFunction<() => void>;
renderHook(() =>
useArrowKeyNavigation({ enabled: true, onPrev: jest.fn(), onNext }),
);
pressArrowDown({ handled: true });
expect(onNext).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,35 @@
import { act, renderHook } from 'tests/test-utils';
import remove from 'api/browser/localstorage/remove';
import set from 'api/browser/localstorage/set';
import { LOCALSTORAGE } from 'constants/localStorage';
import { TracesTableRow } from 'container/TracesExplorer/TracesTable/getFieldColumn';
import { useSpanDrawer } from '../useSpanDrawer';
const rows: TracesTableRow[] = [{ id: 'span-a' }, { id: 'span-b' }];
describe('useSpanDrawer', () => {
beforeEach(() => {
set(LOCALSTORAGE.TRACES_SPAN_DRAWER_ENABLED, 'true');
});
afterEach(() => {
remove(LOCALSTORAGE.TRACES_SPAN_DRAWER_ENABLED);
});
it('does not reopen a span that a refetch dropped and a later one brings back', () => {
const { result, rerender } = renderHook(
({ current }: { current: TracesTableRow[] }) => useSpanDrawer(current),
{ initialProps: { current: rows } },
);
act(() => result.current.tableProps.onRowClick?.(rows[1]));
expect(result.current.drawerProps?.row).toBe(rows[1]);
rerender({ current: [rows[0]] });
expect(result.current.drawerProps?.row).toBeNull();
rerender({ current: rows });
expect(result.current.drawerProps?.row).toBeNull();
});
});

View File

@@ -0,0 +1,74 @@
import { toSpanV3 } from '../utils';
describe('toSpanV3', () => {
const row = {
id: '772c4d29dd9076ac',
span_id: '772c4d29dd9076ac',
timestamp: '2024-07-19T08:39:58.735Z',
resource: { 'service.name': 'frontend' },
events: [
{
name: 'exception',
timeUnixNano: 1721378398735000000,
attributes: { 'exception.type': 'Error' },
},
],
links: [{ traceId: 'a364a8e15af3e9a8', spanId: '5704353737b6778e' }],
};
it('reshapes a list row into the span the details panel reads', () => {
const span = toSpanV3(row);
expect(span.timestamp).toBe(Date.UTC(2024, 6, 19, 8, 39, 58, 735));
expect(span['service.name']).toBe('frontend');
expect(span.events).toStrictEqual([
{
name: 'exception',
timeUnixNano: 1721378398735000000,
attributeMap: { 'exception.type': 'Error' },
isError: false,
},
]);
expect((span as unknown as { references: unknown }).references).toStrictEqual(
row.links,
);
});
it('drops the parent from the linked spans', () => {
const link = { traceId: 'a364a8e15af3e9a8', spanId: '5704353737b6778e' };
const parent = { traceId: '1f2e3d4c5b6a7980', spanId: '0aa1bb2cc3dd4ee5' };
const span = toSpanV3({
...row,
trace_id: parent.traceId,
parent_span_id: parent.spanId,
links: [parent, link, { ...parent, traceId: link.traceId }],
});
expect((span as unknown as { references: unknown }).references).toStrictEqual(
[link, { ...parent, traceId: link.traceId }],
);
});
it('drops the empty parent ref of a root span', () => {
const span = toSpanV3({
...row,
trace_id: 'a364a8e15af3e9a8',
parent_span_id: '',
links: [{ traceId: 'a364a8e15af3e9a8' }],
});
expect((span as unknown as { references: unknown }).references).toStrictEqual(
[],
);
});
it('defaults missing events, links and service name', () => {
const span = toSpanV3({ id: 'x', timestamp: row.timestamp });
expect(span.events).toStrictEqual([]);
expect(span['service.name']).toBe('');
expect((span as unknown as { references: unknown }).references).toStrictEqual(
[],
);
});
});

View File

@@ -0,0 +1,46 @@
import { useEffect } from 'react';
const EDITABLE_TARGET = 'input, textarea, [contenteditable="true"], .cm-editor';
type UseArrowKeyNavigationParams = {
enabled: boolean;
onPrev: () => void;
onNext: () => void;
};
export function useArrowKeyNavigation({
enabled,
onPrev,
onNext,
}: UseArrowKeyNavigationParams): void {
useEffect(() => {
if (!enabled) {
return undefined;
}
const handleKeyDown = (e: KeyboardEvent): void => {
// A focused menu or listbox already used the key.
if (e.defaultPrevented) {
return;
}
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) {
return;
}
if (e.target instanceof Element && e.target.closest(EDITABLE_TARGET)) {
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
onPrev();
} else if (e.key === 'ArrowDown') {
e.preventDefault();
onNext();
}
};
document.addEventListener('keydown', handleKeyDown);
return (): void => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [enabled, onPrev, onNext]);
}

View File

@@ -0,0 +1,87 @@
import { PointerEvent, useCallback, useEffect, useMemo, useState } from 'react';
import { useRowNavigation } from 'components/TanStackTableView/useRowNavigation';
import { TracesTableRow } from 'container/TracesExplorer/TracesTable/getFieldColumn';
import type { TracesTableProps } from 'container/TracesExplorer/TracesTable/TracesTable';
import { useCloseOnOutsidePress } from 'hooks/useCloseOnOutsidePress';
import type { SpanDrawerProps } from './SpanDrawer';
import { isSpanDrawerEnabled } from './utils';
type ActiveSpan = { id: string; index: number };
type UseSpanDrawerResult = {
tableProps: Pick<
TracesTableProps,
'onRowClick' | 'isRowActive' | 'activeRowIndex'
>;
/** Null while the drawer flag is off. */
drawerProps: SpanDrawerProps | null;
/** `onPointerDownCapture` for the area a press keeps the drawer open in. */
onPressInside: (event: PointerEvent) => void;
};
export function useSpanDrawer(rows: TracesTableRow[]): UseSpanDrawerResult {
const [isEnabled] = useState(isSpanDrawerEnabled);
// The index lets navigation step past a duplicate span; the id survives a refetch.
const [activeSpan, setActiveSpan] = useState<ActiveSpan | null>(null);
const activeRowIndex = useMemo(() => {
if (!activeSpan) {
return -1;
}
return rows[activeSpan.index]?.id === activeSpan.id
? activeSpan.index
: rows.findIndex((row) => row.id === activeSpan.id);
}, [rows, activeSpan]);
const isRowActive = useCallback(
(row: TracesTableRow): boolean => row === rows[activeRowIndex],
[rows, activeRowIndex],
);
const handleNavigate = useCallback(
(row: TracesTableRow, index: number): void => {
setActiveSpan({ id: row.id, index });
},
[],
);
const selectRow = useCallback(
(row: TracesTableRow): void => handleNavigate(row, rows.indexOf(row)),
[handleNavigate, rows],
);
const clearActiveSpan = useCallback((): void => setActiveSpan(null), []);
// A refetch can drop the open span; forget it so a later page can't reopen it.
useEffect(() => {
if (activeSpan && activeRowIndex === -1) {
setActiveSpan(null);
}
}, [activeSpan, activeRowIndex]);
const onPressInside = useCloseOnOutsidePress(
isEnabled && activeRowIndex !== -1,
clearActiveSpan,
);
const navigation = useRowNavigation({
rows,
activeIndex: activeRowIndex,
onNavigate: handleNavigate,
});
if (!isEnabled) {
return { tableProps: {}, drawerProps: null, onPressInside };
}
return {
tableProps: { onRowClick: selectRow, isRowActive, activeRowIndex },
drawerProps: {
row: rows[activeRowIndex] ?? null,
onClose: clearActiveSpan,
navigation,
},
onPressInside,
};
}

View File

@@ -0,0 +1,46 @@
import get from 'api/browser/localstorage/get';
import { LOCALSTORAGE } from 'constants/localStorage';
import { TracesTableRow } from 'container/TracesExplorer/TracesTable/getFieldColumn';
import { SpanV3 } from 'types/api/trace/getTraceV3';
/** Off by default; set `TRACES_SPAN_DRAWER_ENABLED` to `true` in localStorage to try it. */
export const isSpanDrawerEnabled = (): boolean =>
get(LOCALSTORAGE.TRACES_SPAN_DRAWER_ENABLED) === 'true';
type ListSpanEvent = {
name: string;
timeUnixNano: number;
attributes?: Record<string, string>;
isError?: boolean;
};
type ListSpanLink = { traceId: string; spanId: string };
/**
* The list API returns a span in a different shape from the trace API the
* details panel was built on: an ISO timestamp, `attributes` on events, and
* `links` in place of `references`.
*/
export const toSpanV3 = (row: TracesTableRow): SpanV3 => {
const resource = (row.resource ?? {}) as Record<string, string>;
const events = (row.events ?? []) as ListSpanEvent[];
const links = (row.links ?? []) as ListSpanLink[];
return {
...row,
timestamp: new Date(row.timestamp as string).getTime(),
'service.name': resource['service.name'] ?? '',
events: events.map(({ attributes, isError, ...event }) => ({
...event,
attributeMap: attributes ?? {},
isError: isError ?? false,
})),
// The list API drops `refType`, so the parent ref is matched by id. A root
// span's parent ref has no span id.
references: links.filter(
(link) =>
!!link.spanId &&
(link.spanId !== row.parent_span_id || link.traceId !== row.trace_id),
),
} as unknown as SpanV3;
};

View File

@@ -24,6 +24,10 @@ export type TracesTableProps = {
panelType: PanelTypeKeys;
/** Builds the trace-detail href for a row; drives row click + cmd/ctrl-click. */
getRowHref: (row: TracesTableRow) => string;
/** Replaces the default row click, which opens the trace detail page. */
onRowClick?: (row: TracesTableRow) => void;
isRowActive?: (row: TracesTableRow) => boolean;
activeRowIndex?: number;
isLoading: boolean;
isFetching: boolean;
isError: boolean;
@@ -41,6 +45,9 @@ function TracesTable({
respectColumnOrder = false,
panelType,
getRowHref,
onRowClick,
isRowActive,
activeRowIndex,
isLoading,
isFetching,
isError,
@@ -57,9 +64,13 @@ function TracesTable({
const handleRowClick = useCallback(
(row: TracesTableRow): void => {
if (onRowClick) {
onRowClick(row);
return;
}
navigate(getRowHref(row));
},
[navigate, getRowHref],
[onRowClick, navigate, getRowHref],
);
const handleRowClickNewTab = useCallback(
@@ -97,6 +108,8 @@ function TracesTable({
onColumnRemove={onColumnRemove}
onRowClick={handleRowClick}
onRowClickNewTab={handleRowClickNewTab}
isRowActive={isRowActive}
activeRowIndex={activeRowIndex}
getRowTestId={(row): string => `traces-table-row-${row.id}`}
testId={`traces-table-${panelType.toLowerCase()}`}
/>
@@ -109,6 +122,9 @@ function TracesTable({
TracesTable.defaultProps = {
columnStorageKey: undefined,
respectColumnOrder: false,
onRowClick: undefined,
isRowActive: undefined,
activeRowIndex: undefined,
onColumnOrderChange: undefined,
onColumnRemove: undefined,
cellTypographySize: 'medium',

View File

@@ -0,0 +1,107 @@
import { createPortal } from 'react-dom';
import { render, screen, userEvent } from 'tests/test-utils';
import { useCloseOnOutsidePress } from '../useCloseOnOutsidePress';
function Harness({
enabled = true,
onClose,
}: {
enabled?: boolean;
onClose: () => void;
}): JSX.Element {
const onPressInside = useCloseOnOutsidePress(enabled, onClose);
return (
<>
<div onPointerDownCapture={onPressInside}>
<button type="button" data-testid="inside">
inside
</button>
<button
type="button"
data-testid="inside-stops"
onPointerDown={(e): void => e.stopPropagation()}
>
inside, stops propagation
</button>
{createPortal(
<button type="button" data-testid="inside-portal">
portal
</button>,
document.body,
)}
</div>
<button type="button" data-testid="outside">
outside
</button>
<button
type="button"
data-testid="outside-stops"
onPointerDown={(e): void => e.stopPropagation()}
>
outside, stops propagation
</button>
</>
);
}
const flush = (): Promise<void> =>
new Promise((resolve) => {
setTimeout(resolve);
});
describe('useCloseOnOutsidePress', () => {
it('closes on a press outside', async () => {
const onClose = jest.fn() as jest.MockedFunction<() => void>;
render(<Harness onClose={onClose} />);
await userEvent.click(screen.getByTestId('outside'));
await flush();
expect(onClose).toHaveBeenCalledTimes(1);
});
it('stays open on a press inside, portals included', async () => {
const onClose = jest.fn() as jest.MockedFunction<() => void>;
render(<Harness onClose={onClose} />);
await userEvent.click(screen.getByTestId('inside'));
await userEvent.click(screen.getByTestId('inside-portal'));
await flush();
expect(onClose).not.toHaveBeenCalled();
});
it('closes when the outside element stops propagation', async () => {
const onClose = jest.fn() as jest.MockedFunction<() => void>;
render(<Harness onClose={onClose} />);
await userEvent.click(screen.getByTestId('outside-stops'));
await flush();
expect(onClose).toHaveBeenCalledTimes(1);
});
it('closes on the first outside press after an inside press stops propagation', async () => {
const onClose = jest.fn() as jest.MockedFunction<() => void>;
render(<Harness onClose={onClose} />);
await userEvent.click(screen.getByTestId('inside-stops'));
await flush();
expect(onClose).not.toHaveBeenCalled();
await userEvent.click(screen.getByTestId('outside'));
await flush();
expect(onClose).toHaveBeenCalledTimes(1);
});
it('does nothing while disabled', async () => {
const onClose = jest.fn() as jest.MockedFunction<() => void>;
render(<Harness enabled={false} onClose={onClose} />);
await userEvent.click(screen.getByTestId('outside'));
await flush();
expect(onClose).not.toHaveBeenCalled();
});
});

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

@@ -0,0 +1,51 @@
import {
PointerEvent as ReactPointerEvent,
useCallback,
useEffect,
useRef,
} from 'react';
/**
* Calls `onClose` on any press outside the elements given the returned handler as
* `onPointerDownCapture`. Menus and dialogs rendered in portals still count as
* inside: React events follow the component tree.
*
* The document listener runs in the capture phase, so an outside element that
* stops propagation still closes. It runs before React marks the press, so the
* check waits for the dispatch to finish.
*/
export function useCloseOnOutsidePress(
enabled: boolean,
onClose: () => void,
): (event: ReactPointerEvent) => void {
const insidePress = useRef<Event | null>(null);
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
useEffect(() => {
if (!enabled) {
return undefined;
}
const timeouts = new Set<ReturnType<typeof setTimeout>>();
const handlePointerDown = (event: PointerEvent): void => {
const timeout = setTimeout(() => {
timeouts.delete(timeout);
if (insidePress.current !== event) {
onCloseRef.current();
}
});
timeouts.add(timeout);
};
document.addEventListener('pointerdown', handlePointerDown, true);
return (): void => {
document.removeEventListener('pointerdown', handlePointerDown, true);
timeouts.forEach(clearTimeout);
};
}, [enabled]);
return useCallback((event: ReactPointerEvent): void => {
insidePress.current = event.nativeEvent;
}, []);
}

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

@@ -13,7 +13,8 @@ import styles from './Events.module.scss';
interface IEventsTableProps {
span: SpanV3;
startTime: number;
/** Trace start; the "since trace start" offset is hidden without it. */
startTime?: number;
isSearchVisible: boolean;
}
@@ -80,17 +81,19 @@ function EventsTable(props: IEventsTableProps): JSX.Element {
<Typography.Text className={styles.attributeKey}>
Start Time
</Typography.Text>
<div className={styles.timestampContainer}>
<Typography.Text className={styles.attributeValue}>
{getYAxisFormattedValue(
`${(timeUnixNano || 0) / 1e6 - startTime}`,
'ms',
)}
</Typography.Text>
<Typography.Text className={styles.timestampText}>
since trace start
</Typography.Text>
</div>
{startTime !== undefined && (
<div className={styles.timestampContainer}>
<Typography.Text className={styles.attributeValue}>
{getYAxisFormattedValue(
`${(timeUnixNano || 0) / 1e6 - startTime}`,
'ms',
)}
</Typography.Text>
<Typography.Text className={styles.timestampText}>
since trace start
</Typography.Text>
</div>
)}
<div className={styles.timestampContainer}>
<Typography.Text className={styles.attributeValue}>
{getYAxisFormattedValue(

View File

@@ -0,0 +1,38 @@
import { render, screen } from 'tests/test-utils';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import Events from '../Events';
const SPAN_START_MS = 1_700_000_000_000;
const span = {
span_id: 'span-1',
trace_id: 'trace-1',
name: 'checkout',
timestamp: SPAN_START_MS,
duration_nano: 50_000_000,
events: [
{
name: 'cache miss',
timeUnixNano: (SPAN_START_MS + 3) * 1e6,
attributeMap: {},
isError: false,
},
],
} as unknown as SpanV3;
describe('Events', () => {
it('shows the offset from the trace start when it is known', () => {
render(<Events span={span} startTime={SPAN_START_MS - 10} isSearchVisible />);
expect(screen.getByText('since trace start')).toBeInTheDocument();
expect(screen.getByText('since span start')).toBeInTheDocument();
});
it('shows only the offset from the span start without a trace start', () => {
render(<Events span={span} isSearchVisible />);
expect(screen.queryByText('since trace start')).not.toBeInTheDocument();
expect(screen.getByText('since span start')).toBeInTheDocument();
});
});

View File

@@ -110,3 +110,15 @@
scrollbar-width: none;
}
}
.drawerPanel {
.panelBody,
.logsFooter,
:global(.pretty-view__search-wrapper) {
background: var(--l2-background);
}
:global(.pretty-view__search-input) {
background: var(--l2-background) !important;
}
}

View File

@@ -3,6 +3,7 @@ import { Badge } from '@signozhq/ui/badge';
import { Tabs, type TabsItemProps } from '@signozhq/ui/tabs';
import { Bookmark, ChartColumnBig, List, ScrollText } from '@signozhq/icons';
import { Skeleton } from 'antd';
import cx from 'classnames';
import { DetailsHeader, DetailsPanelDrawer } from 'components/DetailsPanel';
import { HeaderAction } from 'components/DetailsPanel/DetailsHeader/DetailsHeader';
import { DetailsPanelState } from 'components/DetailsPanel/types';
@@ -49,6 +50,7 @@ import OpenInLogsExplorer from './SpanLogs/OpenInLogsExplorer';
import SpanLogs from './SpanLogs/SpanLogs';
import { useSpanContextLogs } from './SpanLogs/useSpanContextLogs';
import SpanAIMessages from './SpanAIMessages/SpanAIMessages';
import SpanDrawerHeader from './SpanDrawerHeader';
import SpanSummary from './SpanSummary';
import styles from './SpanDetailsPanel.module.scss';
@@ -58,8 +60,12 @@ interface SpanDetailsPanelProps {
selectedSpan: SpanV3 | undefined;
variant?: SpanDetailVariant;
onVariantChange?: (variant: SpanDetailVariant) => void;
/** Page-specific actions, shown before the dock mode switcher. */
headerActions?: HeaderAction[];
traceStartTime?: number;
traceEndTime?: number;
/** The trace bounds are still being fetched; logs wait for them. */
isTraceBoundsLoading?: boolean;
}
// At/above this panel width the summary moves inside the Overview tab (bottom
@@ -73,10 +79,12 @@ function SpanDetailsContent({
selectedSpan,
traceStartTime,
traceEndTime,
isTraceBoundsLoading = false,
}: {
selectedSpan: SpanV3;
traceStartTime?: number;
traceEndTime?: number;
isTraceBoundsLoading?: boolean;
}): JSX.Element {
const [bodyRef, { width: bodyWidth }] = useMeasure<HTMLDivElement>();
const spanAttributeActions = useSpanAttributeActions();
@@ -168,6 +176,31 @@ function SpanDetailsContent({
// [spanAttributeActions, setCopy],
// );
// Without trace bounds the panel speaks only for the span: its own logs
// window, and no trace-wide logs check.
const hasTraceBounds = !!traceStartTime && !!traceEndTime;
const logsTimeRange = useMemo(
() =>
traceStartTime && traceEndTime
? {
startTime: traceStartTime - FIVE_MINUTES_IN_MS,
endTime: traceEndTime + FIVE_MINUTES_IN_MS,
}
: {
startTime: selectedSpan.timestamp - FIVE_MINUTES_IN_MS,
// query_range rejects a fractional end.
endTime:
Math.ceil(selectedSpan.timestamp + selectedSpan.duration_nano / 1e6) +
FIVE_MINUTES_IN_MS,
},
[
traceStartTime,
traceEndTime,
selectedSpan.timestamp,
selectedSpan.duration_nano,
],
);
const {
logs,
isLoading: isLogsLoading,
@@ -178,11 +211,9 @@ function SpanDetailsContent({
} = useSpanContextLogs({
traceId: selectedSpan.trace_id,
spanId: selectedSpan.span_id,
timeRange: {
startTime: (traceStartTime || 0) - FIVE_MINUTES_IN_MS,
endTime: (traceEndTime || 0) + FIVE_MINUTES_IN_MS,
},
isDrawerOpen: true,
timeRange: logsTimeRange,
checkTraceLogs: hasTraceBounds,
enabled: !isTraceBoundsLoading,
});
const infraMetadata = useMemo(() => {
@@ -201,8 +232,7 @@ function SpanDetailsContent({
}, [selectedSpan]);
const handleExplorerPageRedirect = useCallback((): void => {
const startTimeMs = (traceStartTime || 0) - FIVE_MINUTES_IN_MS;
const endTimeMs = (traceEndTime || 0) + FIVE_MINUTES_IN_MS;
const { startTime: startTimeMs, endTime: endTimeMs } = logsTimeRange;
const traceIdFilter = {
op: 'AND',
@@ -244,14 +274,15 @@ function SpanDetailsContent({
searchParams.set(QueryParams.endTime, endTimeMs.toString());
openInNewTab(`${ROUTES.LOGS_EXPLORER}?${searchParams.toString()}`);
}, [selectedSpan.trace_id, traceStartTime, traceEndTime]);
}, [selectedSpan.trace_id, logsTimeRange]);
const emptyLogsStateConfig = useMemo(
() => ({
...getEmptyLogsListConfig(() => {}),
...(!hasTraceBounds && { title: 'No logs found for this span.' }),
showClearFiltersButton: false,
}),
[],
[hasTraceBounds],
);
// const keyAttributes = useMemo(() => {
@@ -321,11 +352,7 @@ function SpanDetailsContent({
</Badge>
) : undefined,
children: (
<Events
span={selectedSpan}
startTime={traceStartTime || 0}
isSearchVisible
/>
<Events span={selectedSpan} startTime={traceStartTime} isSearchVisible />
),
},
{
@@ -336,10 +363,7 @@ function SpanDetailsContent({
<SpanLogs
traceId={selectedSpan.trace_id}
spanId={selectedSpan.span_id}
timeRange={{
startTime: (traceStartTime || 0) - FIVE_MINUTES_IN_MS,
endTime: (traceEndTime || 0) + FIVE_MINUTES_IN_MS,
}}
timeRange={logsTimeRange}
logs={logs}
isLoading={isLogsLoading}
isError={isLogsError}
@@ -394,7 +418,10 @@ function SpanDetailsContent({
with the panel's bottom edge — always visible, never scrolls. */}
{activeTab === 'logs' && (
<div className={styles.logsFooter}>
<OpenInLogsExplorer onClick={handleExplorerPageRedirect} />
<OpenInLogsExplorer
onClick={handleExplorerPageRedirect}
disabled={isTraceBoundsLoading}
/>
</div>
)}
</>
@@ -406,11 +433,14 @@ function SpanDetailsPanel({
selectedSpan,
variant = SpanDetailVariant.DIALOG,
onVariantChange,
headerActions: pageHeaderActions,
traceStartTime,
traceEndTime,
isTraceBoundsLoading,
}: SpanDetailsPanelProps): JSX.Element {
const headerActions = useMemo((): HeaderAction[] => {
const actions: HeaderAction[] = [
...(pageHeaderActions ?? []),
// TODO: Add back when driven through separate config for different pages
// {
// key: 'view-full-trace',
@@ -440,7 +470,7 @@ function SpanDetailsPanel({
}
return actions;
}, [variant, onVariantChange]);
}, [pageHeaderActions, variant, onVariantChange]);
const PANEL_WIDTH = SPAN_DETAILS_PANEL_MIN_WIDTH;
const PANEL_MARGIN_RIGHT = 20;
@@ -449,19 +479,28 @@ function SpanDetailsPanel({
const content = (
<>
<DetailsHeader
title="Span details"
onClose={panelState.close}
actions={headerActions}
className={
variant === SpanDetailVariant.DIALOG ? 'floating-panel__drag-handle' : ''
}
/>
{variant === SpanDetailVariant.DRAWER ? (
<SpanDrawerHeader
title="Span details"
onClose={panelState.close}
actions={headerActions}
/>
) : (
<DetailsHeader
title="Span details"
onClose={panelState.close}
actions={headerActions}
className={
variant === SpanDetailVariant.DIALOG ? 'floating-panel__drag-handle' : ''
}
/>
)}
{selectedSpan ? (
<SpanDetailsContent
selectedSpan={selectedSpan}
traceStartTime={traceStartTime}
traceEndTime={traceEndTime}
isTraceBoundsLoading={isTraceBoundsLoading}
/>
) : (
<div className={styles.panelBody}>
@@ -483,7 +522,8 @@ function SpanDetailsPanel({
<DetailsPanelDrawer
isOpen={panelState.isOpen}
onClose={panelState.close}
className={styles.panel}
className={cx(styles.panel, styles.drawerPanel)}
testId="span-details-drawer"
>
{content}
</DetailsPanelDrawer>

View File

@@ -0,0 +1,33 @@
.header {
box-sizing: border-box;
display: flex;
flex-shrink: 0;
align-items: center;
gap: var(--spacing-4);
height: 48px;
padding: 0 var(--spacing-8) 0 var(--spacing-4);
border-bottom: 1px solid var(--l1-border);
background: var(--l2-background);
}
.divider {
height: 16px;
margin-inline: 0 var(--spacing-4);
}
.title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
color: var(--l1-foreground);
}
.actions {
display: flex;
flex-shrink: 0;
align-items: center;
gap: var(--spacing-4);
}

View File

@@ -0,0 +1,43 @@
import { Button } from '@signozhq/ui/button';
import { Divider } from '@signozhq/ui/divider';
import { X } from '@signozhq/icons';
import type { HeaderAction } from 'components/DetailsPanel';
import styles from './SpanDrawerHeader.module.scss';
interface SpanDrawerHeaderProps {
title: string;
onClose: () => void;
actions: HeaderAction[];
}
function SpanDrawerHeader({
title,
onClose,
actions,
}: SpanDrawerHeaderProps): JSX.Element {
return (
<div className={styles.header}>
<Button
variant="ghost"
color="secondary"
size="md"
icon
aria-label="Close"
onClick={onClose}
testId="span-drawer-close"
>
<X />
</Button>
<Divider type="vertical" className={styles.divider} />
<span className={styles.title}>{title}</span>
<div className={styles.actions}>
{actions.map((action) => (
<div key={action.key}>{action.component}</div>
))}
</div>
</div>
);
}
export default SpanDrawerHeader;

View File

@@ -3,17 +3,23 @@ import { Compass } from '@signozhq/icons';
interface OpenInLogsExplorerProps {
onClick: () => void;
disabled?: boolean;
}
// Opens the full Logs Explorer (new tab) filtered to this span's trace.
// Placement/alignment is the caller's responsibility.
function OpenInLogsExplorer({ onClick }: OpenInLogsExplorerProps): JSX.Element {
function OpenInLogsExplorer({
onClick,
disabled,
}: OpenInLogsExplorerProps): JSX.Element {
return (
<Button
variant="solid"
color="secondary"
size="md"
onClick={onClick}
disabled={disabled}
disabledTooltip="Loading the trace's time range"
prefix={<Compass size={16} />}
testId="open-in-explorer-button"
>

View File

@@ -20,7 +20,8 @@ interface UseSpanContextLogsProps {
startTime: number;
endTime: number;
};
isDrawerOpen?: boolean;
checkTraceLogs?: boolean;
enabled?: boolean;
}
interface UseSpanContextLogsReturn {
@@ -112,7 +113,8 @@ export const useSpanContextLogs = ({
traceId,
spanId,
timeRange,
isDrawerOpen = true,
checkTraceLogs = true,
enabled = true,
}: UseSpanContextLogsProps): UseSpanContextLogsReturn => {
const [allLogs, setAllLogs] = useState<ILog[]>([]);
const [spanLogIds, setSpanLogIds] = useState<Set<string>>(new Set());
@@ -142,7 +144,7 @@ export const useSpanContextLogs = ({
timeRange.endTime,
],
queryFn: () => GetMetricQueryRange(spanQueryPayload, ENTITY_VERSION_V5),
enabled: !!traceId && !!spanId,
enabled: enabled && !!traceId && !!spanId,
staleTime: FIVE_MINUTES_IN_MS,
});
@@ -311,7 +313,11 @@ export const useSpanContextLogs = ({
],
queryFn: () =>
GetMetricQueryRange(traceOnlyQueryPayload as any, ENTITY_VERSION_V5),
enabled: isDrawerOpen && !!traceOnlyQueryPayload && spanLogs.length === 0,
enabled:
enabled &&
checkTraceLogs &&
!!traceOnlyQueryPayload &&
spanLogs.length === 0,
staleTime: FIVE_MINUTES_IN_MS,
});
@@ -332,7 +338,7 @@ export const useSpanContextLogs = ({
return {
logs: allLogs,
isLoading: isSpanLoading && spanLogs.length === 0,
isLoading: (!enabled || isSpanLoading) && spanLogs.length === 0,
isError: isSpanError,
isFetching: isSpanFetching || isBeforeFetching || isAfterFetching,
spanLogIds,

View File

@@ -1,7 +1,21 @@
.traceId {
color: var(--accent-primary);
display: flex;
align-items: center;
gap: var(--spacing-1);
min-width: 0;
// Sized to the icon, so the copy button sits flush against the id.
--button-padding: 2px;
--button-height: auto;
--button-width: auto;
}
// A block, since overflow and text-overflow do not apply to the inline span
// Typography.Text renders.
.value {
display: block;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
}

View File

@@ -1,11 +1,5 @@
import { useCopyToClipboard } from 'react-use';
import { Button } from '@signozhq/ui/button';
import { Tooltip } from '@signozhq/ui/tooltip';
import { toast } from '@signozhq/ui/sonner';
import { AppLink } from 'lib/router/AppLink';
import { matchRoute } from 'lib/router/matchRoute';
import { useAppLocation } from 'lib/router/useAppLocation';
import ROUTES from 'constants/routes';
import { Typography } from '@signozhq/ui/typography';
import CopyButton from 'periscope/components/CopyButton/CopyButton';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import styles from './TraceIdField.module.scss';
@@ -14,51 +8,20 @@ interface TraceIdFieldProps {
span: SpanV3;
}
/**
* Renders a span's trace id. When the user is already on the trace detail
* page for this trace, clicking the id copies it to the clipboard (the
* "navigate" affordance would be a no-op). Otherwise, falls back to the
* existing link to the trace detail page.
*/
export function TraceIdField({ span }: TraceIdFieldProps): JSX.Element {
const { pathname } = useAppLocation();
const match = matchRoute<'id'>(pathname, ROUTES.TRACE_DETAIL, { exact: true });
const [, setCopy] = useCopyToClipboard();
const isCurrentTrace = match?.params.id === span.trace_id;
if (isCurrentTrace) {
const handleCopy = (): void => {
setCopy(span.trace_id);
toast.success('Trace ID copied to clipboard', {
position: 'top-right',
});
};
return (
<Tooltip title="Click to copy trace ID">
<Button
size="md"
variant="link"
color="secondary"
maxWidth="100%"
onClick={handleCopy}
>
{span.trace_id}
</Button>
</Tooltip>
);
}
return (
<AppLink
to={{
pathname: `/trace/${span.trace_id}`,
search: window.location.search,
}}
className={styles.traceId}
>
{span.trace_id}
</AppLink>
<div className={styles.traceId}>
<span className={styles.value} title={span.trace_id}>
<Typography.Text testId="span-details-trace-id">
{span.trace_id}
</Typography.Text>
</span>
<CopyButton
value={span.trace_id}
size={12}
ariaLabel="Copy trace ID"
testId="span-details-copy-trace-id"
/>
</div>
);
}

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

@@ -34,7 +34,8 @@ function ThreadSpanDetails({
const [width, setWidth] = useState(RIGHT_DOCK_MIN);
const [height, setHeight] = useState(BOTTOM_DOCK_DEFAULT);
const { id: traceId = '' } = useAppParams<'id'>();
const { data: traceSummary } = useTraceSummary(traceId);
const { data: traceSummary, isLoading: isTraceSummaryLoading } =
useTraceSummary(traceId);
const panel = (
<SpanDetailsPanel
@@ -44,6 +45,7 @@ function ThreadSpanDetails({
onVariantChange={onVariantChange}
traceStartTime={traceSummary?.startTimestampMillis}
traceEndTime={traceSummary?.endTimestampMillis}
isTraceBoundsLoading={isTraceSummaryLoading}
/>
);

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

@@ -13,7 +13,7 @@ import type { SpantypesPostableTraceAggregationsDTO } from 'api/generated/servic
import { LOCALSTORAGE } from 'constants/localStorage';
import { USER_PREFERENCES } from 'constants/userPreferences';
import { screen, userEvent, within } from 'storybook/test';
import type { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
import type { QueryRangeRequestV5, RawRow } from 'types/api/v5/queryRange';
import {
choiceControl,
@@ -150,6 +150,12 @@ export const traceDetailsMocks = defineStoryMocks({
value: 8,
max: 20,
}),
traceOnlyLogs: toggleControl('Logs only on the trace', {
group: PANEL,
description:
'The span has no logs of its own but the trace does, which is the Logs tab\'s "view logs for the current trace" state.',
value: false,
}),
exportState: choiceControl<ExportState>('Trace export', {
group: EXPORT,
description:
@@ -165,6 +171,12 @@ export const traceDetailsMocks = defineStoryMocks({
errors: values.errors,
traceStart: traceStartOf(),
};
const spanLogRowsFor = (body: QueryRangeRequestV5): RawRow[] => {
const scope = spanLogScopeOf(body);
const count =
values.traceOnlyLogs && scope !== 'trace' ? 0 : values.spanLogs;
return spanLogRows(count, trace, scope);
};
return [
rest.post('http://localhost/api/v1/export_raw_data', (_req, res, ctx) => {
@@ -266,9 +278,7 @@ export const traceDetailsMocks = defineStoryMocks({
if (requestType === 'raw') {
return queryRangeV5RawResponse(
signalOf(body) === 'logs'
? spanLogRows(values.spanLogs, trace, spanLogScopeOf(body))
: spanMatchRows(trace),
signalOf(body) === 'logs' ? spanLogRowsFor(body) : spanMatchRows(trace),
);
}

View File

@@ -17,7 +17,6 @@ import {
focusSpanIndex,
scenarioSpanCount,
spanIdAt,
STORY_TRACE_ID,
} from './__story_mockdata__/traceDetails';
import {
openRowMenu,
@@ -249,18 +248,35 @@ export const SpanPercentileOpen: Story = {
},
};
/** The span's trace id clicked in the panel, which copies it. */
export const TraceIdCopiedToast: Story = {
/** The span has no logs of its own but its trace does, so the Logs tab points to the trace's. */
export const SpanPanelTraceOnlyLogs: Story = {
args: { traceOnlyLogs: true },
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByRole('tab', { name: /Logs/ }, untilLoaded),
);
await expect(
await within(canvasElement).findByText(
'No logs found for selected span. View logs for the current trace.',
undefined,
untilLoaded,
),
).toBeVisible();
},
};
/** The span panel's trace id copied with its copy button, which turns into a check. */
export const TraceIdCopied: Story = {
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByRole(
'button',
{ name: STORY_TRACE_ID },
{ name: 'Copy trace ID' },
untilLoaded,
),
);
await expect(
await screen.findByText('Trace ID copied to clipboard'),
await within(canvasElement).findByRole('button', { name: 'Copied' }),
).toBeVisible();
},
};

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