Compare commits

...

24 Commits

Author SHA1 Message Date
Nikhil Soni
4870fb92de test(integration): log body scalar-and-object key collapses
The nested log body cannot hold a key that is both a scalar and an object
(db.function and db.function.arg) — the counterpart to the flattened trace
attributes bag, which keeps both.

Assisted-by: Claude Opus 4.8
2026-09-30 18:15:28 +05:30
Nikhil Soni
ec060ad882 chore(integration): trim test docstring to the edge case
Assisted-by: Claude Opus 4.8
2026-09-30 16:30:50 +05:30
Nikhil Soni
ed17f8e60a test(integration): scalar-and-object attribute key survives native JSON read
Captures engineering-pod#6072: a span attribute stored as both a scalar and
an object prefix (db.function + db.function.arg) keeps both dotted keys when
read from the native JSON column, which the removed read-as-string collapsed.

Assisted-by: Claude Opus 4.8
2026-09-30 16:18:42 +05:30
Nikhil Soni
be1ec7102c chore(querier): trim comments to the non-obvious
Assisted-by: Claude Opus 4.8
2026-09-30 14:30:09 +05:30
Nikhil Soni
9211b5e829 refactor(querier): inline chcol decode, drop JSON wrapper funcs
NestedJSON/FlattenJSON had collapsed to one-line wrappers over
NestedMap/ValuesByPath; call the chcol methods directly in unwrapVariant and
remove telemetrystoretypes/json.go.

Assisted-by: Claude Opus 4.8
2026-09-30 14:30:09 +05:30
Nikhil Soni
9f79083e99 test(querier): table-driven TestUnwrapVariant and TestLabelValue
Assisted-by: Claude Opus 4.8
2026-09-30 14:30:09 +05:30
Nikhil Soni
e9748b9672 refactor(telemetrystore): return ValuesByPath directly in FlattenJSON
Drop the per-value MarshalJSON+Unmarshal round-trip: ValuesByPath already
yields the leaf map and its chcol.Variant values serialize themselves,
mirroring NestedJSON. Serialization is byte-identical (verified on CH
25.12.5). sonic is no longer imported directly.

Assisted-by: Claude Opus 4.8
2026-09-30 14:30:09 +05:30
Nikhil Soni
9a2afc3cab refactor(querier): move JSON decode policy into unwrapVariant
readAsRaw no longer special-cases the attributes column. unwrapVariant
takes the column name and picks the shape: attributes flat (its dotted keys
merge with the legacy maps), every other JSON column nested.

Assisted-by: Claude Opus 4.8
2026-09-30 14:30:09 +05:30
Nikhil Soni
06a3f09aa4 refactor(telemetrystore): return NestedMap directly in NestedJSON
Drop the MarshalJSON+Unmarshal round-trip: NestedMap already yields the
nested document and its chcol.Variant leaves serialize themselves.
Serialization is byte-identical (verified on CH 25.12.5), and the typed
message path stays a plain string for stripEmptyBodyMessage.

Assisted-by: Claude Opus 4.8
2026-09-30 14:30:09 +05:30
Nikhil Soni
3b8dd1119d fix(querier): decode span attributes to flat dotted keys
The span attribute bag is flattened and merged with the legacy maps, so a
key stored as both a scalar and an object (e.g. `scope` and
`scope.attributes.name`) must stay two distinct dotted keys. NestedJSON
collapsed them and surfaced a mislabeled top-level key that no legacy value
overwrote. Decode the attributes column with FlattenJSON (ValuesByPath); the
log body keeps NestedJSON for its nested contract.

Assisted-by: Claude Opus 4.8
2026-09-30 14:30:09 +05:30
Nikhil Soni
20cfeeab3b fix(telemetrystore): decode JSON via MarshalJSON to keep nested arrays
The manual NestedMap/Variant walk collapsed arrays of objects to empty
maps (education: [{}]). Decode through the driver's MarshalJSON plus sonic
instead, which preserves arrays of objects and matches the prior
string-read semantics (float64 numbers, nested tree).

Assisted-by: Claude Opus 4.8
2026-09-30 14:30:09 +05:30
Nikhil Soni
a0fe8b5c6b refactor(telemetrystore): drop WrapRows and JSONValue
The driver already reports chcol.JSON as the up-front scan type for JSON
columns under the flattened native serialization, so the WrapRows scan-type
override (and its test conn wrapper) is dead weight; verified across
JSON(mdp=0), JSON(mdp=0, message String) and JSON(mdp=100). Decode with a
plain map[string]any instead of the JSONValue alias.

Assisted-by: Claude Opus 4.8
2026-09-30 14:30:09 +05:30
Nikhil Soni
e7cb50aa08 chore: go mod tidy
sonic is no longer imported directly after the JSON scan refactor.

Assisted-by: Claude Opus 4.8
2026-09-30 14:30:09 +05:30
Nikhil Soni
94eeeb20f1 refactor(telemetrystore): scan JSON columns as chcol.JSON
Read JSON columns into chcol.JSON and decode to a nested document, so a
typed sub-path (body_v2.message) no longer errors under the flattened
native serialization. Kept nested, not flattened to dotted paths: the
query builder body contract is nested, so a key stored as both a scalar
and an object still collapses here.

Assisted-by: Claude Opus 4.8
2026-09-30 14:30:09 +05:30
Nikhil Soni
1825a605e2 revert: drop the chcol.JSON scan refactor, keep only the setting swap
Reading JSON in the flattened native serialization lets the driver decode it into the existing JSONValue map, so removing output_format_native_write_json_as_string needs no scan-side changes. The query builder still returns the JSON nested (its scalar-and-object collapse is handled separately for the waterfall).

Assisted-by: Claude Opus 4.8
2026-09-30 14:30:09 +05:30
Nikhil Soni
2f35a9059e refactor(telemetrystore): drop JSONValue in favour of map[string]any
JSONValue was a thin named map used only as a type-switch discriminator; FlattenJSON now returns map[string]any and the consumers assert it directly.

Assisted-by: Claude Opus 4.8
2026-09-30 14:30:09 +05:30
Nikhil Soni
7badbada4e feat(telemetrystore): read JSON columns natively to keep scalar-and-object keys
Swap the read connection from output_format_native_write_json_as_string=1 to output_format_native_use_flattened_dynamic_and_json_serialization=1 and scan JSON columns as chcol.JSON flattened to dotted paths (via WrapRows + unwrapVariant), instead of a map the driver collapses. A key stored as both a scalar and an object is no longer dropped in logs or the trace list view. JSONValue is now a flat result type; its Scan is removed.

Assisted-by: Claude Opus 4.8
2026-09-30 14:30:09 +05:30
Vikrant Gupta
8d80f98710 fix(authz): decode the request body once in the resource middleware (#13014)
Some checks failed
Release Drafter / update_release_draft (push) Has been cancelled
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
#### Description

- The resource middleware now decodes the body once into the route's
declared `OpenAPIDef.Request` type, rejects a malformed body before any
check, and carries the decoded value on `ExtractorContext.Body`.
`BodyField` / `BodyFields` read ids off that value, and handlers read
the same value via `coretypes.BodyFromContext`.
- `handler.Handler` exposes `Request()`, and `handler.New` panics when a
route with resource defs declares a non-pointer request, since the
middleware instantiates it.
- Only `POST /api/v1/service_account_roles` is wired to the new
extractors in this PR to keep the review small. The remaining body
routes still use the gjson extractors and decode again in their
handlers.

#### Issues closed by this PR
- Contributes to: https://github.com/SigNoz/keystone-pod/issues/37
2026-09-29 21:32:25 +00:00
Aditya Singh
adfcebf855 feat(explorer): create alert and add to dashboard actions in the explorer views (#12981)
#### Description
- Create alert and Add to dashboard now live in the view that owns the
row.. logs list row, logs / traces time series and table headers, traces
list and trace rows, metrics per chart. Same line as download
everywhere. First slice of pulling the actions out of the bottom bar,
the bar keeps its own two till it goes so they show twice for now.
- new `ExplorerActions` renders the pair.. the view hands over its
export query, same query the bar gets so nothing changes in what reaches
the alert / dashboard. `TimeSeriesView` got a `headerActions` slot for
it.
- metrics one chart per query: each chart carries its own alert /
dashboard / download for that chart's query, icon only in the split
layout. no per query picker needed.
- alert shaping is source agnostic now.. every noop becomes count and
list / trace panels drop `orderBy` whatever the source. bar only checked
the first query and only stripped for logs. neutral today, traces
already sends `orderBy: []` for those views.
- `DownloadOptionsMenu` moved to the design system buttons.. list
download was the antd primary tinted icon, did not match the rest of the
row.
- llm explorer and meter untouched.. llm gets it later, meter never had
these buttons in the bar.

#### Issues closed by this PR

Closes https://github.com/SigNoz/events-pod/issues/56




https://github.com/user-attachments/assets/a3b7f08c-2c62-4243-8cd7-4dd25cb315fd



#### Additional Information
- no flag.. buttons are live from merge, the bar stays till saved views
lands in the sidebar.
- logs controls row is list only now.. everything in it was list gated
once the buttons moved into the view headers, it was an empty strip on
time series / table.
- analytics is one generic event per action with `sourcepage` in the
payload, not the per explorer names the bar used.
- verified bar == button (alert url and dashboard link) in the browser
for all logs / traces views and metrics single, split and unsplit. tests
pin the query pushed to the url per view.
2026-09-29 16:43:05 +00:00
Swapnil Nakade
254758942e fix: using allLogs categoryGroups for azure container apps (#13013)
#### Description

- The Container Apps logs pipeline failed because the definition passed
log category names (`ContainerAppConsoleLogs`, `ContainerAppSystemLogs`)
as `categoryGroups`. Azure accepts only `allLogs` or `audit` there, so
it rejected the diagnostic setting.
- Switched to `allLogs`, matching the other Azure services. The agent
picks this up on its next config sync, so no agent release is needed.

#### Issues closed by this PR

Closes SigNoz/keystone-pod#45

#### Additional Information
- Not tested on live Azure. Microsoft's built-in policy for
`Microsoft.App/managedEnvironments` sends the same `allLogs` setting to
Event Hub.
2026-09-29 14:24:00 +00:00
Aditya Singh
8a5d98f467 chore(logs): remove the old log details drawer (#12938)
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

- `useIsLogDetailsV2` was a route test, not a feature flag.. v2 rendered
on the logs explorer, infra monitoring and dashboards and everything
else fell back to v1. made v2 the only drawer, so the pipelines preview
gets it too. that's the one behaviour change here.
- deleted the v1 code that leaves behind.. the attribute table and its
json processing, the two HOCs, the `ActionItem` component and the
separate JSON tab. `Overview` is down to its DataViewer path, which also
drops monaco from the logs bundle. `DataType` and the two props types
move out first since MetricsExplorer and infra monitoring read them.
- dropped the standalone `/logs/logs-explorer/live` route. nothing
navigated to it, the time picker's Live option is in-page state on the
explorer. the components stay, that in-page mode still renders them.

#### Issues closed by this PR

Part of https://github.com/SigNoz/engineering-pod/issues/5933

#### Additional Information

- `onAddToQuery` stays for now. it is v1-only inside the drawer but five
call sites still thread it through, a couple of them doubling it as
`onClickActionItem`.. untangling that is a refactor rather than a
deletion.
- `useLogAttributeActions` gated group-by and replace-filter on "old
explorer or live logs". with both routes gone the guard is always false,
so it collapses.
2026-09-29 13:30:34 +00:00
praneeth-signoz
47dd1fabf3 chore(channel-specs): Move channel specs to separate files (#12989)
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
<!--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

- Moved channel specs to separate files under alert manager types
- Channel receivers are also moved the same file

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Closes
https://github.com/orgs/SigNoz/projects/34/views/26?pane=issue&itemId=252814150&issue=SigNoz%7Cpulse-pod%7C374
2026-09-28 17:44:35 +00:00
Vikrant Gupta
270988fb48 fix(tokenizer): persist last_observed_at on postgres (#12982)
#### Description

- The flush CTE rendered `last_observed_at` as an untyped literal, which
postgres resolves to `text` and refuses to assign to the `timestamptz`
column. The column never populated, so the idle expiry never applied.
- Build the CTE from the token model with only `id`, `last_observed_at`
and `updated_at`, so bun casts per dialect and no token secrets land in
the statement.
- Flush now applies cached times through `Token.UpdateLastObservedAt`,
which also skips rows with a newer stored value.
- Integration test in `passwordauthn` runs with a short GC interval and
asserts the column populates on both sql stores.
2026-09-28 14:23:01 +00:00
Naman Verma
39badeb591 fix: remove rules types package import from migration #049 (#12998)
Some checks failed
Release Drafter / update_release_draft (push) Has been cancelled
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
<!--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

Migration package ideally should have have things from types package
imported. Given that rules v1->v2 will (most probably) update/remove
some of the types, such as removing `PreferredChannels` from
`PostableRule`, better not to have this type imported in migrations
package.

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

Part of https://github.com/SigNoz/pulse-pod/issues/225
2026-09-28 11:39:07 +00:00
106 changed files with 2685 additions and 4109 deletions

View File

@@ -33,7 +33,6 @@
"NOT_FOUND": "SigNoz | Page Not Found",
"LOGS": "SigNoz | Logs",
"LOGS_EXPLORER": "SigNoz | Logs Explorer",
"LIVE_LOGS": "SigNoz | Live Logs",
"HOME_PAGE": "Open source Observability Platform | SigNoz",
"PASSWORD_RESET": "SigNoz | Password Reset",
"LIST_LICENSES": "SigNoz | List of Licenses",

View File

@@ -42,7 +42,6 @@
"NOT_FOUND": "SigNoz | Page Not Found",
"LOGS": "SigNoz | Logs",
"LOGS_EXPLORER": "SigNoz | Logs Explorer",
"LIVE_LOGS": "SigNoz | Live Logs",
"LOGS_PIPELINES": "SigNoz | Logs Pipelines",
"HOME_PAGE": "Open source Observability Platform | SigNoz",
"PASSWORD_RESET": "SigNoz | Password Reset",

View File

@@ -300,14 +300,6 @@ describe('PrivateRoute', () => {
);
});
it('should redirect /logs-explorer/live to /logs/logs-explorer/live', () => {
renderPrivateRoute({ initialRoute: '/logs-explorer/live' });
expect(screen.getByTestId('location-display')).toHaveTextContent(
'/logs/logs-explorer/live',
);
});
it('should redirect /logs-save-views to /logs/saved-views', () => {
renderPrivateRoute({ initialRoute: '/logs-save-views' });
@@ -1596,7 +1588,6 @@ describe('PrivateRoute', () => {
// route definition comes last, and both keys are authz-aware either way.
LOGS: { path: ROUTES.LOGS, deniedRoles: DENIED_ROLES },
LOGS_EXPLORER: { path: ROUTES.LOGS_EXPLORER, deniedRoles: DENIED_ROLES },
LIVE_LOGS: { path: ROUTES.LIVE_LOGS, deniedRoles: DENIED_ROLES },
METRICS_EXPLORER: {
path: ROUTES.METRICS_EXPLORER,
deniedRoles: DENIED_ROLES,

View File

@@ -154,10 +154,6 @@ export const Logs = Loadable(
() => import(/* webpackChunkName: "Logs" */ 'pages/LogsModulePage'),
);
export const LiveLogs = Loadable(
() => import(/* webpackChunkName: "Live Logs" */ 'pages/LiveLogs'),
);
export const PipelinePage = Loadable(
() => import(/* webpackChunkName: "Pipelines" */ 'pages/LogsModulePage'),
);

View File

@@ -23,7 +23,6 @@ import {
LicensePage,
ListAllALertsPage,
LLMObservabilityPage,
LiveLogs,
Login,
Logs,
LogsIndexToFields,
@@ -282,13 +281,6 @@ const routes: AppRoutes[] = [
key: 'LOGS',
isPrivate: true,
},
{
path: ROUTES.LIVE_LOGS,
exact: true,
component: LiveLogs,
key: 'LIVE_LOGS',
isPrivate: true,
},
{
path: ROUTES.LOGS_PIPELINES,
exact: true,
@@ -521,7 +513,6 @@ export const LIST_LICENSES: AppRoutes = {
export const oldNewRoutesMapping: Record<string, string> = {
'/pipelines': '/logs/pipelines',
'/logs-explorer': '/logs/logs-explorer',
'/logs-explorer/live': '/logs/logs-explorer/live',
'/logs-save-views': '/logs/saved-views',
'/traces-save-views': '/traces/saved-views',
'/settings/access-tokens': '/settings/service-accounts',

View File

@@ -106,7 +106,7 @@ describe.each([
renderWithStore(dataSource);
const button = screen.getByTestId(testId);
expect(button).toBeInTheDocument();
expect(button).toHaveClass('periscope-btn', 'ghost');
expect(button).toHaveAccessibleName('Download');
});
it('shows popover with export options when download button is clicked', () => {

View File

@@ -1,11 +1,12 @@
import { useCallback, useMemo, useState } from 'react';
import { Button, Popover, Tooltip } from 'antd';
import { Popover, Tooltip } from 'antd';
import { Button } from '@signozhq/ui/button';
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
import { Typography } from '@signozhq/ui/typography';
import { TelemetryFieldKey } from 'api/v5/v5';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useExportRawData } from 'hooks/useExportData/useServerExport';
import { Download, LoaderCircle } from '@signozhq/icons';
import { Download } from '@signozhq/icons';
import { DataSource } from 'types/common/queryBuilder';
import {
@@ -111,8 +112,9 @@ export default function DownloadOptionsMenu({
)}
<Button
type="primary"
icon={<Download size={16} />}
variant="solid"
color="primary"
prefix={<Download size={16} />}
onClick={handleExport}
className="export-button"
disabled={isDownloading}
@@ -144,16 +146,14 @@ export default function DownloadOptionsMenu({
>
<Tooltip title="Download" placement="top">
<Button
className="periscope-btn ghost"
icon={
isDownloading ? (
<LoaderCircle size={14} className="animate-spin" />
) : (
<Download size={14} />
)
}
variant="ghost"
color="secondary"
size="icon"
prefix={<Download size={14} />}
aria-label="Download"
data-testid={`periscope-btn-download-${dataSource}`}
disabled={isDownloading}
loading={isDownloading}
/>
</Tooltip>
</Popover>

View File

@@ -1,7 +1,7 @@
import { DrawerProps } from 'antd';
import { AddToQueryHOCProps } from 'components/Logs/AddToQueryHOC';
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import { ActionItemProps } from 'container/LogDetailedView/ActionItem';
import { AddToQueryHOCProps } from 'components/Logs/AddToQueryHOC';
import { ActionItemProps } from 'container/LogDetailedView/LogDetailedView.types';
import { IField } from 'types/api/logs/fields';
import { ILog } from 'types/api/logs/log';

View File

@@ -19,10 +19,6 @@ jest.mock('periscope/components/DataViewer', () => ({
}));
// Force v2 for these tests regardless of route.
jest.mock('../useIsLogDetailsV2', () => ({
useIsLogDetailsV2: (): boolean => true,
}));
const mockLog: ILog = {
id: 'log-1',
timestamp: '2024-01-15T09:45:30Z',
@@ -58,7 +54,7 @@ function renderDrawer(props: Partial<LogDetailProps> = {}): void {
);
}
describe('LogDetail drawer — header (isLogDetailsV2)', () => {
describe('LogDetail drawer — header', () => {
afterEach(() => {
jest.clearAllMocks();
localStorage.clear();

View File

@@ -1,16 +1,10 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useCopyToClipboard } from 'react-use';
import { Color, Spacing } from '@signozhq/design-tokens';
import { Button } from '@signozhq/ui/button';
import { Drawer, Tooltip } from 'antd';
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
import { Divider } from '@signozhq/ui/divider';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import LogStateIndicator, {
LogType,
} from 'components/Logs/LogStateIndicator/LogStateIndicator';
import LogStateIndicator from 'components/Logs/LogStateIndicator/LogStateIndicator';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import { convertExpressionToFilters } from 'components/QueryBuilderV2/utils';
import { FeatureKeys } from 'constants/features';
@@ -19,33 +13,24 @@ import ContextView from 'container/LogDetailedView/ContextView/ContextView';
import InfraMetrics from 'container/LogDetailedView/InfraMetrics/InfraMetrics';
import Overview from 'container/LogDetailedView/Overview';
import {
aggregateAttributesResourcesToString,
getSanitizedLogBody,
removeEscapeCharacters,
} from 'container/LogDetailedView/utils';
import useInitialQuery from 'container/LogsExplorerContext/useInitialQuery';
import { useOptionsMenu } from 'container/OptionsMenu';
import { FontSize } from 'container/OptionsMenu/types';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useNotifications } from 'hooks/useNotifications';
import { cloneDeep } from 'lodash-es';
import {
ArrowDown,
ArrowUp,
Braces,
ChevronDown,
ChevronUp,
Compass,
Copy,
Filter,
Histogram,
Table,
TextSelect,
X,
} from '@signozhq/icons';
import { JsonView } from 'periscope/components/JsonView';
import { useAppContext } from 'providers/App/App';
import { ILogBody } from 'types/api/logs/log';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
@@ -56,7 +41,6 @@ import { LogDetailInnerProps, LogDetailProps } from './LogDetail.interfaces';
import LogDetailsHeader from './LogDetailsHeader/LogDetailsHeader';
import { useLogNavigation } from './LogDetailsHeader/useLogNavigation';
import LogHighlights from './LogHighlights/LogHighlights';
import { useIsLogDetailsV2 } from './useIsLogDetailsV2';
import './LogDetails.styles.scss';
@@ -64,11 +48,8 @@ import './LogDetails.styles.scss';
function LogDetailInner({
log,
onClose,
onAddToQuery,
onClickActionItem,
selectedTab,
isListViewPanel = false,
listViewPanelSelectedFields,
handleChangeSelectedView,
logs,
onNavigateLog,
@@ -81,7 +62,6 @@ function LogDetailInner({
const [contextQuery, setContextQuery] = useState<Query | undefined>(
initialContextQuery,
);
const [, copyToClipboard] = useCopyToClipboard();
const [selectedView, setSelectedView] = useState<VIEWS>(selectedTab);
const [isFilterVisible, setIsFilterVisible] = useState<boolean>(false);
@@ -94,8 +74,6 @@ function LogDetailInner({
const [isEdit, setIsEdit] = useState<boolean>(false);
const { stagedQuery } = useQueryBuilder();
const isLogDetailsV2 = useIsLogDetailsV2();
// Handle clicks outside to close drawer, except on explicitly ignored regions
useEffect(() => {
const handleClickOutside = (e: MouseEvent): void => {
@@ -173,12 +151,6 @@ function LogDetailInner({
const isDarkMode = useIsDarkMode();
const { notifications } = useNotifications();
const { onLogCopy } = useCopyLogLink(log?.id);
const LogJsonData = log ? aggregateAttributesResourcesToString(log) : '';
const handleModeChange = (value: string): void => {
setSelectedView(value as VIEWS);
setIsEdit(false);
@@ -220,13 +192,6 @@ function LogDetailInner({
[logBody],
);
const handleJSONCopy = (): void => {
copyToClipboard(LogJsonData);
notifications.success({
message: 'Copied to clipboard',
});
};
const handleQueryExpressionChange = useCallback(
(value: string, queryIndex: number) => {
// update the query at the given index
@@ -295,8 +260,6 @@ function LogDetailInner({
}
};
const logType = log?.attributes_string?.log_level || LogType.INFO;
return (
<Drawer
width="60%"
@@ -304,69 +267,15 @@ function LogDetailInner({
maskClosable={false}
getContainer={getContainer}
title={
isLogDetailsV2 ? (
<LogDetailsHeader
log={log}
onNavigatePrev={goToPrev}
onNavigateNext={goToNext}
isPrevDisabled={isPrevDisabled}
isNextDisabled={isNextDisabled}
showOpenInExplorer={!!handleOpenInExplorer}
onOpenInExplorer={handleOpenInExplorer}
/>
) : (
<div className="log-detail-drawer__title" data-log-detail-ignore="true">
<div className="log-detail-drawer__title-left">
<Divider type="vertical" className={cx('log-type-indicator', LogType)} />
<Typography.Text className="title">Log details</Typography.Text>
</div>
<div className="log-detail-drawer__title-right">
<div className="log-arrows">
<Tooltip
title={isPrevDisabled ? '' : 'Move to previous log'}
placement="top"
mouseLeaveDelay={0}
>
<Button
variant="outlined"
color="secondary"
prefix={<ChevronUp size={14} />}
className="log-arrow-btn log-arrow-btn-up"
disabled={isPrevDisabled}
onClick={goToPrev}
/>
</Tooltip>
<Tooltip
title={isNextDisabled ? '' : 'Move to next log'}
placement="top"
mouseLeaveDelay={0}
>
<Button
variant="outlined"
color="secondary"
prefix={<ChevronDown size={14} />}
className="log-arrow-btn log-arrow-btn-down"
disabled={isNextDisabled}
onClick={goToNext}
/>
</Tooltip>
</div>
{handleOpenInExplorer && (
<div>
<Button
variant="outlined"
color="secondary"
prefix={<Compass size={16} />}
className="open-in-explorer-btn"
onClick={handleOpenInExplorer}
>
Open in Explorer
</Button>
</div>
)}
</div>
</div>
)
<LogDetailsHeader
log={log}
onNavigatePrev={goToPrev}
onNavigateNext={goToNext}
isPrevDisabled={isPrevDisabled}
isNextDisabled={isNextDisabled}
showOpenInExplorer={!!handleOpenInExplorer}
onOpenInExplorer={handleOpenInExplorer}
/>
}
placement="right"
onClose={drawerCloseHandler}
@@ -385,15 +294,11 @@ function LogDetailInner({
data-testid="log-detail-drawer"
>
<div className="log-detail-drawer__log">
{isLogDetailsV2 ? (
<LogStateIndicator
severityText={log.severity_text}
severityNumber={log.severity_number}
fontSize={options?.fontSize ?? FontSize.MEDIUM}
/>
) : (
<Divider type="vertical" className={cx('log-type-indicator', logType)} />
)}
<LogStateIndicator
severityText={log.severity_text}
severityNumber={log.severity_number}
fontSize={options?.fontSize ?? FontSize.MEDIUM}
/>
<Tooltip
title={removeEscapeCharacters(logBody)}
placement="left"
@@ -405,9 +310,9 @@ function LogDetailInner({
<div className="log-overflow-shadow">&nbsp;</div>
</div>
{isLogDetailsV2 && <LogHighlights log={log} />}
<LogHighlights log={log} />
{isLogDetailsV2 && <div className="log-detail-drawer__section-divider" />}
<div className="log-detail-drawer__section-divider" />
<div className="tabs-and-search">
<ToggleGroupSimple
@@ -425,21 +330,6 @@ function LogDetailInner({
</div>
),
},
// V2's DataViewer has its own Pretty/JSON toggle, so the separate
// JSON tab is redundant.
...(isLogDetailsV2
? []
: [
{
value: VIEW_TYPES.JSON,
label: (
<div className="view-title">
<Braces size={14} />
JSON
</div>
),
},
]),
{
value: VIEW_TYPES.CONTEXT,
label: (
@@ -478,26 +368,6 @@ function LogDetailInner({
/>
</Tooltip>
)}
{/* V2 moves copy actions into the header ⋯ menu */}
{!isLogDetailsV2 && (
<Tooltip
title={selectedView === VIEW_TYPES.JSON ? 'Copy JSON' : 'Copy Log Link'}
placement="topLeft"
aria-label={
selectedView === VIEW_TYPES.JSON ? 'Copy JSON' : 'Copy Log Link'
}
mouseLeaveDelay={0}
>
<Button
variant="link"
color="secondary"
size="sm"
prefix={<Copy size={12} />}
onClick={selectedView === VIEW_TYPES.JSON ? handleJSONCopy : onLogCopy}
/>
</Tooltip>
)}
</div>
</div>
{isFilterVisible && contextQuery?.builder.queryData[0] && (
@@ -514,19 +384,11 @@ function LogDetailInner({
{selectedView === VIEW_TYPES.OVERVIEW && (
<Overview
logData={log}
onAddToQuery={onAddToQuery}
onClickActionItem={onClickActionItem}
isListViewPanel={isListViewPanel}
selectedOptions={options}
listViewPanelSelectedFields={listViewPanelSelectedFields}
handleChangeSelectedView={handleChangeSelectedView}
onApplyLogFilter={onApplyLogFilter}
/>
)}
{!isLogDetailsV2 && selectedView === VIEW_TYPES.JSON && (
<JsonView data={LogJsonData} height="68vh" />
)}
{selectedView === VIEW_TYPES.CONTEXT && (
<ContextView
log={log}

View File

@@ -1,11 +0,0 @@
import ROUTES from 'constants/routes';
import { useLocation } from 'react-router-dom';
export function useIsLogDetailsV2(): boolean {
const { pathname } = useLocation();
return (
pathname === ROUTES.LOGS_EXPLORER ||
pathname.startsWith(ROUTES.INFRASTRUCTURE_MONITORING_BASE) ||
pathname.startsWith(`${ROUTES.ALL_DASHBOARD}/`)
);
}

View File

@@ -1,54 +0,0 @@
import { ReactNode, useCallback, useEffect } from 'react';
import { useCopyToClipboard } from 'react-use';
import { Popover } from 'antd';
import { useNotifications } from 'hooks/useNotifications';
function CopyClipboardHOC({
entityKey,
textToCopy,
tooltipText = 'Copy to clipboard',
children,
}: CopyClipboardHOCProps): JSX.Element {
const [value, setCopy] = useCopyToClipboard();
const { notifications } = useNotifications();
useEffect(() => {
if (value.value) {
const key = entityKey || '';
const notificationMessage = `${key} copied to clipboard`;
notifications.success({
message: notificationMessage,
key: notificationMessage,
});
}
}, [value, notifications, entityKey]);
const onClick = useCallback((): void => {
setCopy(textToCopy);
}, [setCopy, textToCopy]);
return (
<span onClick={onClick} role="presentation" tabIndex={-1}>
<Popover
placement="top"
overlayClassName="drawer-popover"
content={<span style={{ fontSize: '0.9rem' }}>{tooltipText}</span>}
>
{children}
</Popover>
</span>
);
}
interface CopyClipboardHOCProps {
entityKey: string | undefined;
textToCopy: string;
tooltipText?: string;
children: ReactNode;
}
export default CopyClipboardHOC;
CopyClipboardHOC.defaultProps = {
tooltipText: 'Copy to clipboard',
};

View File

@@ -38,7 +38,6 @@ const ROUTES = {
LOGS_BASE: '/logs',
LOGS: '/logs/logs-explorer',
LOGS_EXPLORER: '/logs/logs-explorer',
LIVE_LOGS: '/logs/logs-explorer/live',
LOGS_PIPELINES: '/logs/pipelines',
PASSWORD_RESET: '/password-reset',
LIST_LICENSES: '/licenses',

View File

@@ -0,0 +1,109 @@
import { useState } from 'react';
import { Grid2X2 } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import logEvent from 'api/common/logEvent';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ExportPanelContainer from 'container/ExportPanel/ExportPanelContainer';
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
import { useGetExportToDashboardLink } from 'hooks/dashboard/useGetExportToDashboardLink';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { v4 } from 'uuid';
import {
EXPLORER_ACTION_EVENTS,
getExplorerActionEventPayload,
getExportPanelType,
} from './utils';
function AddToDashboardButton({
query,
sourcepage,
panelType,
isOneChartPerQuery = false,
}: {
query: Query | null;
sourcepage: DataSource;
panelType?: PANEL_TYPES;
isOneChartPerQuery?: boolean;
}): JSX.Element {
const [queryToExport, setQueryToExport] = useState<Query | null>(null);
const { panelType: contextPanelType } = useQueryBuilder();
const { safeNavigate } = useSafeNavigate();
const getExportToDashboardLink = useGetExportToDashboardLink();
const open = (): void => {
if (!query) {
return;
}
void logEvent(
EXPLORER_ACTION_EVENTS[sourcepage].addToDashboard,
getExplorerActionEventPayload({
sourcepage,
panelType: contextPanelType,
isOneChartPerQuery,
}),
);
setQueryToExport(query);
};
const handleExport = (
dashboard: ExportDashboard | null,
isNewDashboard?: boolean,
): void => {
if (!dashboard || !queryToExport) {
return;
}
const exportPanelType = panelType ?? getExportPanelType(contextPanelType);
void logEvent(EXPLORER_ACTION_EVENTS[sourcepage].exported, {
...getExplorerActionEventPayload({
sourcepage,
panelType: contextPanelType,
isOneChartPerQuery,
}),
isNewDashboard,
dashboardName: dashboard.title,
});
const link = getExportToDashboardLink({
query: queryToExport,
panelType: exportPanelType,
dashboardId: dashboard.id,
widgetId: v4(),
});
if (link) {
safeNavigate(link);
}
};
const button = (
<Button
variant="ghost"
color="secondary"
size="icon"
disabled={!query}
onClick={open}
prefix={<Grid2X2 size={16} />}
aria-label="Add to dashboard"
data-testid="explorer-add-to-dashboard"
/>
);
return (
<>
<TooltipSimple title="Add to dashboard">{button}</TooltipSimple>
<ExportPanelContainer
open={queryToExport !== null}
onClose={(): void => setQueryToExport(null)}
query={queryToExport}
onExport={handleExport}
/>
</>
);
}
export default AddToDashboardButton;

View File

@@ -0,0 +1,61 @@
import { useHistory } from 'react-router-dom';
import { ConciergeBell } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import logEvent from 'api/common/logEvent';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import {
EXPLORER_ACTION_EVENTS,
getCreateAlertLink,
getExplorerActionEventPayload,
} from './utils';
function CreateAlertButton({
query,
sourcepage,
isOneChartPerQuery = false,
}: {
query: Query | null;
sourcepage: DataSource;
isOneChartPerQuery?: boolean;
}): JSX.Element {
const history = useHistory();
const { panelType } = useQueryBuilder();
const createAlert = (): void => {
if (!query) {
return;
}
void logEvent(
EXPLORER_ACTION_EVENTS[sourcepage].createAlert,
getExplorerActionEventPayload({ sourcepage, panelType, isOneChartPerQuery }),
);
history.push(getCreateAlertLink({ query, panelType }));
};
const button = (
<Button
variant="ghost"
color="secondary"
size={isOneChartPerQuery ? 'icon' : 'md'}
disabled={!query}
onClick={createAlert}
prefix={<ConciergeBell size={16} />}
aria-label="Create an alert"
data-testid="explorer-create-alert"
>
{!isOneChartPerQuery && 'Create an alert'}
</Button>
);
return isOneChartPerQuery ? (
<TooltipSimple title="Create an alert">{button}</TooltipSimple>
) : (
button
);
}
export default CreateAlertButton;

View File

@@ -0,0 +1,39 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import AddToDashboardButton from './AddToDashboardButton';
import CreateAlertButton from './CreateAlertButton';
function ExplorerActions({
query,
dashboardQuery = query,
sourcepage,
panelType,
isOneChartPerQuery,
}: {
query: Query | null;
// When the dashboard export differs from the alert one (traces list injects columns).
dashboardQuery?: Query | null;
sourcepage: DataSource;
panelType?: PANEL_TYPES;
isOneChartPerQuery?: boolean;
}): JSX.Element {
return (
<>
<CreateAlertButton
query={query}
sourcepage={sourcepage}
isOneChartPerQuery={isOneChartPerQuery}
/>
<AddToDashboardButton
query={dashboardQuery}
sourcepage={sourcepage}
panelType={panelType}
isOneChartPerQuery={isOneChartPerQuery}
/>
</>
);
}
export default ExplorerActions;

View File

@@ -0,0 +1,320 @@
import logEvent from 'api/common/logEvent';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import {
getExportQueryData as getLogsExportQuery,
getQueryByPanelType as getLogsQueryByPanelType,
} from 'container/LogsExplorerViews/explorerUtils';
import { OptionsQuery } from 'container/OptionsMenu/types';
import {
getExportQueryData as getTracesExportQuery,
getQueryByPanelType as getTracesQueryByPanelType,
} from 'container/TracesExplorer/explorerUtils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { buildExportPanelLink } from 'pages/DashboardPage/DashboardContainer/PanelEditor/newPanelRoute';
import { render, screen, userEvent } from 'tests/test-utils';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
import AddToDashboardButton from '../AddToDashboardButton';
import { getExportPanelType } from '../utils';
const DASHBOARD = { id: 'dash-1', title: 'Dash 1' };
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: jest.fn(),
}));
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: jest.fn(),
}));
jest.mock('uuid', () => ({ v4: (): string => 'widget-1' }));
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(() => Promise.resolve()),
}));
// The picker is the dialog's business; here it just hands a dashboard back.
jest.mock('container/ExportPanel/ExportPanelContainer', () => ({
__esModule: true,
default: ({
open,
query,
onExport,
}: {
open: boolean;
query: Query | null;
onExport: (dashboard: { id: string; title: string }) => void;
}): JSX.Element | null =>
open ? (
<button
type="button"
data-testid="export-stub"
data-query={JSON.stringify(query)}
onClick={(): void => onExport({ id: 'dash-1', title: 'Dash 1' })}
>
export
</button>
) : null,
}));
const mockSafeNavigate = jest.fn();
const mockedUseQueryBuilder = jest.mocked(useQueryBuilder);
const mockedUseSafeNavigate = jest.mocked(useSafeNavigate);
const mockedLogEvent = jest.mocked(logEvent);
const FILTER = "service.name = 'frontend'";
const COLUMNS = [{ name: 'service.name' }, { name: 'name' }];
const options = { selectColumns: COLUMNS } as unknown as OptionsQuery;
function stagedQuery(dataSource: DataSource, queryName = 'A'): Query {
const base = initialQueriesMap[dataSource];
return {
...base,
id: `query-${queryName}`,
builder: {
...base.builder,
queryData: [
{
...base.builder.queryData[0],
queryName,
aggregateOperator: StringOperators.COUNT,
filter: { expression: FILTER },
orderBy: [{ columnName: 'timestamp', order: 'asc' }],
groupBy: [{ key: 'service.name', dataType: 'string', type: 'resource' }],
},
],
},
} as Query;
}
function setPanelType(panelType: PANEL_TYPES): void {
mockedUseQueryBuilder.mockReturnValue({ panelType } as ReturnType<
typeof useQueryBuilder
>);
}
async function exportTo(
query: Query | null,
sourcepage: DataSource,
panelType: PANEL_TYPES,
panelTypeProp?: PANEL_TYPES,
): Promise<void> {
setPanelType(panelType);
render(
<AddToDashboardButton
query={query}
sourcepage={sourcepage}
panelType={panelTypeProp}
/>,
);
const user = userEvent.setup();
await user.click(screen.getByTestId('explorer-add-to-dashboard'));
await user.click(screen.getByTestId('export-stub'));
}
function expectedLink(query: Query, panelType: PANEL_TYPES): string | null {
return buildExportPanelLink({
query,
panelType,
dashboardId: DASHBOARD.id,
});
}
describe('AddToDashboardButton', () => {
beforeEach(() => {
mockSafeNavigate.mockReset();
mockedLogEvent.mockClear();
mockedUseSafeNavigate.mockReturnValue({ safeNavigate: mockSafeNavigate });
});
it('is disabled without a query and the picker stays closed', () => {
setPanelType(PANEL_TYPES.LIST);
render(<AddToDashboardButton query={null} sourcepage={DataSource.LOGS} />);
expect(screen.getByTestId('explorer-add-to-dashboard')).toBeDisabled();
expect(screen.queryByTestId('export-stub')).not.toBeInTheDocument();
});
it('hands the picker the same query it will export', async () => {
const query = stagedQuery(DataSource.LOGS);
setPanelType(PANEL_TYPES.TIME_SERIES);
render(<AddToDashboardButton query={query} sourcepage={DataSource.LOGS} />);
await userEvent
.setup()
.click(screen.getByTestId('explorer-add-to-dashboard'));
expect(screen.getByTestId('export-stub')).toHaveAttribute(
'data-query',
JSON.stringify(query),
);
});
it("logs the explorer's own open and success events", async () => {
const query = stagedQuery(DataSource.TRACES);
await exportTo(query, DataSource.TRACES, PANEL_TYPES.TABLE);
expect(mockedLogEvent).toHaveBeenCalledWith(
'Traces Explorer: Add to dashboard clicked',
{
sourcepage: DataSource.TRACES,
panelType: PANEL_TYPES.TABLE,
},
);
expect(mockedLogEvent).toHaveBeenCalledWith(
'Traces Explorer: Add to dashboard successful',
{
sourcepage: DataSource.TRACES,
panelType: PANEL_TYPES.TABLE,
isNewDashboard: undefined,
dashboardName: DASHBOARD.title,
},
);
});
it('a panel type from the page wins over the fold of the context one', async () => {
const query = stagedQuery(DataSource.METRICS);
// context says list, the page says time series
await exportTo(
query,
DataSource.METRICS,
PANEL_TYPES.LIST,
PANEL_TYPES.TIME_SERIES,
);
expect(mockSafeNavigate).toHaveBeenCalledWith(
expectedLink(query, PANEL_TYPES.TIME_SERIES),
);
});
describe('logs, the query the page hands over per view', () => {
const staged = stagedQuery(DataSource.LOGS);
it('list: the list request shaping with timestamp desc, panel type list', async () => {
const listRequest = getLogsQueryByPanelType(staged, PANEL_TYPES.LIST, {
page: 1,
pageSize: 100,
filters: { items: [], op: 'AND' },
filter: { expression: FILTER },
});
const exportQuery = getLogsExportQuery(
listRequest,
PANEL_TYPES.LIST,
) as Query;
await exportTo(exportQuery, DataSource.LOGS, PANEL_TYPES.LIST);
expect(exportQuery.builder.queryData[0].orderBy).toStrictEqual([
{ columnName: 'timestamp', order: 'desc' },
]);
expect(mockSafeNavigate).toHaveBeenCalledWith(
expectedLink(exportQuery, PANEL_TYPES.LIST),
);
});
it.each([PANEL_TYPES.TIME_SERIES, PANEL_TYPES.TABLE])(
'%s: staged query untouched, same panel type',
async (panelType) => {
const exportQuery = getLogsExportQuery(staged, panelType) as Query;
await exportTo(exportQuery, DataSource.LOGS, panelType);
expect(exportQuery).toBe(staged);
expect(mockSafeNavigate).toHaveBeenCalledWith(
expectedLink(staged, panelType),
);
},
);
});
describe('traces, the query the page hands over per view', () => {
const staged = stagedQuery(DataSource.TRACES);
it('list: list shaping plus the selected columns, panel type list', async () => {
const exportQuery = getTracesExportQuery(
getTracesQueryByPanelType(staged, PANEL_TYPES.LIST),
getExportPanelType(PANEL_TYPES.LIST),
options,
);
await exportTo(exportQuery, DataSource.TRACES, PANEL_TYPES.LIST);
const [queryData] = exportQuery.builder.queryData;
expect(queryData.selectColumns).toStrictEqual(COLUMNS);
expect(queryData.groupBy).toStrictEqual([]);
expect(mockSafeNavigate).toHaveBeenCalledWith(
expectedLink(exportQuery, PANEL_TYPES.LIST),
);
});
it('trace: list shaping, no columns, panel type folds to time series', async () => {
const exportQuery = getTracesExportQuery(
getTracesQueryByPanelType(staged, PANEL_TYPES.TRACE),
getExportPanelType(PANEL_TYPES.TRACE),
options,
);
await exportTo(exportQuery, DataSource.TRACES, PANEL_TYPES.TRACE);
expect(exportQuery.builder.queryData[0].selectColumns).toBeUndefined();
expect(mockSafeNavigate).toHaveBeenCalledWith(
expectedLink(exportQuery, PANEL_TYPES.TIME_SERIES),
);
});
// Same as the alert: the list / trace order lives in ListView state and the
// page shapes the export without it, so the panel query has no order by.
it.each([PANEL_TYPES.LIST, PANEL_TYPES.TRACE])(
'%s: order by is not carried into the panel query',
async (panelType) => {
expect(staged.builder.queryData[0].orderBy).toHaveLength(1);
const exportQuery = getTracesExportQuery(
getTracesQueryByPanelType(staged, panelType),
getExportPanelType(panelType),
options,
);
await exportTo(exportQuery, DataSource.TRACES, panelType);
expect(exportQuery.builder.queryData[0].orderBy).toStrictEqual([]);
expect(mockSafeNavigate).toHaveBeenCalledWith(
expectedLink(exportQuery, getExportPanelType(panelType)),
);
},
);
it.each([PANEL_TYPES.TIME_SERIES, PANEL_TYPES.TABLE])(
'%s: staged query untouched, same panel type',
async (panelType) => {
const exportQuery = getTracesExportQuery(
getTracesQueryByPanelType(staged, panelType),
getExportPanelType(panelType),
options,
);
await exportTo(exportQuery, DataSource.TRACES, panelType);
expect(exportQuery).toBe(staged);
expect(mockSafeNavigate).toHaveBeenCalledWith(
expectedLink(staged, panelType),
);
},
);
});
it('metrics: the chart query as is, panel type time series from the page', async () => {
const query = stagedQuery(DataSource.METRICS);
await exportTo(
query,
DataSource.METRICS,
PANEL_TYPES.TIME_SERIES,
PANEL_TYPES.TIME_SERIES,
);
expect(mockSafeNavigate).toHaveBeenCalledWith(
expectedLink(query, PANEL_TYPES.TIME_SERIES),
);
});
});

View File

@@ -0,0 +1,245 @@
import { useHistory } from 'react-router-dom';
import logEvent from 'api/common/logEvent';
import { QueryParams } from 'constants/query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import {
getExportQueryData as getLogsExportQuery,
getQueryByPanelType as getLogsQueryByPanelType,
} from 'container/LogsExplorerViews/explorerUtils';
import { getQueryByPanelType as getTracesQueryByPanelType } from 'container/TracesExplorer/explorerUtils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { render, screen, userEvent } from 'tests/test-utils';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
import CreateAlertButton from '../CreateAlertButton';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useHistory: jest.fn(),
}));
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: jest.fn(),
}));
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(() => Promise.resolve()),
}));
const mockPush = jest.fn();
const mockedUseHistory = jest.mocked(useHistory);
const mockedUseQueryBuilder = jest.mocked(useQueryBuilder);
const mockedLogEvent = jest.mocked(logEvent);
const FILTER = "service.name = 'frontend'";
const ORDER_BY = [{ columnName: 'timestamp', order: 'asc' }];
function stagedQuery(
dataSource: DataSource,
aggregateOperator: StringOperators,
queryName = 'A',
): Query {
const base = initialQueriesMap[dataSource];
return {
...base,
id: `query-${queryName}`,
builder: {
...base.builder,
queryData: [
{
...base.builder.queryData[0],
queryName,
aggregateOperator,
filter: { expression: FILTER },
orderBy: ORDER_BY,
groupBy: [{ key: 'service.name', dataType: 'string', type: 'resource' }],
},
],
},
} as Query;
}
function pushedQuery(): Query {
expect(mockPush).toHaveBeenCalledTimes(1);
const [path, search] = (mockPush.mock.calls[0][0] as string).split('?');
expect(path).toBe(ROUTES.ALERTS_NEW);
const raw = new URLSearchParams(search).get(QueryParams.compositeQuery);
return JSON.parse(raw as string);
}
function setPanelType(panelType: PANEL_TYPES): void {
mockedUseQueryBuilder.mockReturnValue({ panelType } as ReturnType<
typeof useQueryBuilder
>);
}
async function clickCreateAlert(
query: Query | null,
sourcepage: DataSource,
panelType: PANEL_TYPES,
): Promise<void> {
setPanelType(panelType);
render(<CreateAlertButton query={query} sourcepage={sourcepage} />);
await userEvent.setup().click(screen.getByTestId('explorer-create-alert'));
}
describe('CreateAlertButton', () => {
beforeEach(() => {
mockPush.mockReset();
mockedLogEvent.mockClear();
mockedUseHistory.mockReturnValue({ push: mockPush } as unknown as ReturnType<
typeof useHistory
>);
});
it('is disabled and does nothing without a query', async () => {
await clickCreateAlert(null, DataSource.LOGS, PANEL_TYPES.LIST);
expect(screen.getByTestId('explorer-create-alert')).toBeDisabled();
expect(mockPush).not.toHaveBeenCalled();
});
it("logs the explorer's own create alert event", async () => {
const query = stagedQuery(DataSource.TRACES, StringOperators.COUNT);
await clickCreateAlert(query, DataSource.TRACES, PANEL_TYPES.TIME_SERIES);
expect(mockedLogEvent).toHaveBeenCalledWith('Traces Explorer: Create alert', {
sourcepage: DataSource.TRACES,
panelType: PANEL_TYPES.TIME_SERIES,
});
});
it('one chart per query on metrics: icon only, and the event carries the layout', async () => {
setPanelType(PANEL_TYPES.TIME_SERIES);
render(
<CreateAlertButton
query={stagedQuery(DataSource.METRICS, StringOperators.COUNT)}
sourcepage={DataSource.METRICS}
isOneChartPerQuery
/>,
);
const button = screen.getByTestId('explorer-create-alert');
expect(button).not.toHaveTextContent('Create an alert');
await userEvent.setup().click(button);
expect(mockedLogEvent).toHaveBeenCalledWith(
'Metrics Explorer: Add to alert clicked',
{
sourcepage: DataSource.METRICS,
panelType: PANEL_TYPES.TIME_SERIES,
tab: 'explorer',
oneChartPerQueryEnabled: true,
},
);
});
describe('logs, the query the page hands over per view', () => {
const staged = stagedQuery(DataSource.LOGS, StringOperators.NOOP);
it('list: count aggregation, no order by, filter and pagination as the page sent them', async () => {
const listRequest = getLogsQueryByPanelType(staged, PANEL_TYPES.LIST, {
page: 1,
pageSize: 100,
filters: { items: [], op: 'AND' },
filter: { expression: FILTER },
});
const exportQuery = getLogsExportQuery(
listRequest,
PANEL_TYPES.LIST,
) as Query;
await clickCreateAlert(exportQuery, DataSource.LOGS, PANEL_TYPES.LIST);
const [queryData] = pushedQuery().builder.queryData;
expect(queryData.aggregateOperator).toBe(StringOperators.COUNT);
expect(queryData.orderBy).toStrictEqual([]);
expect(queryData.groupBy).toStrictEqual([]);
expect(queryData.filter).toStrictEqual({ expression: FILTER });
expect(queryData.pageSize).toBe(100);
});
it('time series: staged query as is, order by and group by kept', async () => {
const tsStaged = stagedQuery(DataSource.LOGS, StringOperators.COUNT);
const exportQuery = getLogsExportQuery(
tsStaged,
PANEL_TYPES.TIME_SERIES,
) as Query;
await clickCreateAlert(
exportQuery,
DataSource.LOGS,
PANEL_TYPES.TIME_SERIES,
);
const [queryData] = pushedQuery().builder.queryData;
expect(queryData).toStrictEqual(tsStaged.builder.queryData[0]);
});
it('table: staged query as is', async () => {
const tableStaged = stagedQuery(DataSource.LOGS, StringOperators.COUNT);
const exportQuery = getLogsExportQuery(
tableStaged,
PANEL_TYPES.TABLE,
) as Query;
await clickCreateAlert(exportQuery, DataSource.LOGS, PANEL_TYPES.TABLE);
expect(pushedQuery().builder).toStrictEqual(tableStaged.builder);
});
});
describe('traces, the query the page hands over per view', () => {
const staged = stagedQuery(DataSource.TRACES, StringOperators.NOOP);
it.each([PANEL_TYPES.LIST, PANEL_TYPES.TRACE])(
'%s: count aggregation, group by cleared by the list shaping, filter kept',
async (panelType) => {
const exportQuery = getTracesQueryByPanelType(staged, panelType);
await clickCreateAlert(exportQuery, DataSource.TRACES, panelType);
const [queryData] = pushedQuery().builder.queryData;
expect(queryData.aggregateOperator).toBe(StringOperators.COUNT);
expect(queryData.groupBy).toStrictEqual([]);
expect(queryData.filter).toStrictEqual({ expression: FILTER });
},
);
// The list / trace views keep their order in ListView state, and the page
// shapes the export without it, so the alert never sees an order by.
it.each([PANEL_TYPES.LIST, PANEL_TYPES.TRACE])(
'%s: order by is not carried, even when the staged query has one',
async (panelType) => {
expect(staged.builder.queryData[0].orderBy).toStrictEqual(ORDER_BY);
const exportQuery = getTracesQueryByPanelType(staged, panelType);
await clickCreateAlert(exportQuery, DataSource.TRACES, panelType);
expect(pushedQuery().builder.queryData[0].orderBy).toStrictEqual([]);
},
);
it.each([PANEL_TYPES.TIME_SERIES, PANEL_TYPES.TABLE])(
'%s: staged query as is',
async (panelType) => {
const aggStaged = stagedQuery(DataSource.TRACES, StringOperators.COUNT);
const exportQuery = getTracesQueryByPanelType(aggStaged, panelType);
await clickCreateAlert(exportQuery, DataSource.TRACES, panelType);
expect(pushedQuery().builder).toStrictEqual(aggStaged.builder);
},
);
});
it('metrics: the chart query as is', async () => {
const query = stagedQuery(DataSource.METRICS, StringOperators.COUNT);
await clickCreateAlert(query, DataSource.METRICS, PANEL_TYPES.TIME_SERIES);
expect(pushedQuery().builder).toStrictEqual(query.builder);
});
});

View File

@@ -0,0 +1,144 @@
import { QueryParams } from 'constants/query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { StringOperators } from 'types/common/queryBuilder';
import { getCreateAlertLink, getExportPanelType } from '../utils';
function withFirstQuery(
base: Query,
overrides: Partial<Query['builder']['queryData'][number]>,
): Query {
return {
...base,
builder: {
...base.builder,
queryData: [{ ...base.builder.queryData[0], ...overrides }],
},
};
}
function decodeQuery(link: string): Query {
const search = link.split('?')[1];
const raw = new URLSearchParams(search).get(QueryParams.compositeQuery);
return JSON.parse(raw as string);
}
describe('getExportPanelType', () => {
it.each([PANEL_TYPES.TIME_SERIES, PANEL_TYPES.TABLE, PANEL_TYPES.LIST])(
'keeps %s',
(panelType) => {
expect(getExportPanelType(panelType)).toBe(panelType);
},
);
it.each([PANEL_TYPES.BAR, PANEL_TYPES.PIE, PANEL_TYPES.TRACE, null])(
'folds %s to time series',
(panelType) => {
expect(getExportPanelType(panelType)).toBe(PANEL_TYPES.TIME_SERIES);
},
);
});
describe('getCreateAlertLink', () => {
const orderBy = [{ columnName: 'timestamp', order: 'desc' }];
it('points at the new alert route with the query in the url', () => {
const query = initialQueriesMap.traces;
const link = getCreateAlertLink({
query,
panelType: PANEL_TYPES.TIME_SERIES,
});
expect(link.startsWith(`${ROUTES.ALERTS_NEW}?`)).toBe(true);
expect(decodeQuery(link)).toStrictEqual(query);
});
it('logs list: noop becomes count and order by is dropped', () => {
const query = withFirstQuery(initialQueriesMap.logs, {
aggregateOperator: StringOperators.NOOP,
orderBy,
});
const [queryData] = decodeQuery(
getCreateAlertLink({
query,
panelType: PANEL_TYPES.LIST,
}),
).builder.queryData;
expect(queryData.aggregateOperator).toBe(StringOperators.COUNT);
expect(queryData.orderBy).toStrictEqual([]);
});
it('logs time series keeps order by', () => {
const query = withFirstQuery(initialQueriesMap.logs, {
aggregateOperator: StringOperators.COUNT,
orderBy,
});
const [queryData] = decodeQuery(
getCreateAlertLink({
query,
panelType: PANEL_TYPES.TIME_SERIES,
}),
).builder.queryData;
expect(queryData.orderBy).toStrictEqual(orderBy);
});
it.each([PANEL_TYPES.LIST, PANEL_TYPES.TRACE])(
'%s drops order by whatever the source',
(panelType) => {
const query = withFirstQuery(initialQueriesMap.traces, {
aggregateOperator: StringOperators.NOOP,
orderBy,
});
const [queryData] = decodeQuery(getCreateAlertLink({ query, panelType }))
.builder.queryData;
expect(queryData.aggregateOperator).toBe(StringOperators.COUNT);
expect(queryData.orderBy).toStrictEqual([]);
},
);
it('converts a noop on any query, not only the first', () => {
const first = initialQueriesMap.logs.builder.queryData[0];
const query: Query = {
...initialQueriesMap.logs,
builder: {
...initialQueriesMap.logs.builder,
queryData: [
{ ...first, aggregateOperator: StringOperators.COUNT },
{ ...first, queryName: 'B', aggregateOperator: StringOperators.NOOP },
],
},
};
const operators = decodeQuery(
getCreateAlertLink({ query, panelType: PANEL_TYPES.TIME_SERIES }),
).builder.queryData.map((item) => item.aggregateOperator);
expect(operators).toStrictEqual([
StringOperators.COUNT,
StringOperators.COUNT,
]);
});
it('does not mutate the query it is given', () => {
const query = withFirstQuery(initialQueriesMap.logs, {
aggregateOperator: StringOperators.NOOP,
orderBy,
});
const snapshot = JSON.stringify(query);
getCreateAlertLink({
query,
panelType: PANEL_TYPES.LIST,
});
expect(JSON.stringify(query)).toBe(snapshot);
});
});

View File

@@ -0,0 +1,85 @@
import { AVAILABLE_EXPORT_PANEL_TYPES } from 'constants/panelTypes';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import {
MetricsExplorerEventKeys,
MetricsExplorerEvents,
} from 'container/MetricsExplorer/events';
import { cloneDeep } from 'lodash-es';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
export const EXPLORER_ACTION_EVENTS: Record<
DataSource,
{ createAlert: string; addToDashboard: string; exported: string }
> = {
[DataSource.LOGS]: {
createAlert: 'Logs Explorer: Create alert',
addToDashboard: 'Logs Explorer: Add to dashboard clicked',
exported: 'Logs Explorer: Add to dashboard successful',
},
[DataSource.TRACES]: {
createAlert: 'Traces Explorer: Create alert',
addToDashboard: 'Traces Explorer: Add to dashboard clicked',
exported: 'Traces Explorer: Add to dashboard successful',
},
[DataSource.METRICS]: {
createAlert: MetricsExplorerEvents.AddToAlertClicked,
addToDashboard: MetricsExplorerEvents.AddToDashboardClicked,
exported: MetricsExplorerEvents.AddToDashboardSuccessful,
},
};
export function getExplorerActionEventPayload({
sourcepage,
panelType,
isOneChartPerQuery,
}: {
sourcepage: DataSource;
panelType: PANEL_TYPES | null;
isOneChartPerQuery: boolean;
}): Record<string, unknown> {
if (sourcepage !== DataSource.METRICS) {
return { sourcepage, panelType };
}
return {
sourcepage,
panelType,
[MetricsExplorerEventKeys.Tab]: 'explorer',
[MetricsExplorerEventKeys.OneChartPerQueryEnabled]: isOneChartPerQuery,
};
}
export function getExportPanelType(panelType: PANEL_TYPES | null): PANEL_TYPES {
return panelType && AVAILABLE_EXPORT_PANEL_TYPES.includes(panelType)
? panelType
: PANEL_TYPES.TIME_SERIES;
}
// Alerts need an aggregation, and list style views carry an order the alert
// cannot use.
export function getCreateAlertLink({
query,
panelType,
}: {
query: Query;
panelType: PANEL_TYPES | null;
}): string {
const isListStyle =
panelType === PANEL_TYPES.LIST || panelType === PANEL_TYPES.TRACE;
const alertQuery = cloneDeep(query);
alertQuery.builder.queryData = alertQuery.builder.queryData.map((item) => ({
...item,
aggregateOperator:
item.aggregateOperator === StringOperators.NOOP
? StringOperators.COUNT
: item.aggregateOperator,
orderBy: isListStyle ? [] : item.orderBy,
}));
return `${ROUTES.ALERTS_NEW}?${QueryParams.compositeQuery}=${encodeURIComponent(
JSON.stringify(alertQuery),
)}`;
}

View File

@@ -2,7 +2,7 @@ import { useMemo } from 'react';
import type { ColumnsType } from 'antd/lib/table';
import { ResizeTable } from 'components/ResizeTable';
import FieldRenderer from 'container/LogDetailedView/FieldRenderer';
import { DataType } from 'container/LogDetailedView/TableView';
import { DataType } from 'container/LogDetailedView/LogDetailedView.types';
import styles from './EventsContent.module.scss';

View File

@@ -1,64 +0,0 @@
import { memo, useCallback, useMemo } from 'react';
import { CircleMinus, CirclePlus } from '@signozhq/icons';
import { Button, Col, Popover } from 'antd';
import { OPERATORS } from 'constants/queryBuilder';
import { removeJSONStringifyQuotes } from 'lib/removeJSONStringifyQuotes';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
function ActionItem({
fieldKey,
fieldValue,
onClickActionItem,
}: ActionItemProps): JSX.Element {
const handleClick = useCallback(
(operator: string) => {
const validatedFieldValue = removeJSONStringifyQuotes(fieldValue);
onClickActionItem(fieldKey, validatedFieldValue, operator);
},
[onClickActionItem, fieldKey, fieldValue],
);
const onClickHandler = useCallback(
(operator: string) => (): void => {
handleClick(operator);
},
[handleClick],
);
const PopOverMenuContent = useMemo(
() => (
<Col>
<Button type="text" size="small" onClick={onClickHandler(OPERATORS.IN)}>
<CirclePlus size={12} /> Filter for value
</Button>
<br />
<Button type="text" size="small" onClick={onClickHandler(OPERATORS.NIN)}>
<CircleMinus size={12} /> Filter out value
</Button>
</Col>
),
[onClickHandler],
);
return (
<Popover placement="bottomLeft" content={PopOverMenuContent} trigger="click">
<Button type="text" size="small">
...
</Button>
</Popover>
);
}
export interface ActionItemProps {
fieldKey: string;
fieldValue: string;
onClickActionItem: (
fieldKey: string,
fieldValue: string,
operator: string,
dataType?: DataTypes,
fieldType?: string,
) => void;
}
export default memo(ActionItem);

View File

@@ -1,14 +0,0 @@
import styled from 'styled-components';
export const TitleWrapper = styled.span`
user-select: text !important;
cursor: text;
.hover-reveal {
visibility: hidden;
}
&:hover .hover-reveal {
visibility: visible;
}
`;

View File

@@ -1,247 +0,0 @@
import { useCallback } from 'react';
import { useCopyToClipboard } from 'react-use';
import { orange } from '@ant-design/colors';
import { Settings } from '@signozhq/icons';
import {
type BaseMenuItem,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@signozhq/ui/dropdown-menu';
import {
negateOperator,
OPERATORS,
QUERY_BUILDER_FUNCTIONS,
} from 'constants/antlrQueryConstants';
import { FeatureKeys } from 'constants/features';
import { useActiveLog } from 'hooks/logs/useActiveLog';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { ICurrentQueryData } from 'hooks/useHandleExplorerTabChange';
import { useNotifications } from 'hooks/useNotifications';
import { ExplorerViews } from 'pages/LogsExplorer/utils';
import { useAppContext } from 'providers/App/App';
import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TitleWrapper } from './BodyTitleRenderer.styles';
import { DROPDOWN_KEY } from './constant';
import { BodyTitleRendererProps } from './LogDetailedView.types';
import {
generateFieldKeyForArray,
getDataTypes,
removeObjectFromString,
} from './utils';
function BodyTitleRenderer({
title,
parentIsArray = false,
nodeKey,
value,
handleChangeSelectedView,
}: BodyTitleRendererProps): JSX.Element {
const { onAddToQuery } = useActiveLog();
const { stagedQuery, updateQueriesData } = useQueryBuilder();
const { featureFlags } = useAppContext();
const [, setCopy] = useCopyToClipboard();
const { notifications } = useNotifications();
const cleanedNodeKey = removeObjectFromString(nodeKey);
const isBodyJsonQueryEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.USE_JSON_BODY)
?.active || false;
// Group by is supported only for body json query enabled and not for array elements
const isGroupBySupported =
isBodyJsonQueryEnabled && !cleanedNodeKey.includes('[]');
const filterHandler = (isFilterIn: boolean) => (): void => {
if (parentIsArray) {
onAddToQuery(
generateFieldKeyForArray(
cleanedNodeKey,
getDataTypes(value),
isBodyJsonQueryEnabled,
),
`${value}`,
isFilterIn
? QUERY_BUILDER_FUNCTIONS.HAS
: negateOperator(QUERY_BUILDER_FUNCTIONS.HAS),
parentIsArray ? getDataTypes([value]) : getDataTypes(value),
);
} else {
onAddToQuery(
`body.${cleanedNodeKey}`,
`${value}`,
isFilterIn ? OPERATORS['='] : OPERATORS['!='],
getDataTypes(value),
);
}
};
const groupByHandler = useCallback((): void => {
if (!stagedQuery) {
return;
}
const groupByKey = parentIsArray
? generateFieldKeyForArray(
cleanedNodeKey,
getDataTypes(value),
isBodyJsonQueryEnabled,
)
: `body.${cleanedNodeKey}`;
const fieldDataType = getDataTypes(value);
const normalizedDataType: DataTypes | undefined = Object.values(
DataTypes,
).includes(fieldDataType as DataTypes)
? (fieldDataType as DataTypes)
: undefined;
const updatedQuery = updateQueriesData(
stagedQuery,
'queryData',
(item, index) => {
if (index === 0) {
const newGroupByItem: BaseAutocompleteData = {
key: groupByKey,
type: '',
dataType: normalizedDataType,
};
return { ...item, groupBy: [...(item.groupBy || []), newGroupByItem] };
}
return item;
},
);
const queryData: ICurrentQueryData = {
query: updatedQuery,
};
handleChangeSelectedView?.(ExplorerViews.TIMESERIES, queryData);
}, [
cleanedNodeKey,
handleChangeSelectedView,
isBodyJsonQueryEnabled,
parentIsArray,
stagedQuery,
updateQueriesData,
value,
]);
const onClickHandler = (key: string): void => {
const mapper = {
[DROPDOWN_KEY.FILTER_IN]: filterHandler(true),
[DROPDOWN_KEY.FILTER_OUT]: filterHandler(false),
[DROPDOWN_KEY.GROUP_BY]: groupByHandler,
};
const handler = mapper[key];
if (handler) {
handler();
}
};
const menuItems: BaseMenuItem[] = [
{
key: DROPDOWN_KEY.FILTER_IN,
label: `Filter for ${value}`,
},
{
key: DROPDOWN_KEY.FILTER_OUT,
label: `Filter out ${value}`,
},
...(isGroupBySupported
? [
{
key: DROPDOWN_KEY.GROUP_BY,
label: `Group by ${nodeKey}`,
},
]
: []),
];
const handleNodeClick = useCallback(
(e: React.MouseEvent): void => {
// Prevent tree node expansion/collapse
e.stopPropagation();
let copyText: string;
// Check if value is an object or array
const isObject = typeof value === 'object' && value !== null;
if (isObject) {
// For objects/arrays, stringify the entire structure
copyText = JSON.stringify(value, null, 2);
} else if (parentIsArray) {
// array elements
copyText = `${value}`;
} else {
// primitive values
const valueStr = typeof value === 'string' ? value : String(value);
copyText = valueStr;
}
setCopy(copyText);
if (copyText) {
const notificationMessage = isObject
? `${cleanedNodeKey} object copied to clipboard`
: `${cleanedNodeKey} copied to clipboard`;
notifications.success({
message: notificationMessage,
key: notificationMessage,
});
}
},
[cleanedNodeKey, parentIsArray, setCopy, value, notifications],
);
return (
<TitleWrapper onClick={handleNodeClick}>
{typeof value !== 'object' && (
<span
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
}}
onMouseDown={(e): void => e.preventDefault()}
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Settings style={{ marginRight: 8 }} className="hover-reveal" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<div data-log-detail-ignore="true">
{menuItems.map((item) => (
<DropdownMenuItem
key={item.key}
onSelect={(): void => onClickHandler(item.key as string)}
>
{item.label}
</DropdownMenuItem>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
</span>
)}
{title.toString()}{' '}
{!parentIsArray && typeof value !== 'object' && (
<span>
: <span style={{ color: orange[6] }}>{`${value}`}</span>
</span>
)}
</TitleWrapper>
);
}
export default BodyTitleRenderer;

View File

@@ -1,92 +0,0 @@
import { useMemo, useState } from 'react';
import MEditor, { EditorProps, Monaco } from '@monaco-editor/react';
import { Color } from '@signozhq/design-tokens';
import { Switch } from '@signozhq/ui/switch';
import { Typography } from '@signozhq/ui/typography';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { JSONViewProps } from './LogDetailedView.types';
import { aggregateAttributesResourcesToString } from './utils';
import './JsonView.styles.scss';
function JSONView({ logData }: JSONViewProps): JSX.Element {
const [isWrapWord, setIsWrapWord] = useState<boolean>(true);
const LogJsonData = useMemo(
() => aggregateAttributesResourcesToString(logData),
[logData],
);
const isDarkMode = useIsDarkMode();
const options: EditorProps['options'] = {
automaticLayout: true,
readOnly: true,
wordWrap: isWrapWord ? 'on' : 'off',
minimap: {
enabled: false,
},
fontWeight: '400',
// fontFamily: 'SF Mono',
fontFamily: 'Geist Mono',
fontSize: 13,
lineHeight: 18,
colorDecorators: true,
scrollBeyondLastLine: false,
scrollbar: {
vertical: 'hidden',
horizontal: 'hidden',
},
folding: false,
};
const handleWrapWord = (checked: boolean): void => {
setIsWrapWord(checked);
};
function setEditorTheme(monaco: Monaco): void {
monaco.editor.defineTheme('my-theme', {
base: 'vs-dark',
inherit: true,
rules: [
{ token: 'string.key.json', foreground: Color.BG_VANILLA_400 },
{ token: 'string.value.json', foreground: Color.BG_ROBIN_400 },
],
colors: {
'editor.background': Color.BG_INK_400,
},
// fontFamily: 'SF Mono',
fontFamily: 'Space Mono',
fontSize: 12,
fontWeight: 'normal',
lineHeight: 18,
letterSpacing: -0.06,
});
}
return (
<div className="json-view-container">
<MEditor
value={LogJsonData}
language="json"
options={options}
onChange={(): void => {}}
height="68vh"
theme={isDarkMode ? 'my-theme' : 'light'}
beforeMount={setEditorTheme}
/>
<div className="json-view-footer">
<div className="log-switch">
<div className="wrap-word-switch">
<Typography.Text>Wrap text</Typography.Text>
<Switch value={isWrapWord} onChange={handleWrapWord} />
</div>
</div>
</div>
</div>
);
}
export default JSONView;

View File

@@ -1,14 +1,5 @@
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import { MetricsType } from 'container/MetricsApplication/constant';
import { ILog } from 'types/api/logs/log';
export interface BodyTitleRendererProps {
title: string;
nodeKey: string;
value: unknown;
parentIsArray?: boolean;
handleChangeSelectedView?: ChangeViewFunctionType;
}
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
export type AnyObject = { [key: string]: any };
@@ -22,6 +13,21 @@ export interface IFieldAttributes {
logType?: MetricsType;
}
export interface JSONViewProps {
logData: ILog;
/** One key/field/value row in an attribute table. */
export interface DataType {
key: string;
field: string;
value: string;
}
export interface ActionItemProps {
fieldKey: string;
fieldValue: string;
onClickActionItem: (
fieldKey: string,
fieldValue: string,
operator: string,
dataType?: DataTypes,
fieldType?: string,
) => void;
}

View File

@@ -1,32 +1,13 @@
import { ReactNode, useState } from 'react';
import MEditor, { EditorProps, Monaco } from '@monaco-editor/react';
import { Color } from '@signozhq/design-tokens';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { Switch } from '@signozhq/ui/switch';
import { Collapse } from 'antd';
import { Divider } from '@signozhq/ui/divider';
import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import { AddToQueryHOCProps } from 'components/Logs/AddToQueryHOC';
import { ReactNode } from 'react';
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import { OptionsQuery } from 'container/OptionsMenu/types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { ChevronDown, ChevronRight, Search } from '@signozhq/icons';
import { useIsLogDetailsV2 } from 'components/LogDetail/useIsLogDetailsV2';
import { DataViewer } from 'periscope/components/DataViewer';
import { IField } from 'types/api/logs/fields';
import { ILog } from 'types/api/logs/log';
import { ActionItemProps } from './ActionItem';
import { useLogAttributeActions } from './hooks/useLogAttributeActions';
import TableView from './TableView';
import {
aggregateAttributesResourcesToObject,
buildPrettyViewData,
getBodyDisplayString,
getSanitizedLogBody,
removeEscapeCharacters,
} from './utils';
import './Overview.styles.scss';
@@ -38,247 +19,58 @@ const MAX_BODY_SANITIZE_CHARS = 64 * 1024;
interface OverviewProps {
logData: ILog;
isListViewPanel?: boolean;
selectedOptions: OptionsQuery;
listViewPanelSelectedFields?: IField[] | null;
handleChangeSelectedView?: ChangeViewFunctionType;
onApplyLogFilter?: (expression: string) => void;
}
type Props = OverviewProps &
Partial<Pick<ActionItemProps, 'onClickActionItem'>> &
Pick<AddToQueryHOCProps, 'onAddToQuery'>;
function Overview({
logData,
onAddToQuery,
onClickActionItem,
isListViewPanel = false,
selectedOptions,
listViewPanelSelectedFields,
handleChangeSelectedView,
onApplyLogFilter,
}: Props): JSX.Element {
const [isWrapWord, setIsWrapWord] = useState<boolean>(true);
const [isSearchVisible, setIsSearchVisible] = useState<boolean>(true);
const [isAttributesExpanded, setIsAttributesExpanded] =
useState<boolean>(true);
const [fieldSearchInput, setFieldSearchInput] = useState<string>('');
const isDarkMode = useIsDarkMode();
}: OverviewProps): JSX.Element {
const { actions, visibleActions } = useLogAttributeActions({
handleChangeSelectedView,
isListViewPanel,
onApplyLogFilter,
});
const isLogDetailsV2 = useIsLogDetailsV2();
if (isLogDetailsV2) {
const raw = aggregateAttributesResourcesToObject(logData);
const prettyData = buildPrettyViewData(raw);
return (
<div className="overview-container">
<DataViewer
data={prettyData}
drawerKey="logs-details"
fontSize={13}
prettyViewProps={{
actions,
visibleActions,
renderLeafValue: (value, keyPath): ReactNode | undefined => {
// Sanitize (unescape + ANSI→color) string values under `body`.
// Skip huge ones (render raw, still safe) to avoid the sanitize
// choke;
if (
typeof value !== 'string' ||
keyPath[keyPath.length - 1] !== 'body' ||
value.length > MAX_BODY_SANITIZE_CHARS
) {
return undefined;
}
return (
<span
className="log-body-value"
// Safe: getSanitizedLogBody runs the value through dompurify.
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{
__html: getSanitizedLogBody(value, { shouldEscapeHtml: true }),
}}
/>
);
},
}}
jsonString={JSON.stringify(raw, null, 2)}
/>
</div>
);
}
const options: EditorProps['options'] = {
automaticLayout: true,
readOnly: true,
wordWrap: isWrapWord ? 'on' : 'off',
minimap: {
enabled: false,
},
fontWeight: '400',
fontFamily: 'Geist Mono',
fontSize: 13,
lineHeight: 18,
colorDecorators: true,
scrollBeyondLastLine: false,
scrollbar: {
vertical: 'hidden',
horizontal: 'hidden',
},
};
const handleWrapWord = (checked: boolean): void => {
setIsWrapWord(checked);
};
function setEditorTheme(monaco: Monaco): void {
monaco.editor.defineTheme('my-theme', {
base: 'vs-dark',
inherit: true,
rules: [
{ token: 'string.key.json', foreground: Color.BG_VANILLA_400 },
{ token: 'string.value.json', foreground: Color.BG_ROBIN_400 },
],
colors: {
'editor.background': Color.BG_INK_400,
},
});
}
const handleSearchVisible = (): void => {
setIsSearchVisible(!isSearchVisible);
};
const toogleAttributePanelOpenState = (): void => {
setIsAttributesExpanded(!isAttributesExpanded);
};
const raw = aggregateAttributesResourcesToObject(logData);
const prettyData = buildPrettyViewData(raw);
return (
<div className="overview-container">
<Collapse
defaultActiveKey={['1']}
expandIcon={(props): ReactNode =>
props.isActive ? <ChevronDown size={14} /> : <ChevronRight size={14} />
}
items={[
{
key: '1',
label: (
<Badge color="vanilla">
<Typography.Text style={{ color: Color.BG_ROBIN_400 }}>
body
</Typography.Text>
</Badge>
),
children: (
<div className="logs-body-content">
<MEditor
value={removeEscapeCharacters(getBodyDisplayString(logData.body))}
language="json"
options={options}
onChange={(): void => {}}
height="20vh"
theme={isDarkMode ? 'my-theme' : 'light'}
onMount={(_, monaco): void => {
document.fonts.ready.then(() => {
monaco.editor.remeasureFonts();
});
}}
beforeMount={setEditorTheme}
/>
<Divider
style={{
margin: 0,
border: isDarkMode
? `1px solid ${Color.BG_SLATE_500}`
: `1px solid ${Color.BG_VANILLA_200}`,
}}
/>
<div className="log-switch">
<div className="wrap-word-switch">
<Typography.Text>Wrap text</Typography.Text>
<Switch value={isWrapWord} onChange={handleWrapWord} />
</div>
</div>
</div>
),
// extra: <Badge className="tag" color="vanilla">JSON</Badge>,
className: 'collapse-content',
<DataViewer
data={prettyData}
drawerKey="logs-details"
fontSize={13}
prettyViewProps={{
actions,
visibleActions,
renderLeafValue: (value, keyPath): ReactNode | undefined => {
// Sanitize (unescape + ANSI→color) string values under `body`.
// Skip huge ones (render raw, still safe) to avoid the sanitize
// choke;
if (
typeof value !== 'string' ||
keyPath[keyPath.length - 1] !== 'body' ||
value.length > MAX_BODY_SANITIZE_CHARS
) {
return undefined;
}
return (
<span
className="log-body-value"
// Safe: getSanitizedLogBody runs the value through dompurify.
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{
__html: getSanitizedLogBody(value, { shouldEscapeHtml: true }),
}}
/>
);
},
]}
/>
<Collapse
className="attribute-table"
defaultActiveKey={['1']}
bordered={false}
expandIcon={(props): ReactNode =>
props.isActive ? <ChevronDown size={14} /> : <ChevronRight size={14} />
}
items={[
{
key: '1',
label: (
<div
className="attribute-tab-header"
onClick={toogleAttributePanelOpenState}
>
<Badge color="vanilla">
<Typography.Text style={{ color: Color.BG_ROBIN_400 }}>
Attributes
</Typography.Text>
</Badge>
{isAttributesExpanded && (
<Button
variant="link"
color="none"
className="action-btn"
prefix={<Search size={12} />}
onClick={(e): void => {
e.stopPropagation();
handleSearchVisible();
}}
>
Search
</Button>
)}
</div>
),
children: (
<>
{isSearchVisible && (
<Input
autoFocus
placeholder="Search for a field..."
className="search-input"
value={fieldSearchInput}
onChange={(e): void => setFieldSearchInput(e.target.value)}
/>
)}
<TableView
logData={logData}
onAddToQuery={onAddToQuery}
fieldSearchInput={fieldSearchInput}
onClickActionItem={onClickActionItem}
isListViewPanel={isListViewPanel}
selectedOptions={selectedOptions}
listViewPanelSelectedFields={listViewPanelSelectedFields}
handleChangeSelectedView={handleChangeSelectedView}
/>
</>
),
className: 'collapse-content attribute-collapse',
},
]}
}}
jsonString={JSON.stringify(raw, null, 2)}
/>
</div>
);
@@ -286,7 +78,6 @@ function Overview({
Overview.defaultProps = {
isListViewPanel: false,
listViewPanelSelectedFields: null,
handleChangeSelectedView: undefined,
};

View File

@@ -1,93 +0,0 @@
.attribute-table-container {
.ant-table {
background: var(--l2-background);
.ant-table-row:hover {
.ant-table-cell {
.value-field {
.action-btn {
display: flex;
position: absolute;
top: 50%;
right: 16px;
transform: translateY(-50%);
gap: 4px;
}
}
}
}
.ant-table-cell {
border: 1px solid var(--l1-border);
background: var(--l2-background);
vertical-align: top;
}
.attribute-name {
.ant-btn {
&:hover {
background-color: none !important;
}
}
}
.attribute-pin {
cursor: pointer;
padding: 14px 8px 8px;
vertical-align: top;
text-align: center;
.log-attribute-pin {
padding: 0;
display: flex;
justify-content: center;
align-items: center;
.pin-attribute-icon {
border: none;
&.pinned svg {
fill: var(--accent-primary);
}
}
}
}
.value-field-container {
background: var(--l2-background);
&.attribute-pin {
background: var(--l2-background);
}
.value-field {
font-family: 'Geist Mono';
position: relative;
}
.action-btn {
display: none;
width: max-content;
position: absolute;
padding: 0 16px;
right: 0;
.filter-btn {
display: flex;
align-items: center;
border: none;
box-shadow: none;
border-radius: 2px;
background: var(--l3-background);
padding: 2px 3px;
gap: 3px;
height: 18px;
width: 20px;
}
}
}
}
}

View File

@@ -1,349 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { generatePath } from 'react-router-dom';
import { Link, Pin } from '@signozhq/icons';
import { Color } from '@signozhq/design-tokens';
import { Button, Space, TableColumnsType as ColumnsType, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import AddToQueryHOC, {
AddToQueryHOCProps,
} from 'components/Logs/AddToQueryHOC';
import { ResizeTable } from 'components/ResizeTable';
import { OPERATORS } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import { MetricsType } from 'container/MetricsApplication/constant';
import { FontSize, OptionsQuery } from 'container/OptionsMenu/types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import history from 'lib/history';
import { fieldSearchFilter } from 'lib/logs/fieldSearch';
import { removeJSONStringifyQuotes } from 'lib/removeJSONStringifyQuotes';
import { IField } from 'types/api/logs/fields';
import { ILog } from 'types/api/logs/log';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { openInNewTab } from 'utils/navigation';
import { ActionItemProps } from './ActionItem';
import { RESTRICTED_SELECTED_FIELDS } from './config';
import FieldRenderer from './FieldRenderer';
import TableViewActions from './TableView/TableViewActions';
import {
filterKeyForField,
findKeyPath,
flattenObject,
getFieldAttributes,
} from './utils';
import './TableView.styles.scss';
interface TableViewProps {
logData: ILog;
fieldSearchInput: string;
selectedOptions: OptionsQuery;
isListViewPanel?: boolean;
listViewPanelSelectedFields?: IField[] | null;
handleChangeSelectedView?: ChangeViewFunctionType;
}
type Props = TableViewProps &
Partial<Pick<ActionItemProps, 'onClickActionItem'>> &
Pick<AddToQueryHOCProps, 'onAddToQuery'>;
function TableView({
logData,
fieldSearchInput,
onAddToQuery,
onClickActionItem,
isListViewPanel = false,
selectedOptions,
listViewPanelSelectedFields,
handleChangeSelectedView,
}: Props): JSX.Element | null {
const [isfilterInLoading, setIsFilterInLoading] = useState<boolean>(false);
const [isfilterOutLoading, setIsFilterOutLoading] = useState<boolean>(false);
const isDarkMode = useIsDarkMode();
const [pinnedAttributes, setPinnedAttributes] = useState<
Record<string, boolean>
>({});
useEffect(() => {
const pinnedAttributes: Record<string, boolean> = {};
if (isListViewPanel) {
listViewPanelSelectedFields?.forEach((val) => {
const path = findKeyPath(logData, val.name, '');
if (path) {
pinnedAttributes[path] = true;
}
});
} else {
// eslint-disable-next-line sonarjs/no-identical-functions
selectedOptions.selectColumns.forEach((val) => {
const path = findKeyPath(logData, val.name, '');
if (path) {
pinnedAttributes[path] = true;
}
});
}
// pin trace_id by default when present
if (logData?.trace_id) {
pinnedAttributes.trace_id = true;
}
setPinnedAttributes(pinnedAttributes);
}, [
logData,
selectedOptions.selectColumns,
listViewPanelSelectedFields,
isListViewPanel,
]);
// When USE_JSON_BODY is enabled, body arrives as a pre-parsed object. Serialize it
// back to a string so flattenObject keeps `body` as a single table row instead of
// recursively expanding it into dotted sub-keys (body.message, body.foo.bar, …),
// which would break the tree view in BodyContent that relies on record.field === 'body'.
const flattenLogData: Record<string, string> | null = useMemo(() => {
if (!logData) {
return null;
}
const normalizedLog =
typeof logData.body === 'object' && logData.body !== null
? { ...logData, body: JSON.stringify(logData.body) }
: logData;
return flattenObject(normalizedLog);
}, [logData]);
const handleClick = (
operator: string,
fieldKey: string,
fieldValue: string,
dataType: string | undefined,
fieldType: string | undefined,
): void => {
const validatedFieldValue = removeJSONStringifyQuotes(fieldValue);
if (onClickActionItem) {
onClickActionItem(
fieldKey,
validatedFieldValue,
operator,
dataType as DataTypes,
fieldType,
);
}
};
const onClickHandler =
(
operator: string,
fieldKey: string,
fieldValue: string,
dataType: string | undefined,
fieldType: MetricsType | undefined,
) =>
(): void => {
handleClick(operator, fieldKey, fieldValue, dataType, fieldType);
if (operator === OPERATORS['=']) {
setIsFilterInLoading(true);
}
if (operator === OPERATORS['!=']) {
setIsFilterOutLoading(true);
}
};
if (logData === null) {
return null;
}
const dataSource =
flattenLogData !== null &&
Object.keys(flattenLogData)
.filter((field) => fieldSearchFilter(field, fieldSearchInput))
.map((key) => ({
key,
field: key,
value: JSON.stringify(flattenLogData[key]),
}));
const onTraceHandler = (
record: DataType,
event: React.MouseEvent<HTMLDivElement, MouseEvent>,
): void => {
if (flattenLogData === null) {
return;
}
const traceId = flattenLogData[record.field];
const spanId = flattenLogData?.span_id;
if (traceId) {
const basePath = generatePath(ROUTES.TRACE_DETAIL, {
id: traceId,
});
const route = spanId ? `${basePath}?spanId=${spanId}` : basePath;
if (event.ctrlKey || event.metaKey) {
// open the trace in new tab
openInNewTab(route);
} else {
history.push(route);
}
}
};
if (!dataSource) {
return null;
}
const columns: ColumnsType<DataType> = [
{
title: '',
dataIndex: 'pin',
key: 'pin',
width: 5,
align: 'left',
className: 'attribute-pin value-field-container',
render: (fieldData: Record<string, string>, record): JSX.Element => {
let pinColor = isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500;
if (pinnedAttributes[record?.key]) {
pinColor = Color.BG_ROBIN_500;
}
return (
<div className="log-attribute-pin value-field">
<div
className={cx(
'pin-attribute-icon',
pinnedAttributes[record?.key] ? 'pinned' : '',
)}
>
{pinnedAttributes[record?.key] && <Pin size={14} color={pinColor} />}
</div>
</div>
);
},
},
{
title: 'Field',
dataIndex: 'field',
key: 'field',
width: 50,
align: 'left',
ellipsis: true,
className: 'attribute-name',
render: (field: string, record): JSX.Element => {
const renderedField = <FieldRenderer field={field} />;
if (record.field === 'trace_id') {
const traceId = flattenLogData[record.field];
return (
<Space size="middle" className="log-attribute">
<Typography.Text>{renderedField}</Typography.Text>
{traceId && (
<Tooltip title="Inspect in Trace" mouseLeaveDelay={0}>
<Button
className="periscope-btn"
onClick={(
event: React.MouseEvent<HTMLDivElement, MouseEvent>,
): void => {
onTraceHandler(record, event);
}}
>
<Link size={15} />
</Button>
</Tooltip>
)}
</Space>
);
}
const fieldFilterKey = filterKeyForField(field);
const { dataType } = getFieldAttributes(field);
if (!RESTRICTED_SELECTED_FIELDS.includes(fieldFilterKey)) {
return (
<AddToQueryHOC
fieldKey={fieldFilterKey}
fieldValue={flattenLogData[field]}
onAddToQuery={onAddToQuery}
fontSize={FontSize.SMALL}
dataType={dataType as DataTypes}
>
{renderedField}
</AddToQueryHOC>
);
}
return renderedField;
},
},
{
title: 'Value',
key: 'value',
width: 70,
ellipsis: false,
className: 'value-field-container attribute-value',
render: (fieldData: Record<string, string>, record): JSX.Element => (
<TableViewActions
fieldData={fieldData}
record={record}
isListViewPanel={isListViewPanel}
isfilterInLoading={isfilterInLoading}
isfilterOutLoading={isfilterOutLoading}
onClickHandler={onClickHandler}
handleChangeSelectedView={handleChangeSelectedView}
/>
),
},
];
function sortPinnedAttributes(
data: Record<string, string>[],
sortingObj: Record<string, boolean>,
): Record<string, string>[] {
const sortingKeys = Object.keys(sortingObj);
return data.sort((a, b) => {
const aKey = a.key;
const bKey = b.key;
const aSortIndex = sortingKeys.indexOf(aKey);
const bSortIndex = sortingKeys.indexOf(bKey);
if (sortingObj[aKey] && !sortingObj[bKey]) {
return -1;
}
if (!sortingObj[aKey] && sortingObj[bKey]) {
return 1;
}
return aSortIndex - bSortIndex;
});
}
const sortedAttributes = sortPinnedAttributes(dataSource, pinnedAttributes);
return (
<ResizeTable
columns={columns}
tableLayout="fixed"
dataSource={sortedAttributes}
pagination={false}
showHeader={false}
className="attribute-table-container"
/>
);
}
TableView.defaultProps = {
isListViewPanel: false,
listViewPanelSelectedFields: null,
handleChangeSelectedView: undefined,
};
export interface DataType {
key: string;
field: string;
value: string;
}
export default TableView;

View File

@@ -1,62 +0,0 @@
.open-popover {
&.value-field {
.action-btn {
display: flex !important;
position: absolute !important;
top: 50% !important;
right: 16px !important;
transform: translateY(-50%) !important;
gap: 4px !important;
}
}
}
.selectable-tree {
.ant-tree-node-content-wrapper {
user-select: text !important;
cursor: text !important;
min-width: 0;
}
.ant-tree-title {
user-select: text !important;
cursor: text !important;
overflow-wrap: anywhere;
}
}
.table-view-actions-content {
.ant-popover-inner {
border-radius: 4px;
border: 1px solid var(--l1-border);
background: linear-gradient(
139deg,
color-mix(in srgb, var(--card) 80%, transparent) 0%,
color-mix(in srgb, var(--card) 90%, transparent) 98.68%
);
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
padding: 0px;
.more-filter-actions {
display: flex;
align-items: center;
gap: 8px;
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: normal;
letter-spacing: 0.14px;
padding: 12px 18px 12px 14px;
.ant-btn-icon {
margin-inline-end: 0px;
}
}
.more-filter-actions:hover {
background-color: unset !important;
}
}
}

View File

@@ -1,532 +0,0 @@
import React, { useCallback, useMemo, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { Color } from '@signozhq/design-tokens';
import { Button, Popover, Spin, Tooltip, Tree } from 'antd';
import type { DataNode } from 'antd/es/tree';
import GroupByIcon from 'assets/CustomIcons/GroupByIcon';
import cx from 'classnames';
import CopyClipboardHOC from 'components/Logs/CopyClipboardHOC';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { OPERATORS } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import { MetricsType } from 'container/MetricsApplication/constant';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { ICurrentQueryData } from 'hooks/useHandleExplorerTabChange';
import {
ArrowDownToDot,
ArrowUpFromDot,
Ellipsis,
RefreshCw,
} from '@signozhq/icons';
import { ExplorerViews } from 'pages/LogsExplorer/utils';
import { useTimezone } from 'providers/Timezone';
import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import { RESTRICTED_SELECTED_FIELDS } from '../config';
import { DataType } from '../TableView';
import {
filterKeyForField,
getFieldAttributes,
getSanitizedLogBody,
parseFieldValue,
removeEscapeCharacters,
} from '../utils';
import useAsyncJSONProcessing from './useAsyncJSONProcessing';
import './TableViewActions.styles.scss';
interface ITableViewActionsProps {
fieldData: Record<string, string>;
record: DataType;
isListViewPanel: boolean;
isfilterInLoading: boolean;
isfilterOutLoading: boolean;
onClickHandler: (
operator: string,
fieldKey: string,
fieldValue: string,
dataType: string | undefined,
logType: MetricsType | undefined,
) => () => void;
handleChangeSelectedView?: ChangeViewFunctionType;
}
// Memoized Tree Component
const MemoizedTree = React.memo<{ treeData: DataNode[] }>(({ treeData }) => (
<Tree
defaultExpandAll
showLine
treeData={treeData}
className="selectable-tree"
/>
));
MemoizedTree.displayName = 'MemoizedTree';
// Body Content Component
const BodyContent: React.FC<{
fieldData: Record<string, string>;
record: DataType;
bodyHtml: { __html: string };
textToCopy: string;
handleChangeSelectedView?: ChangeViewFunctionType;
}> = React.memo(
({ fieldData, record, bodyHtml, textToCopy, handleChangeSelectedView }) => {
const { isLoading, treeData, error } = useAsyncJSONProcessing(
fieldData.value,
record.field === 'body',
handleChangeSelectedView,
);
// Show JSON tree if available, otherwise show HTML content
if (record.field === 'body' && treeData) {
return <MemoizedTree treeData={treeData} />;
}
if (record.field === 'body' && isLoading) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Spin size="small" />
<span style={{ color: Color.BG_SIENNA_400 }}>Processing JSON...</span>
</div>
);
}
if (record.field === 'body' && error) {
return (
<span
style={{ color: Color.BG_SIENNA_400, whiteSpace: 'pre-wrap', tabSize: 4 }}
>
Error parsing Body JSON
</span>
);
}
if (record.field === 'body') {
return (
<CopyClipboardHOC entityKey="body" textToCopy={textToCopy}>
<span
style={{ color: Color.BG_SIENNA_400, whiteSpace: 'pre-wrap', tabSize: 4 }}
>
<span dangerouslySetInnerHTML={bodyHtml} />
</span>
</CopyClipboardHOC>
);
}
return null;
},
);
BodyContent.displayName = 'BodyContent';
export default function TableViewActions(
props: ITableViewActionsProps,
): React.ReactElement {
const {
fieldData,
record,
isListViewPanel,
isfilterInLoading,
isfilterOutLoading,
onClickHandler,
handleChangeSelectedView,
} = props;
const { pathname } = useLocation();
const { stagedQuery, updateQueriesData } = useQueryBuilder();
const { dataType, logType: fieldType } = getFieldAttributes(record.field);
// there is no option for where clause in live logs page or infra monitoring
const isLiveLogsOrInfraPage = useMemo(
() =>
pathname === ROUTES.LIVE_LOGS ||
pathname === ROUTES.INFRASTRUCTURE_MONITORING_HOSTS ||
pathname === ROUTES.INFRASTRUCTURE_MONITORING_KUBERNETES,
[pathname],
);
const [isOpen, setIsOpen] = useState<boolean>(false);
const { formatTimezoneAdjustedTimestamp } = useTimezone();
// Memoize bodyHtml computation
const bodyHtml = useMemo(() => {
if (record.field !== 'body') {
return { __html: '' };
}
return {
__html: getSanitizedLogBody(record.value, { shouldEscapeHtml: true }),
};
}, [record.field, record.value]);
const fieldFilterKey = filterKeyForField(fieldData.field);
const handleGroupByAttribute = useCallback((): void => {
if (!stagedQuery) {
return;
}
const normalizedDataType: DataTypes | undefined =
dataType && Object.values(DataTypes).includes(dataType as DataTypes)
? (dataType as DataTypes)
: undefined;
const updatedQuery = updateQueriesData(
stagedQuery,
'queryData',
(item, index) => {
// Only add groupBy for index 0
if (index === 0) {
const newGroupByItem: BaseAutocompleteData = {
key: fieldFilterKey,
type: fieldType || '',
dataType: normalizedDataType,
};
const updatedGroupBy = [...(item.groupBy || []), newGroupByItem];
return { ...item, groupBy: updatedGroupBy };
}
return item;
},
);
const queryData: ICurrentQueryData = {
query: updatedQuery,
};
handleChangeSelectedView?.(ExplorerViews.TIMESERIES, queryData);
}, [
stagedQuery,
updateQueriesData,
fieldFilterKey,
fieldType,
dataType,
handleChangeSelectedView,
]);
const handleReplaceFilter = useCallback((): void => {
if (!stagedQuery) {
return;
}
const normalizedDataType: DataTypes | undefined =
dataType && Object.values(DataTypes).includes(dataType as DataTypes)
? (dataType as DataTypes)
: undefined;
const updatedQuery = updateQueriesData(
stagedQuery,
'queryData',
(item, index) => {
// Only replace filters for index 0
if (index === 0) {
const newFilterItem: BaseAutocompleteData = {
key: fieldFilterKey,
type: fieldType || '',
dataType: normalizedDataType,
};
// Create new filter items array with single IN filter
const newFilters = {
items: [
{
id: '',
key: newFilterItem,
op: OPERATORS.IN,
value: [parseFieldValue(fieldData.value)],
},
],
op: 'AND',
};
// Clear the expression and update filters
return {
...item,
filters: newFilters,
filter: { expression: '' },
};
}
return item;
},
);
const queryData: ICurrentQueryData = {
query: updatedQuery,
};
handleChangeSelectedView?.(ExplorerViews.LIST, queryData);
}, [
stagedQuery,
updateQueriesData,
fieldFilterKey,
fieldType,
dataType,
fieldData,
handleChangeSelectedView,
]);
// Memoize textToCopy computation
const textToCopy = useMemo(() => {
let text = fieldData.value;
try {
text = text.replace(/^"|"$/g, '');
} catch (error) {
console.error(
'Failed to remove starting and ending quotes from the value',
error,
);
}
// If the value is valid JSON (object or array), pretty-print it for copying
try {
const parsed = JSON.parse(text);
if (typeof parsed === 'object' && parsed !== null) {
return JSON.stringify(parsed, null, 2);
}
} catch {
// not JSON, return as-is
}
return text;
}, [fieldData.value]);
// Memoize cleanTimestamp computation
const cleanTimestamp = useMemo(() => {
if (record.field !== 'timestamp') {
return '';
}
return fieldData.value.replace(/^["']|["']$/g, '');
}, [record.field, fieldData.value]);
const renderFieldContent = useCallback((): JSX.Element => {
const commonStyles: React.CSSProperties = {
color: Color.BG_SIENNA_400,
whiteSpace: 'pre-wrap',
tabSize: 4,
};
switch (record.field) {
case 'body':
return (
<BodyContent
fieldData={fieldData}
record={record}
bodyHtml={bodyHtml}
textToCopy={textToCopy}
handleChangeSelectedView={handleChangeSelectedView}
/>
);
case 'timestamp':
return (
<span style={commonStyles}>
{formatTimezoneAdjustedTimestamp(
cleanTimestamp,
DATE_TIME_FORMATS.UTC_US_MS,
)}
</span>
);
default:
return (
<span style={commonStyles}>{removeEscapeCharacters(fieldData.value)}</span>
);
}
}, [
record,
fieldData,
bodyHtml,
textToCopy,
handleChangeSelectedView,
formatTimezoneAdjustedTimestamp,
cleanTimestamp,
]);
// Early return for body field with async processing
if (record.field === 'body') {
return (
<div className={cx('value-field', isOpen ? 'open-popover' : '')}>
<BodyContent
fieldData={fieldData}
record={record}
bodyHtml={bodyHtml}
textToCopy={textToCopy}
handleChangeSelectedView={handleChangeSelectedView}
/>
{!isListViewPanel &&
!RESTRICTED_SELECTED_FIELDS.includes(fieldFilterKey) && (
<span className="action-btn">
<Tooltip title="Filter for value" mouseLeaveDelay={0}>
<Button
className="filter-btn periscope-btn"
icon={
isfilterInLoading ? (
<Spin size="small" />
) : (
<ArrowDownToDot size={14} style={{ transform: 'rotate(90deg)' }} />
)
}
onClick={onClickHandler(
OPERATORS['='],
fieldFilterKey,
parseFieldValue(fieldData.value),
dataType,
fieldType,
)}
/>
</Tooltip>
<Tooltip title="Filter out value" mouseLeaveDelay={0}>
<Button
className="filter-btn periscope-btn"
icon={
isfilterOutLoading ? (
<Spin size="small" />
) : (
<ArrowUpFromDot size={14} style={{ transform: 'rotate(90deg)' }} />
)
}
onClick={onClickHandler(
OPERATORS['!='],
fieldFilterKey,
parseFieldValue(fieldData.value),
dataType,
fieldType,
)}
/>
</Tooltip>
{!isLiveLogsOrInfraPage && (
<Popover
open={isOpen}
onOpenChange={setIsOpen}
arrow={false}
content={
<div data-log-detail-ignore="true">
<Button
className="more-filter-actions"
type="text"
icon={<GroupByIcon />}
onClick={handleGroupByAttribute}
>
Group By Attribute
</Button>
<Button
className="more-filter-actions"
type="text"
icon={<RefreshCw size={14} />}
onClick={handleReplaceFilter}
>
Replace filters with this value
</Button>
</div>
}
rootClassName="table-view-actions-content"
trigger="hover"
placement="bottomLeft"
>
<Button
icon={<Ellipsis size={14} />}
className="filter-btn periscope-btn"
/>
</Popover>
)}
</span>
)}
</div>
);
}
return (
<div className={cx('value-field', isOpen ? 'open-popover' : '')}>
<CopyClipboardHOC entityKey={fieldFilterKey} textToCopy={textToCopy}>
{renderFieldContent()}
</CopyClipboardHOC>
{!isListViewPanel &&
!RESTRICTED_SELECTED_FIELDS.includes(fieldFilterKey) && (
<span className="action-btn">
<Tooltip title="Filter for value" mouseLeaveDelay={0}>
<Button
className="filter-btn periscope-btn"
icon={
isfilterInLoading ? (
<Spin size="small" />
) : (
<ArrowDownToDot size={14} style={{ transform: 'rotate(90deg)' }} />
)
}
onClick={onClickHandler(
OPERATORS['='],
fieldFilterKey,
parseFieldValue(fieldData.value),
dataType,
fieldType,
)}
/>
</Tooltip>
<Tooltip title="Filter out value" mouseLeaveDelay={0}>
<Button
className="filter-btn periscope-btn"
icon={
isfilterOutLoading ? (
<Spin size="small" />
) : (
<ArrowUpFromDot size={14} style={{ transform: 'rotate(90deg)' }} />
)
}
onClick={onClickHandler(
OPERATORS['!='],
fieldFilterKey,
parseFieldValue(fieldData.value),
dataType,
fieldType,
)}
/>
</Tooltip>
{!isLiveLogsOrInfraPage && (
<Popover
open={isOpen}
onOpenChange={setIsOpen}
arrow={false}
content={
<div data-log-detail-ignore="true">
<Button
className="more-filter-actions"
type="text"
icon={<GroupByIcon />}
onClick={handleGroupByAttribute}
>
Group By Attribute
</Button>
<Button
className="more-filter-actions"
type="text"
icon={<RefreshCw size={14} />}
onClick={handleReplaceFilter}
>
Replace filters with this value
</Button>
</div>
}
rootClassName="table-view-actions-content"
trigger="hover"
placement="bottomLeft"
>
<Button
icon={<Ellipsis size={14} />}
className="filter-btn periscope-btn"
/>
</Popover>
)}
</span>
)}
</div>
);
}
TableViewActions.defaultProps = {
handleChangeSelectedView: undefined,
};

View File

@@ -1,366 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { ExplorerViews } from 'pages/LogsExplorer/utils';
import { RESTRICTED_SELECTED_FIELDS } from '../../config';
import TableViewActions from '../TableViewActions';
import useAsyncJSONProcessing from '../useAsyncJSONProcessing';
// Mock data for tests
let mockCopyToClipboard: jest.Mock;
let mockNotificationsSuccess: jest.Mock;
// Mock the components and hooks
jest.mock('components/Logs/CopyClipboardHOC', () => ({
__esModule: true,
default: ({
children,
textToCopy,
entityKey,
}: {
children: React.ReactNode;
textToCopy: string;
entityKey: string;
}): JSX.Element => (
<div
className="CopyClipboardHOC"
data-testid={`copy-clipboard-${entityKey}`}
data-text-to-copy={textToCopy}
onClick={(): void => {
if (mockCopyToClipboard) {
mockCopyToClipboard(textToCopy);
}
if (mockNotificationsSuccess) {
mockNotificationsSuccess({
message: `${entityKey} copied to clipboard`,
key: `${entityKey} copied to clipboard`,
});
}
}}
role="button"
tabIndex={0}
>
{children}
</div>
),
}));
jest.mock('../useAsyncJSONProcessing', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('antd', () => {
const antd = jest.requireActual('antd');
return {
...antd,
// Render popover content inline to make its children testable
Popover: ({ content, children }: any): JSX.Element => (
<div data-testid="popover">
<div data-testid="popover-content">{content}</div>
{children}
</div>
),
};
});
jest.mock('providers/Timezone', () => ({
useTimezone: (): {
formatTimezoneAdjustedTimestamp: (timestamp: string) => string;
} => ({
formatTimezoneAdjustedTimestamp: (timestamp: string): string => timestamp,
}),
}));
jest.mock('react-router-dom', () => ({
useLocation: (): {
pathname: string;
search: string;
hash: string;
state: null;
} => ({
pathname: '/test',
search: '',
hash: '',
state: null,
}),
}));
jest.mock('hooks/queryBuilder/useQueryBuilder');
jest.mock('hooks/saveViews/useGetSavedViewParams');
describe('TableViewActions', () => {
const TEST_VALUE = 'test value';
const TEST_FIELD = 'test-field';
const ACTION_BUTTON_TEST_ID = '.action-btn';
const defaultProps = {
fieldData: {
field: TEST_FIELD,
value: TEST_VALUE,
},
record: {
key: 'test-key',
field: TEST_FIELD,
value: TEST_VALUE,
},
isListViewPanel: false,
isfilterInLoading: false,
isfilterOutLoading: false,
onClickHandler: jest.fn(),
handleChangeSelectedView: jest.fn(),
};
beforeEach(() => {
mockCopyToClipboard = jest.fn();
mockNotificationsSuccess = jest.fn();
defaultProps.onClickHandler = jest.fn();
defaultProps.handleChangeSelectedView = jest.fn();
// Default mock for useAsyncJSONProcessing
const mockUseAsyncJSONProcessing = jest.mocked(useAsyncJSONProcessing);
mockUseAsyncJSONProcessing.mockReturnValue({
isLoading: false,
treeData: null,
error: null,
});
// Default mock for useQueryBuilder
jest.mocked(useQueryBuilder).mockReturnValue({
stagedQuery: null,
updateQueriesData: jest.fn((query, type, callback) => {
const updatedBuilder = {
...query.builder,
[type]: query.builder[type].map(callback),
};
return {
...query,
builder: updatedBuilder,
};
}),
} as any);
// Default mock for useGetSavedViewParams
jest
.mocked(useGetSavedViewParams)
.mockReturnValue({ viewName: '', viewKey: '' });
});
it('should render without crashing', () => {
render(
<TableViewActions
fieldData={defaultProps.fieldData}
record={defaultProps.record}
isListViewPanel={defaultProps.isListViewPanel}
isfilterInLoading={defaultProps.isfilterInLoading}
isfilterOutLoading={defaultProps.isfilterOutLoading}
onClickHandler={defaultProps.onClickHandler}
handleChangeSelectedView={defaultProps.handleChangeSelectedView}
/>,
);
expect(screen.getByText(TEST_VALUE)).toBeInTheDocument();
});
it('should not render action buttons for restricted fields', () => {
RESTRICTED_SELECTED_FIELDS.forEach((field) => {
const { container } = render(
<TableViewActions
fieldData={{
...defaultProps.fieldData,
field,
}}
record={{
...defaultProps.record,
field,
}}
isListViewPanel={defaultProps.isListViewPanel}
isfilterInLoading={defaultProps.isfilterInLoading}
isfilterOutLoading={defaultProps.isfilterOutLoading}
onClickHandler={defaultProps.onClickHandler}
handleChangeSelectedView={defaultProps.handleChangeSelectedView}
/>,
);
// Verify that action buttons are not rendered for restricted fields
expect(
container.querySelector(ACTION_BUTTON_TEST_ID),
).not.toBeInTheDocument();
});
});
it('should render action buttons for non-restricted fields', () => {
const { container } = render(
<TableViewActions
fieldData={defaultProps.fieldData}
record={defaultProps.record}
isListViewPanel={defaultProps.isListViewPanel}
isfilterInLoading={defaultProps.isfilterInLoading}
isfilterOutLoading={defaultProps.isfilterOutLoading}
onClickHandler={defaultProps.onClickHandler}
handleChangeSelectedView={defaultProps.handleChangeSelectedView}
/>,
);
// Verify that action buttons are rendered for non-restricted fields
expect(container.querySelector(ACTION_BUTTON_TEST_ID)).toBeInTheDocument();
});
it('should call handleChangeSelectedView when clicking group by', () => {
const mockStagedQuery = {
id: 'test-query-id',
queryType: 'queryBuilder',
builder: {
queryData: [
{
queryName: 'A',
dataSource: 'logs',
aggregateOperator: 'count',
functions: [],
filter: {},
groupBy: [],
expression: '',
disabled: false,
having: [],
limit: null,
stepInterval: null,
orderBy: [],
legend: '',
},
],
queryFormulas: [],
queryTraceOperator: [],
},
promql: [],
clickhouse_sql: [],
};
const mockUpdateQueriesData = jest.fn((query, type, callback) => {
const section = query.builder?.[type];
if (!Array.isArray(section)) {
return query;
}
return {
...query,
builder: {
...query.builder,
[type]: section.map(callback),
},
};
});
jest.mocked(useQueryBuilder).mockReturnValue({
stagedQuery: mockStagedQuery,
updateQueriesData: mockUpdateQueriesData,
} as any);
jest
.mocked(useGetSavedViewParams)
.mockReturnValue({ viewName: '', viewKey: '' });
render(
<TableViewActions
fieldData={defaultProps.fieldData}
record={defaultProps.record}
isListViewPanel={defaultProps.isListViewPanel}
isfilterInLoading={defaultProps.isfilterInLoading}
isfilterOutLoading={defaultProps.isfilterOutLoading}
onClickHandler={defaultProps.onClickHandler}
handleChangeSelectedView={defaultProps.handleChangeSelectedView}
/>,
);
fireEvent.click(screen.getByText('Group By Attribute'));
expect(defaultProps.handleChangeSelectedView).toHaveBeenCalledWith(
ExplorerViews.TIMESERIES,
expect.objectContaining({
query: expect.objectContaining({
builder: expect.objectContaining({
queryData: expect.arrayContaining([
expect.objectContaining({
groupBy: expect.arrayContaining([
expect.objectContaining({
key: TEST_FIELD,
type: '',
}),
]),
}),
]),
}),
}),
}),
);
});
it('should not render action buttons in list view panel', () => {
const { container } = render(
<TableViewActions
fieldData={defaultProps.fieldData}
record={defaultProps.record}
isListViewPanel
isfilterInLoading={defaultProps.isfilterInLoading}
isfilterOutLoading={defaultProps.isfilterOutLoading}
onClickHandler={defaultProps.onClickHandler}
handleChangeSelectedView={defaultProps.handleChangeSelectedView}
/>,
);
// Verify that action buttons are not rendered in list view panel
expect(
container.querySelector(ACTION_BUTTON_TEST_ID),
).not.toBeInTheDocument();
});
it('should copy non-JSON body text without quotes when user clicks on body', () => {
// Setup: body field with surrounding quotes
const bodyValueWithQuotes =
'"FeatureFlag \'kafkaQueueProblems\' is enabled, sleeping 1 second"';
const expectedCopiedText =
"FeatureFlag 'kafkaQueueProblems' is enabled, sleeping 1 second";
const bodyProps = {
fieldData: {
field: 'body',
value: bodyValueWithQuotes,
},
record: {
key: 'body-key',
field: 'body',
value: bodyValueWithQuotes,
},
isListViewPanel: false,
isfilterInLoading: false,
isfilterOutLoading: false,
onClickHandler: jest.fn(),
handleChangeSelectedView: jest.fn(),
};
// Render component with body field
render(
<TableViewActions
fieldData={bodyProps.fieldData}
record={bodyProps.record}
isListViewPanel={bodyProps.isListViewPanel}
isfilterInLoading={bodyProps.isfilterInLoading}
isfilterOutLoading={bodyProps.isfilterOutLoading}
onClickHandler={bodyProps.onClickHandler}
handleChangeSelectedView={bodyProps.handleChangeSelectedView}
/>,
);
// Find the clickable copy area for body
const copyArea = screen.getByTestId('copy-clipboard-body');
// Verify it has the correct text to copy (without quotes)
expect(copyArea).toHaveAttribute('data-text-to-copy', expectedCopiedText);
// Action: User clicks on body content
fireEvent.click(copyArea);
// Assert: Text was copied without surrounding quotes
expect(mockCopyToClipboard).toHaveBeenCalledWith(expectedCopiedText);
// Assert: Success notification shown
expect(mockNotificationsSuccess).toHaveBeenCalledWith({
message: 'body copied to clipboard',
key: 'body copied to clipboard',
});
});
});

View File

@@ -1,129 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { FeatureKeys } from 'constants/features';
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import { isEmpty } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
import { jsonToDataNodes, recursiveParseJSON } from '../utils';
const MAX_BODY_BYTES = 100 * 1024; // 100 KB
// Hook for async JSON processing
const useAsyncJSONProcessing = (
value: string | Record<string, unknown>,
shouldProcess: boolean,
handleChangeSelectedView?: ChangeViewFunctionType,
): {
isLoading: boolean;
treeData: any[] | null;
error: string | null;
} => {
const [jsonState, setJsonState] = useState<{
isLoading: boolean;
treeData: any[] | null;
error: string | null;
}>({
isLoading: false,
treeData: null,
error: null,
});
const processingRef = useRef<boolean>(false);
const { featureFlags } = useAppContext();
const isBodyJsonQueryEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.USE_JSON_BODY)
?.active || false;
// eslint-disable-next-line sonarjs/cognitive-complexity
useEffect((): (() => void) => {
if (!shouldProcess || processingRef.current) {
return (): void => {};
}
// When value is already a parsed object skip the size check and JSON parsing
const parseBody = (): Record<string, unknown> | null => {
if (typeof value === 'object' && value !== null) {
return value as Record<string, unknown>;
}
const byteSize = new Blob([value as string]).size;
if (byteSize > MAX_BODY_BYTES) {
return null;
}
return recursiveParseJSON(value as string);
};
processingRef.current = true;
setJsonState({ isLoading: true, treeData: null, error: null });
// Option 1: Using setTimeout for non-blocking processing
const processAsync = (): void => {
setTimeout(() => {
try {
const parsedBody = parseBody();
if (parsedBody && !isEmpty(parsedBody)) {
const treeData = jsonToDataNodes(parsedBody, {
isBodyJsonQueryEnabled,
handleChangeSelectedView,
});
setJsonState({ isLoading: false, treeData, error: null });
} else {
setJsonState({ isLoading: false, treeData: null, error: null });
}
} catch (error) {
setJsonState({
isLoading: false,
treeData: null,
error: error instanceof Error ? error.message : 'Parsing failed',
});
} finally {
processingRef.current = false;
}
}, 0);
};
// Option 2: Using requestIdleCallback for better performance
const processWithIdleCallback = (): void => {
if ('requestIdleCallback' in window) {
requestIdleCallback(
// eslint-disable-next-line sonarjs/no-identical-functions
(): void => {
try {
const parsedBody = parseBody();
if (parsedBody && !isEmpty(parsedBody)) {
const treeData = jsonToDataNodes(parsedBody, {
isBodyJsonQueryEnabled,
handleChangeSelectedView,
});
setJsonState({ isLoading: false, treeData, error: null });
} else {
setJsonState({ isLoading: false, treeData: null, error: null });
}
} catch (error) {
setJsonState({
isLoading: false,
treeData: null,
error: error instanceof Error ? error.message : 'Parsing failed',
});
} finally {
processingRef.current = false;
}
},
{ timeout: 1000 },
);
} else {
processAsync();
}
};
processWithIdleCallback();
// Cleanup function
return (): void => {
processingRef.current = false;
};
}, [value, shouldProcess, isBodyJsonQueryEnabled, handleChangeSelectedView]);
return jsonState;
};
export default useAsyncJSONProcessing;

View File

@@ -1,108 +0,0 @@
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import BodyTitleRenderer from '../BodyTitleRenderer';
let mockSetCopy: jest.Mock;
const mockNotification = jest.fn();
jest.mock('hooks/logs/useActiveLog', () => ({
useActiveLog: (): any => ({
onAddToQuery: jest.fn(),
}),
}));
jest.mock('react-use', () => ({
useCopyToClipboard: (): any => {
mockSetCopy = jest.fn();
return [{ value: null }, mockSetCopy];
},
}));
jest.mock('hooks/useNotifications', () => ({
useNotifications: (): any => ({
notifications: {
success: mockNotification,
error: jest.fn(),
info: jest.fn(),
warning: jest.fn(),
open: jest.fn(),
destroy: jest.fn(),
},
}),
}));
describe('BodyTitleRenderer', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should copy primitive value when node is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<BodyTitleRenderer
title="name"
nodeKey="user.name"
value="John"
parentIsArray={false}
/>,
);
await user.click(screen.getByText('name'));
await waitFor(() => {
expect(mockSetCopy).toHaveBeenCalledWith('John');
expect(mockNotification).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining('user.name'),
}),
);
});
});
it('should copy array element value when clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<BodyTitleRenderer
title="0"
nodeKey="items[*].0"
value="arrayElement"
parentIsArray
/>,
);
await user.click(screen.getByText('0'));
await waitFor(() => {
expect(mockSetCopy).toHaveBeenCalledWith('arrayElement');
});
});
it('should copy entire object when object node is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const testObject = { id: 123, active: true };
render(
<BodyTitleRenderer
title="metadata"
nodeKey="user.metadata"
value={testObject}
parentIsArray={false}
/>,
);
await user.click(screen.getByText('metadata'));
await waitFor(() => {
const callArg = mockSetCopy.mock.calls[0][0];
const expectedJson = JSON.stringify(testObject, null, 2);
expect(callArg).toBe(expectedJson);
expect(mockNotification).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining('object copied'),
}),
);
});
});
});

View File

@@ -1,9 +1,7 @@
import { useCallback, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import { CircleMinus, CirclePlus, Layers, RefreshCw } from '@signozhq/icons';
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
import { FeatureKeys } from 'constants/features';
import ROUTES from 'constants/routes';
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { ICurrentQueryData } from 'hooks/useHandleExplorerTabChange';
@@ -53,7 +51,6 @@ export function useLogAttributeActions({
isListViewPanel = false,
onApplyLogFilter,
}: UseLogAttributeActionsParams): UseLogAttributeActionsResult {
const { pathname } = useLocation();
const { stagedQuery, updateQueriesData } = useQueryBuilder();
const { featureFlags } = useAppContext();
@@ -61,8 +58,6 @@ export function useLogAttributeActions({
featureFlags?.find((flag) => flag.name === FeatureKeys.USE_JSON_BODY)
?.active || false;
const isLiveLogs = pathname === ROUTES.LIVE_LOGS;
const filterFor = useCallback(
(context: FieldContext, isFilterIn: boolean): void => {
const target = buildLogFilterTarget(
@@ -219,8 +214,7 @@ export function useLogAttributeActions({
shouldHide: (_key, fieldKeyPath): boolean =>
!handleChangeSelectedView ||
!buildLogFilterTarget(fieldKeyPath, undefined, isBodyJsonQueryEnabled)
.groupBySupported ||
isLiveLogs,
.groupBySupported,
},
{
key: LogDetailsAction.REPLACE_FILTER,
@@ -228,7 +222,7 @@ export function useLogAttributeActions({
icon: <RefreshCw size={12} />,
onClick: replaceFilter,
shouldHide: (_key, fieldKeyPath): boolean =>
!handleChangeSelectedView || isRestricted(fieldKeyPath) || isLiveLogs,
!handleChangeSelectedView || isRestricted(fieldKeyPath),
},
];
}, [
@@ -236,7 +230,6 @@ export function useLogAttributeActions({
groupBy,
replaceFilter,
isBodyJsonQueryEnabled,
isLiveLogs,
handleChangeSelectedView,
onApplyLogFilter,
]);

View File

@@ -1,10 +1,7 @@
import * as Sentry from '@sentry/react';
import Convert from 'ansi-to-html';
import type { DataNode } from 'antd/es/tree';
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import { MetricsType } from 'container/MetricsApplication/constant';
import dompurify from 'dompurify';
import { uniqueId } from 'lodash-es';
import {
ILog,
ILogAggregateAttributesResources,
@@ -13,7 +10,6 @@ import {
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { FORBID_DOM_PURIFY_ATTR, FORBID_DOM_PURIFY_TAGS } from 'utils/app';
import BodyTitleRenderer from './BodyTitleRenderer';
import { typeToArrayTypeMapper } from './config';
import { AnyObject, IFieldAttributes } from './LogDetailedView.types';
@@ -40,135 +36,6 @@ export const recursiveParseJSON = (obj: string): Record<string, unknown> => {
}
};
type JsonToDataNodesOptions = {
parentKey?: string;
parentIsArray?: boolean;
isBodyJsonQueryEnabled?: boolean;
handleChangeSelectedView?: ChangeViewFunctionType;
};
type ComputeDataNodeOptions = {
key: string;
valueIsArray: boolean;
value: unknown;
nodeKey: string;
parentIsArray: boolean;
isBodyJsonQueryEnabled?: boolean;
handleChangeSelectedView?: ChangeViewFunctionType;
};
export const computeDataNode = ({
key,
valueIsArray,
value,
nodeKey,
parentIsArray,
isBodyJsonQueryEnabled = false,
handleChangeSelectedView,
}: ComputeDataNodeOptions): DataNode => ({
key: uniqueId(),
title: (
<BodyTitleRenderer
title={`${key} ${valueIsArray ? '[...]' : ''}`}
nodeKey={nodeKey}
value={value}
parentIsArray={parentIsArray}
handleChangeSelectedView={handleChangeSelectedView}
/>
),
children: jsonToDataNodes(value as Record<string, unknown>, {
parentKey: valueIsArray
? `${nodeKey}${isBodyJsonQueryEnabled ? '[]' : '[*]'}`
: nodeKey,
parentIsArray: valueIsArray,
isBodyJsonQueryEnabled,
handleChangeSelectedView,
}),
});
export function jsonToDataNodes(
json: Record<string, unknown>,
options: JsonToDataNodesOptions = {},
): DataNode[] {
const {
parentKey = '',
parentIsArray = false,
isBodyJsonQueryEnabled = false,
handleChangeSelectedView,
} = options;
return Object.entries(json).map(([key, value]) => {
let nodeKey = parentKey || key;
if (parentIsArray) {
nodeKey += `.${value}`;
} else if (parentKey) {
nodeKey += `.${key}`;
}
const valueIsArray = Array.isArray(value);
if (parentIsArray) {
if (typeof value === 'object' && value !== null) {
return computeDataNode({
key,
valueIsArray,
value,
nodeKey,
parentIsArray,
isBodyJsonQueryEnabled,
handleChangeSelectedView,
});
}
return {
key: uniqueId(),
title: (
<BodyTitleRenderer
title={value as string}
nodeKey={nodeKey}
value={value}
parentIsArray={parentIsArray}
handleChangeSelectedView={handleChangeSelectedView}
/>
),
children: jsonToDataNodes(
{},
{
parentKey: nodeKey,
parentIsArray: valueIsArray,
isBodyJsonQueryEnabled,
handleChangeSelectedView,
},
),
};
}
if (typeof value === 'object' && value !== null) {
return computeDataNode({
key,
valueIsArray,
value,
nodeKey,
parentIsArray,
isBodyJsonQueryEnabled,
handleChangeSelectedView,
});
}
return {
key: uniqueId(),
title: (
<BodyTitleRenderer
title={key}
nodeKey={nodeKey}
value={value}
parentIsArray={parentIsArray}
handleChangeSelectedView={handleChangeSelectedView}
/>
),
};
});
}
export function flattenObject(obj: AnyObject, prefix = ''): AnyObject {
return Object.keys(obj).reduce((acc: AnyObject, k: string): AnyObject => {
const pre = prefix.length ? `${prefix}.` : '';
@@ -254,14 +121,6 @@ export const getFieldAttributes = (field: string): IFieldAttributes => {
// Returns key to be used when filtering for `field` via
// the query builder. This is useful for powering filtering
// by field values from log details view.
export const filterKeyForField = (field: string): string => {
// Must work for all 3 of the following types of cases
// timestamp -> timestamp
// attributes_string.log.file -> log.file
// resources_string.k8s.pod.name -> k8s.pod.name
const fieldAttribs = getFieldAttributes(field);
return fieldAttribs?.newField || field;
};
export const aggregateAttributesResourcesToObject = (
logData: ILog,
@@ -418,16 +277,6 @@ export const escapeHtml = (unsafe: string): string =>
.replace(/'/g, '&#039;');
// parse field value to remove escaping characters
export const parseFieldValue = (value: string): string => {
try {
return JSON.parse(value);
} catch (error) {
return value;
}
};
// now we do not want to render colors everywhere like in tooltip and monaco editor hence we remove such codes to make
// the log line readable
export const removeEscapeCharacters = (str: string): string =>
(str ?? '')
.replace(/\\x1[bB][[0-9;]*m/g, '')
@@ -458,10 +307,6 @@ export const unescapeString = (str: string): string =>
String.fromCharCode(parseInt(hex, 16)),
); // Replaces Unicode escape sequences
export function removeExtraSpaces(input: string): string {
return input.replace(/\s+/g, ' ').trim();
}
export function findKeyPath(
obj: AnyObject,
targetKey: string,

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { ReactNode, useState } from 'react';
import { Switch } from '@signozhq/ui/switch';
import { Typography } from '@signozhq/ui/typography';
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
@@ -6,7 +6,6 @@ import FieldsSelector from 'components/FieldsSelector';
import LogsFormatOptionsMenu from 'components/LogsFormatOptionsMenu/LogsFormatOptionsMenu';
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
import { LOCALSTORAGE } from 'constants/localStorage';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useOptionsMenu } from 'container/OptionsMenu';
import { LOGS_REQUIRED_COLUMNS } from 'container/OptionsMenu/constants';
import { ArrowUp10, Minus } from '@signozhq/icons';
@@ -14,18 +13,18 @@ import { DataSource, StringOperators } from 'types/common/queryBuilder';
function LogsActionsContainer({
listQuery,
selectedPanelType,
showFrequencyChart,
handleToggleFrequencyChart,
orderBy,
setOrderBy,
explorerActions,
}: {
listQuery: any;
selectedPanelType: PANEL_TYPES;
showFrequencyChart: boolean;
handleToggleFrequencyChart: () => void;
orderBy: string;
setOrderBy: (value: string) => void;
explorerActions: ReactNode;
}): JSX.Element {
const { options, config } = useOptionsMenu({
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
@@ -60,48 +59,43 @@ function LogsActionsContainer({
<div className="logs-actions-container">
<div className="tab-options">
<div className="tab-options-left">
{selectedPanelType === PANEL_TYPES.LIST && (
<div className="frequency-chart-view-controller">
<Typography>Frequency chart</Typography>
<Switch
value={showFrequencyChart}
defaultValue
onChange={handleToggleFrequencyChart}
/>
</div>
)}
<div className="frequency-chart-view-controller">
<Typography>Frequency chart</Typography>
<Switch
value={showFrequencyChart}
defaultValue
onChange={handleToggleFrequencyChart}
/>
</div>
</div>
<div className="tab-options-right">
{selectedPanelType === PANEL_TYPES.LIST && (
<>
<div className="order-by-container">
<div className="order-by-label">
Order by <Minus size={14} /> <ArrowUp10 size={14} />
</div>
{explorerActions}
<div className="order-by-container">
<div className="order-by-label">
Order by <Minus size={14} /> <ArrowUp10 size={14} />
</div>
<ListViewOrderBy
value={orderBy}
onChange={(value): void => setOrderBy(value)}
dataSource={DataSource.LOGS}
/>
</div>
<div className="download-options-container">
<DownloadOptionsMenu
dataSource={DataSource.LOGS}
selectedColumns={options?.selectColumns}
/>
</div>
<div className="format-options-container">
<LogsFormatOptionsMenu
items={formatItems}
selectedOptionFormat={options.format}
config={config}
onOpenColumns={(): void => setIsFieldsSelectorOpen(true)}
/>
</div>
</>
)}
<ListViewOrderBy
value={orderBy}
onChange={(value): void => setOrderBy(value)}
dataSource={DataSource.LOGS}
/>
</div>
<div className="download-options-container">
<DownloadOptionsMenu
dataSource={DataSource.LOGS}
selectedColumns={options?.selectColumns}
/>
</div>
<div className="format-options-container">
<LogsFormatOptionsMenu
items={formatItems}
selectedOptionFormat={options.format}
config={config}
onOpenColumns={(): void => setIsFieldsSelectorOpen(true)}
/>
</div>
</div>
</div>
{config.fieldsSelector && (

View File

@@ -187,6 +187,7 @@
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
padding: 12px;
flex-shrink: 0;
}

View File

@@ -37,6 +37,7 @@ import {
getListQuery,
getQueryByPanelType,
} from 'container/LogsExplorerViews/explorerUtils';
import ExplorerActions from 'container/ExplorerActions/ExplorerActions';
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
import { useGetExportToDashboardLink } from 'hooks/dashboard/useGetExportToDashboardLink';
@@ -140,6 +141,13 @@ function LogsExplorerViewsContainer({
[selectedPanelType, requestData],
);
const explorerActions = useMemo(
() => (
<ExplorerActions query={exportDefaultQuery} sourcepage={DataSource.LOGS} />
),
[exportDefaultQuery],
);
const {
data: listChartData,
isFetching: isFetchingListChartData,
@@ -416,14 +424,14 @@ function LogsExplorerViewsContainer({
return (
<div className="logs-explorer-views-container">
<div className="logs-explorer-views-types">
{!showLiveLogs && (
{!showLiveLogs && selectedPanelType === PANEL_TYPES.LIST && (
<LogsActionsContainer
listQuery={listQuery}
selectedPanelType={selectedPanelType}
showFrequencyChart={showFrequencyChart}
handleToggleFrequencyChart={handleToggleFrequencyChart}
orderBy={orderBy}
setOrderBy={setOrderBy}
explorerActions={explorerActions}
/>
)}
@@ -474,21 +482,23 @@ function LogsExplorerViewsContainer({
dataSource={DataSource.LOGS}
setWarning={setWarning}
allowExport
headerActions={explorerActions}
/>
</div>
)}
{selectedPanelType === PANEL_TYPES.TABLE && !showLiveLogs && (
<div className="table-view-container">
{data && !isError && (
<div className="table-view-container-header">
<div className="table-view-container-header">
{explorerActions}
{data && !isError && (
<ExportMenu
dataSource={DataSource.LOGS}
data={data}
query={stagedQuery || initialQueriesMap.metrics}
fileName="logs-table"
/>
</div>
)}
)}
</div>
<LogsExplorerTable
data={
(data?.payload?.data?.newResult?.data?.result ||

View File

@@ -394,6 +394,7 @@ function Explorer(): JSX.Element {
setYAxisUnit={setYAxisUnit}
showYAxisUnitSelector={showYAxisUnitSelector}
isCancelled={isCancelled}
exportDefaultQuery={exportDefaultQuery}
/>
</div>
</div>

View File

@@ -19,6 +19,7 @@ import { ENTITY_VERSION_V5 } from 'constants/app';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { MAX_QUERY_RETRIES } from 'constants/reactQuery';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import ExplorerActions from 'container/ExplorerActions/ExplorerActions';
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
import { convertDataValueToMs } from 'container/TimeSeriesView/utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
@@ -51,6 +52,7 @@ function TimeSeries({
showYAxisUnitSelector,
metrics,
isCancelled = false,
exportDefaultQuery,
}: TimeSeriesProps): JSX.Element {
const { stagedQuery, currentQuery } = useQueryBuilder();
@@ -272,6 +274,9 @@ function TimeSeries({
metricName;
const currentYAxisUnit = yAxisUnit || metricUnit;
const exportQuery = changeLayoutForOneChartPerQuery
? queryPayloads[index]
: exportDefaultQuery;
return (
<div
@@ -312,6 +317,14 @@ function TimeSeries({
error={queries[index].error as APIError}
setWarning={setWarning}
allowExport
headerActions={
<ExplorerActions
query={stagedQuery ? exportQuery : null}
sourcepage={DataSource.METRICS}
panelType={PANEL_TYPES.TIME_SERIES}
isOneChartPerQuery={changeLayoutForOneChartPerQuery}
/>
}
/>
</div>
);

View File

@@ -4,6 +4,7 @@ import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { useSearchParams } from 'react-router-dom-v5-compat';
import { render, screen } from '@testing-library/react';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import {
MetrictypesTemporalityDTO,
MetrictypesTypeDTO,
@@ -146,9 +147,11 @@ function renderExplorer(): void {
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<Provider store={store}>
<ErrorModalProvider>
<Explorer />
</ErrorModalProvider>
<TooltipProvider>
<ErrorModalProvider>
<Explorer />
</ErrorModalProvider>
</TooltipProvider>
</Provider>
</MemoryRouter>
</QueryClientProvider>,

View File

@@ -1,6 +1,7 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import * as metricsExplorerHooks from 'api/generated/services/metrics';
import { initialQueriesMap } from 'constants/queryBuilder';
import TimeSeries from '../TimeSeries';
import { TimeSeriesProps } from '../types';
@@ -71,6 +72,7 @@ function renderTimeSeries(
yAxisUnit="count"
setYAxisUnit={mockSetYAxisUnit}
showYAxisUnitSelector={false}
exportDefaultQuery={initialQueriesMap.metrics}
{...overrides}
/>,
);

View File

@@ -1,6 +1,7 @@
import { Dispatch, SetStateAction } from 'react';
import { MetricsexplorertypesMetricMetadataDTO } from 'api/generated/services/sigNoz.schemas';
import { Warning } from 'types/api';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
export interface TimeSeriesProps {
onFetchingStateChange?: (isFetching: boolean) => void;
@@ -17,4 +18,5 @@ export interface TimeSeriesProps {
setYAxisUnit: (unit: string) => void;
showYAxisUnitSelector: boolean;
isCancelled?: boolean;
exportDefaultQuery: Query;
}

View File

@@ -7,7 +7,7 @@ import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import classNames from 'classnames';
import ResizeTable from 'components/ResizeTable/ResizeTable';
import { DataType } from 'container/LogDetailedView/TableView';
import { DataType } from 'container/LogDetailedView/LogDetailedView.types';
import { CircleArrowDown, CircleArrowRight, Focus } from '@signozhq/icons';
import { MetricsExplorerEventKeys, MetricsExplorerEvents } from '../events';

View File

@@ -6,7 +6,7 @@ import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import { useGetMetricAttributes } from 'api/generated/services/metrics';
import { ResizeTable } from 'components/ResizeTable';
import { DataType } from 'container/LogDetailedView/TableView';
import { DataType } from 'container/LogDetailedView/LogDetailedView.types';
import {
Check,
Copy,

View File

@@ -21,7 +21,7 @@ import YAxisUnitSelector from 'components/YAxisUnitSelector';
import { YAxisSource } from 'components/YAxisUnitSelector/types';
import { getUniversalNameFromMetricUnit } from 'components/YAxisUnitSelector/utils';
import FieldRenderer from 'container/LogDetailedView/FieldRenderer';
import { DataType } from 'container/LogDetailedView/TableView';
import { DataType } from 'container/LogDetailedView/LogDetailedView.types';
import { useNotifications } from 'hooks/useNotifications';
import { PenLine, Save, X } from '@signozhq/icons';

View File

@@ -19,6 +19,7 @@ export enum MetricsExplorerEvents {
YAxisUnitApplied = 'Metrics Explorer: Y axis unit applied',
AddToAlertClicked = 'Metrics Explorer: Add to alert clicked',
AddToDashboardClicked = 'Metrics Explorer: Add to dashboard clicked',
AddToDashboardSuccessful = 'Metrics Explorer: Add to dashboard successful',
SaveViewClicked = 'Metrics Explorer: Save view clicked',
SearchApplied = 'Metrics Explorer: Search applied',
ViewEdited = 'Metrics Explorer: View edited',

View File

@@ -0,0 +1,25 @@
.timeSeriesView {
height: 50vh;
min-height: 350px;
padding: 0px 12px;
:global(.ant-card-body) {
height: 50vh;
min-height: 350px;
padding: 0px 12px;
}
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
flex-shrink: 0;
}
.headerActions {
display: flex;
align-items: center;
gap: 8px;
}

View File

@@ -1,19 +0,0 @@
.time-series-view {
height: 50vh;
min-height: 350px;
padding: 0px 12px;
&__header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
flex-shrink: 0;
}
.ant-card-body {
height: 50vh;
min-height: 350px;
padding: 0px 12px;
}
}

View File

@@ -1,5 +1,6 @@
import {
Dispatch,
ReactNode,
SetStateAction,
useCallback,
useEffect,
@@ -10,6 +11,7 @@ import {
// eslint-disable-next-line no-restricted-imports
import { useDispatch, useSelector } from 'react-redux';
import { useLocation } from 'react-router-dom';
import cx from 'classnames';
import logEvent from 'api/common/logEvent';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import Uplot from 'components/Uplot';
@@ -50,7 +52,7 @@ import { getTimeRange } from 'utils/getTimeRange';
import ExportMenu from 'components/ExportMenu/ExportMenu';
import './TimeSeriesView.styles.scss';
import styles from './TimeSeriesView.module.scss';
function TimeSeriesView({
data,
@@ -66,6 +68,7 @@ function TimeSeriesView({
allowExport = false,
exportFileName,
onYAxisUnitChange,
headerActions,
}: TimeSeriesViewProps): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
@@ -252,28 +255,31 @@ function TimeSeriesView({
);
const showExport = allowExport && !!data?.rawV5Response;
const showHeader = showExport || !!onYAxisUnitChange;
const showHeader = showExport || !!onYAxisUnitChange || !!headerActions;
return (
<div className="time-series-view">
<div className={cx('time-series-view', styles.timeSeriesView)}>
{isError && error && <ErrorInPlace error={error as APIError} />}
{showHeader && (
<div className="time-series-view__header">
<div className={styles.header} data-testid="time-series-view-header">
<div>
{onYAxisUnitChange && (
<BuilderUnitsFilter onChange={onYAxisUnitChange} yAxisUnit={yAxisUnit} />
)}
</div>
{showExport && data?.rawV5Response && (
<ExportMenu
dataSource={dataSource}
yAxisUnit={yAxisUnit}
data={data}
query={currentQuery}
fileName={exportFileName ?? `${dataSource}-timeseries`}
/>
)}
<div className={styles.headerActions}>
{headerActions}
{showExport && data?.rawV5Response && (
<ExportMenu
dataSource={dataSource}
yAxisUnit={yAxisUnit}
data={data}
query={currentQuery}
fileName={exportFileName ?? `${dataSource}-timeseries`}
/>
)}
</div>
</div>
)}
@@ -344,6 +350,8 @@ interface TimeSeriesViewProps {
// Opt-in: render the y-axis unit selector in the header (views without their
// own selector, e.g. Logs). Metrics keeps its separate YAxisUnitSelector.
onYAxisUnitChange?: (value: string) => void;
// Rendered in the header ahead of the export menu.
headerActions?: ReactNode;
}
TimeSeriesView.defaultProps = {

View File

@@ -131,7 +131,7 @@ describe('TimeSeriesView header gating', () => {
});
it('renders no header row when neither export nor unit selector is enabled', () => {
const { container } = renderView({ withRawV5: false });
expect(container.querySelector('.time-series-view__header')).toBeNull();
const { queryByTestId } = renderView({ withRawV5: false });
expect(queryByTestId('time-series-view-header')).toBeNull();
});
});

View File

@@ -207,4 +207,4 @@ export const routesToSkip = [
ROUTES.AI_OBSERVABILITY_EXPLORER,
];
export const routesToDisable = [ROUTES.LOGS_EXPLORER, ROUTES.LIVE_LOGS];
export const routesToDisable = [ROUTES.LOGS_EXPLORER];

View File

@@ -2,6 +2,7 @@ import {
Dispatch,
memo,
MutableRefObject,
ReactNode,
SetStateAction,
useCallback,
useEffect,
@@ -55,6 +56,7 @@ interface ListViewProps {
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
setIsLoadingQueries: Dispatch<SetStateAction<boolean>>;
queryKeyRef?: MutableRefObject<any>;
headerActions?: ReactNode;
}
function ListView({
@@ -62,6 +64,7 @@ function ListView({
setWarning,
setIsLoadingQueries,
queryKeyRef,
headerActions,
}: ListViewProps): JSX.Element {
const { stagedQuery, panelType: panelTypeFromQueryBuilder } =
useQueryBuilder();
@@ -227,6 +230,7 @@ function ListView({
return (
<div className={styles.container}>
<div className="trace-explorer-controls">
{headerActions}
<div className="order-by-container">
<div className="order-by-label">
Order by <Minus size={14} /> <ArrowUp10 size={14} />
@@ -272,6 +276,7 @@ function ListView({
ListView.defaultProps = {
queryKeyRef: undefined,
headerActions: undefined,
};
export default memo(ListView);

View File

@@ -2,6 +2,7 @@
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
padding: 12px;
flex-shrink: 0;
}

View File

@@ -2,6 +2,7 @@ import {
Dispatch,
memo,
MutableRefObject,
ReactNode,
SetStateAction,
useEffect,
useMemo,
@@ -30,10 +31,12 @@ function TableView({
setWarning,
setIsLoadingQueries,
queryKeyRef,
headerActions,
}: {
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
setIsLoadingQueries: Dispatch<SetStateAction<boolean>>;
queryKeyRef?: MutableRefObject<any>;
headerActions?: ReactNode;
}): JSX.Element {
const { stagedQuery, panelType } = useQueryBuilder();
@@ -101,14 +104,17 @@ function TableView({
return (
<Space.Compact block direction="vertical">
{isError && error && <ErrorInPlace error={error as APIError} />}
{!isError && data && (
{!isError && (
<div className="traces-table-view-header">
<ExportMenu
dataSource={DataSource.TRACES}
data={data}
query={stagedQuery || initialQueriesMap.traces}
fileName="traces-table"
/>
{headerActions}
{data && (
<ExportMenu
dataSource={DataSource.TRACES}
data={data}
query={stagedQuery || initialQueriesMap.traces}
fileName="traces-table"
/>
)}
</div>
)}
{!isError && (
@@ -125,6 +131,7 @@ function TableView({
TableView.defaultProps = {
queryKeyRef: undefined,
headerActions: undefined,
};
export default memo(TableView);

View File

@@ -2,6 +2,7 @@ import {
Dispatch,
memo,
MutableRefObject,
ReactNode,
SetStateAction,
useEffect,
useMemo,
@@ -40,6 +41,7 @@ interface TracesViewProps {
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
setIsLoadingQueries: Dispatch<SetStateAction<boolean>>;
queryKeyRef?: MutableRefObject<any>;
headerActions?: ReactNode;
}
function TracesView({
@@ -47,6 +49,7 @@ function TracesView({
setWarning,
setIsLoadingQueries,
queryKeyRef,
headerActions,
}: TracesViewProps): JSX.Element {
const { stagedQuery, panelType } = useQueryBuilder();
@@ -155,6 +158,7 @@ function TracesView({
</Typography>
<div className="trace-explorer-controls">
{headerActions}
<DownloadOptionsMenu
dataSource={DataSource.TRACES}
panelType={PANEL_TYPES.TRACE}
@@ -187,6 +191,7 @@ function TracesView({
TracesView.defaultProps = {
queryKeyRef: undefined,
headerActions: undefined,
};
export default memo(TracesView);

View File

@@ -1,183 +0,0 @@
import {
Dispatch,
SetStateAction,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { DEFAULT_PER_PAGE_VALUE } from 'container/Controls/config';
import { ORDERBY_FILTERS } from 'container/QueryBuilder/filters/OrderByFilter/config';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { isEqual } from 'lodash-es';
import { ILog } from 'types/api/logs/log';
import {
IBuilderQuery,
TagFilterItem,
} from 'types/api/queryBuilder/queryBuilderData';
interface TimeRange {
startTime: number;
endTime: number;
}
interface UsePaginatedLogsProps {
timeRange: TimeRange;
filters: IBuilderQuery['filters'];
queryKeyFilters?: string[];
excludeFilterKeys?: string[];
basePayload: GetQueryResultsProps;
}
interface UseHandleLogsPagination {
logs: ILog[];
hasReachedEndOfLogs: boolean;
isPaginating: boolean;
currentPage: number;
resetLogsList: boolean;
setIsPaginating: Dispatch<SetStateAction<boolean>>;
handleNewData: (currentData: any) => void;
loadMoreLogs: () => void;
shouldResetPage: boolean;
queryPayload: GetQueryResultsProps;
}
export const useHandleLogsPagination = ({
timeRange,
filters,
queryKeyFilters = [],
excludeFilterKeys = [],
basePayload,
}: UsePaginatedLogsProps): UseHandleLogsPagination => {
const [logs, setLogs] = useState<ILog[]>([]);
const [hasReachedEndOfLogs, setHasReachedEndOfLogs] = useState(false);
const [restFilters, setRestFilters] = useState<TagFilterItem[]>([]);
const [resetLogsList, setResetLogsList] = useState<boolean>(false);
const [page, setPage] = useState(1);
const [prevTimeRange, setPrevTimeRange] = useState<TimeRange | null>(
timeRange,
);
const [isPaginating, setIsPaginating] = useState(false);
const { shouldResetPage, newRestFilters } = useMemo(() => {
const newRestFilters = filters?.items?.filter((item) => {
const keyToCheck = item.key?.key ?? '';
return (
!queryKeyFilters.includes(keyToCheck) &&
!excludeFilterKeys.includes(keyToCheck)
);
});
const areFiltersSame = isEqual(restFilters, newRestFilters);
const shouldResetPage =
!areFiltersSame ||
timeRange.startTime !== prevTimeRange?.startTime ||
timeRange.endTime !== prevTimeRange?.endTime;
return { shouldResetPage, newRestFilters };
}, [
filters,
timeRange,
prevTimeRange,
queryKeyFilters,
excludeFilterKeys,
restFilters,
]);
const currentPage = useMemo(() => {
if (shouldResetPage) {
return 1;
}
return page;
}, [shouldResetPage, page]);
// Handle data updates
const handleNewData = useCallback(
(currentData: any) => {
if (!currentData[0].list) {
setHasReachedEndOfLogs(true);
return;
}
const currentLogs: ILog[] =
currentData[0].list?.map((item: any) => ({
...item.data,
timestamp: item.timestamp,
})) || [];
if (resetLogsList) {
setLogs(currentLogs);
setResetLogsList(false);
return;
}
const newLogs = currentLogs.filter(
(newLog) => !logs.some((existingLog) => isEqual(existingLog, newLog)),
);
if (newLogs.length > 0) {
setLogs((prev) => [...prev, ...newLogs]);
}
},
[logs, resetLogsList],
);
// Reset logic
useEffect(() => {
if (shouldResetPage) {
setPage(1);
setLogs([]);
setResetLogsList(true);
}
setPrevTimeRange(timeRange);
setRestFilters(newRestFilters || []);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [shouldResetPage, timeRange]);
const loadMoreLogs = useCallback(() => {
if (!logs.length) {
return;
}
setPage((prev) => prev + 1);
setIsPaginating(true);
}, [logs]);
const queryPayload = useMemo(
() => ({
...basePayload,
query: {
...basePayload.query,
builder: {
...basePayload.query.builder,
queryData: [
{
...basePayload.query.builder.queryData[0],
pageSize: DEFAULT_PER_PAGE_VALUE,
offset: (currentPage - 1) * DEFAULT_PER_PAGE_VALUE,
orderBy: [
{ columnName: 'timestamp', order: ORDERBY_FILTERS.DESC },
{ columnName: 'id', order: ORDERBY_FILTERS.DESC },
],
},
],
},
},
}),
[basePayload, currentPage],
);
return {
logs,
hasReachedEndOfLogs,
isPaginating,
currentPage,
resetLogsList,
queryPayload,
setIsPaginating,
handleNewData,
loadMoreLogs,
shouldResetPage,
};
};

View File

@@ -1,9 +0,0 @@
export const fieldSearchFilter = (
searchSpace = '',
currentValue = '',
): boolean => {
if (!currentValue || !searchSpace) {
return true;
}
return searchSpace.toLowerCase().indexOf(currentValue.toLowerCase()) !== -1;
};

View File

@@ -1,6 +1,7 @@
import {
Dispatch,
MutableRefObject,
ReactNode,
SetStateAction,
useEffect,
useMemo,
@@ -29,6 +30,7 @@ function TimeSeriesViewContainer({
setWarning,
setIsLoadingQueries,
queryKeyRef,
headerActions,
}: TimeSeriesViewProps): JSX.Element {
const { stagedQuery, currentQuery, panelType } = useQueryBuilder();
@@ -126,6 +128,7 @@ function TimeSeriesViewContainer({
dataSource={dataSource}
setWarning={setWarning}
allowExport
headerActions={headerActions}
/>
</div>
);
@@ -137,11 +140,13 @@ interface TimeSeriesViewProps {
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
setIsLoadingQueries: Dispatch<SetStateAction<boolean>>;
queryKeyRef?: MutableRefObject<any>;
headerActions?: ReactNode;
}
TimeSeriesViewContainer.defaultProps = {
dataSource: DataSource.TRACES,
queryKeyRef: undefined,
headerActions: undefined,
};
export default TimeSeriesViewContainer;

View File

@@ -13,6 +13,8 @@ import { LOCALSTORAGE } from 'constants/localStorage';
import { AVAILABLE_EXPORT_PANEL_TYPES } from 'constants/panelTypes';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { usePageActions } from 'container/AIAssistant/pageActions/usePageActions';
import ExplorerActions from 'container/ExplorerActions/ExplorerActions';
import { getExportPanelType } from 'container/ExplorerActions/utils';
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
import { useOptionsMenu } from 'container/OptionsMenu';
import LeftToolbarActions from 'container/QueryBuilder/components/ToolbarActions/LeftToolbarActions';
@@ -194,6 +196,27 @@ function TracesExplorer(): JSX.Element {
[stagedQuery, panelType],
);
const exportDashboardQuery = useMemo(
() =>
getExportQueryData(
exportDefaultQuery,
getExportPanelType(panelType),
options,
),
[exportDefaultQuery, panelType, options],
);
const explorerActions = useMemo(
() => (
<ExplorerActions
query={stagedQuery ? exportDefaultQuery : null}
dashboardQuery={stagedQuery ? exportDashboardQuery : null}
sourcepage={DataSource.TRACES}
/>
),
[stagedQuery, exportDefaultQuery, exportDashboardQuery],
);
const handleExport = useCallback(
(dashboard: ExportDashboard | null, isNewDashboard?: boolean): void => {
if (!dashboard || !panelType) {
@@ -318,6 +341,7 @@ function TracesExplorer(): JSX.Element {
setWarning={setWarning}
setIsLoadingQueries={setIsLoadingQueries}
queryKeyRef={listQueryKeyRef}
headerActions={explorerActions}
/>
</div>
)}
@@ -329,6 +353,7 @@ function TracesExplorer(): JSX.Element {
setWarning={setWarning}
setIsLoadingQueries={setIsLoadingQueries}
queryKeyRef={listQueryKeyRef}
headerActions={explorerActions}
/>
</div>
)}
@@ -341,6 +366,7 @@ function TracesExplorer(): JSX.Element {
setWarning={setWarning}
setIsLoadingQueries={setIsLoadingQueries}
queryKeyRef={listQueryKeyRef}
headerActions={explorerActions}
/>
</div>
)}
@@ -351,6 +377,7 @@ function TracesExplorer(): JSX.Element {
setWarning={setWarning}
setIsLoadingQueries={setIsLoadingQueries}
queryKeyRef={listQueryKeyRef}
headerActions={explorerActions}
/>
</div>
)}

View File

@@ -95,7 +95,6 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
VERSION: ['ADMIN', 'EDITOR', 'VIEWER'],
LOGS: ['ADMIN', 'EDITOR', 'VIEWER'],
LOGS_EXPLORER: ['ADMIN', 'EDITOR', 'VIEWER'],
LIVE_LOGS: ['ADMIN', 'EDITOR', 'VIEWER'],
LIST_LICENSES: ['ADMIN'],
LOGS_INDEX_FIELDS: ['ADMIN', 'EDITOR', 'VIEWER'],
LOGS_PIPELINES: ['ADMIN', 'EDITOR', 'VIEWER'],
@@ -169,7 +168,6 @@ export const routeWithInitialAuthZSupport = {
TRACE_DETAIL: true,
LOGS: true,
LOGS_EXPLORER: true,
LIVE_LOGS: true,
ROLES_SETTINGS: true,
ROLE_CREATE: true,
ROLE_DETAILS: true,

2
go.mod
View File

@@ -12,7 +12,6 @@ require (
github.com/SigNoz/signoz-otel-collector v0.144.6
github.com/antlr4-go/antlr/v4 v4.13.1
github.com/antonmedv/expr v1.15.3
github.com/bytedance/sonic v1.14.1
github.com/cespare/xxhash/v2 v2.3.0
github.com/coreos/go-oidc/v3 v3.17.0
github.com/dgraph-io/ristretto/v2 v2.3.0
@@ -112,6 +111,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 // indirect
github.com/aws/smithy-go v1.24.2 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.14.1 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect

View File

@@ -5,6 +5,7 @@ import (
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/binding"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/prometheus"
@@ -79,6 +80,14 @@ func (h *prometheusOpenAPIHandler) ResourceDefs() []handler.ResourceDef {
}}
}
func (h *prometheusOpenAPIHandler) Request() any {
return nil
}
func (h *prometheusOpenAPIHandler) BindBodyOptions() []binding.BindBodyOption {
return nil
}
func (provider *provider) addPrometheusRoutes(router *mux.Router) error {
if err := router.Handle("/prometheus/api/v1/query", &prometheusOpenAPIHandler{
handlerFunc: provider.authzMiddleware.CheckResources(provider.prometheusHandler.Query, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),

View File

@@ -4,6 +4,7 @@ import (
"net/http"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/http/binding"
pkghandler "github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/gorilla/mux"
@@ -55,6 +56,14 @@ func (handler *healthOpenAPIHandler) ResourceDefs() []pkghandler.ResourceDef {
return nil
}
func (handler *healthOpenAPIHandler) Request() any {
return nil
}
func (handler *healthOpenAPIHandler) BindBodyOptions() []binding.BindBodyOption {
return nil
}
func (provider *provider) addRegistryRoutes(router *mux.Router) error {
if err := router.Handle("/api/v2/healthz", newHealthOpenAPIHandler(
provider.authzMiddleware.OpenAccess(provider.factoryHandler.Healthz),

View File

@@ -358,10 +358,20 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryAccessControl,
SourceResource: coretypes.ResourceServiceAccount,
SourceIDs: coretypes.OneID(coretypes.BodyJSONPath("serviceAccountId")),
SourceIDs: coretypes.OneID(coretypes.BodyField(func(req *serviceaccounttypes.PostableServiceAccountRole) string {
if req.ServiceAccountID.IsZero() {
return ""
}
return req.ServiceAccountID.StringValue()
})),
SourceSelector: coretypes.IDSelector,
TargetResource: coretypes.ResourceRole,
TargetIDs: coretypes.OneID(coretypes.BodyJSONPath("roleId")),
TargetIDs: coretypes.OneID(coretypes.BodyField(func(req *serviceaccounttypes.PostableServiceAccountRole) string {
if req.RoleID.IsZero() {
return ""
}
return req.RoleID.StringValue()
})),
TargetSelector: provider.roleSelector,
}),
)).Methods(http.MethodPost).GetError(); err != nil {

View File

@@ -1,10 +1,13 @@
package handler
import (
"fmt"
"net/http"
"reflect"
"slices"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/binding"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/swaggest/openapi-go"
"github.com/swaggest/openapi-go/openapi3"
@@ -16,12 +19,15 @@ type Handler interface {
http.Handler
ServeOpenAPI(openapi.OperationContext)
ResourceDefs() []ResourceDef
Request() any
BindBodyOptions() []binding.BindBodyOption
}
type handler struct {
handlerFunc http.HandlerFunc
openAPIDef OpenAPIDef
resourceDefs []ResourceDef
handlerFunc http.HandlerFunc
openAPIDef OpenAPIDef
resourceDefs []ResourceDef
bindBodyOptions []binding.BindBodyOption
}
func New(handlerFunc http.HandlerFunc, openAPIDef OpenAPIDef, opts ...Option) Handler {
@@ -47,6 +53,10 @@ func New(handlerFunc http.HandlerFunc, openAPIDef OpenAPIDef, opts ...Option) Ha
opt(handler)
}
if RequiresBody(handler.resourceDefs) && (openAPIDef.Request == nil || reflect.TypeOf(openAPIDef.Request).Kind() != reflect.Pointer) {
panic(fmt.Sprintf("handler %s: a body extractor needs OpenAPIDef.Request to be a pointer, got %T", openAPIDef.ID, openAPIDef.Request))
}
return handler
}
@@ -135,3 +145,11 @@ func (handler *handler) ServeOpenAPI(opCtx openapi.OperationContext) {
func (handler *handler) ResourceDefs() []ResourceDef {
return handler.resourceDefs
}
func (handler *handler) Request() any {
return handler.openAPIDef.Request
}
func (handler *handler) BindBodyOptions() []binding.BindBodyOption {
return handler.bindBodyOptions
}

View File

@@ -4,6 +4,8 @@ import (
"net/http"
"testing"
"github.com/SigNoz/signoz/pkg/http/binding"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/gorilla/mux"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -22,6 +24,41 @@ func (bespokeOpenAPIHandler) ServeOpenAPI(opCtx openapi.OperationContext) {
func (bespokeOpenAPIHandler) ResourceDefs() []ResourceDef { return nil }
func (bespokeOpenAPIHandler) Request() any { return nil }
func (bespokeOpenAPIHandler) BindBodyOptions() []binding.BindBodyOption { return nil }
func TestNewPanicsWhenBodyExtractorHasNoPointerRequest(t *testing.T) {
type body struct{ ID string }
bodyDef := BasicResourceDef{Resource: coretypes.ResourceRole, Verb: coretypes.VerbRead, ID: coretypes.BodyField(func(req *body) string { return req.ID }), Selector: coretypes.IDSelector}
pathDef := BasicResourceDef{Resource: coretypes.ResourceRole, Verb: coretypes.VerbRead, ID: coretypes.PathParam("id"), Selector: coretypes.IDSelector}
testCases := []struct {
name string
request any
def ResourceDef
panics bool
}{
{name: "BodyExtractor_ValueRequest_Panics", request: body{}, def: bodyDef, panics: true},
{name: "BodyExtractor_NilRequest_Panics", request: nil, def: bodyDef, panics: true},
{name: "BodyExtractor_PointerRequest_Registers", request: new(body), def: bodyDef, panics: false},
{name: "PathExtractor_ValueRequest_Registers", request: body{}, def: pathDef, panics: false},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
register := func() {
New(func(http.ResponseWriter, *http.Request) {}, OpenAPIDef{ID: testCase.name, Request: testCase.request}, WithResourceDefs(testCase.def))
}
if testCase.panics {
assert.Panics(t, register)
} else {
assert.NotPanics(t, register)
}
})
}
}
func TestAttachStabilities(t *testing.T) {
router := mux.NewRouter()
router.Handle("/development", New(func(http.ResponseWriter, *http.Request) {}, OpenAPIDef{ID: "Development", SuccessStatusCode: http.StatusOK, Stability: StabilityDevelopment})).Methods(http.MethodGet)

View File

@@ -1,5 +1,7 @@
package handler
import "github.com/SigNoz/signoz/pkg/http/binding"
type Option func(*handler)
func WithResourceDefs(defs ...ResourceDef) Option {
@@ -7,3 +9,9 @@ func WithResourceDefs(defs ...ResourceDef) Option {
h.resourceDefs = append(h.resourceDefs, defs...)
}
}
func WithBindBodyOptions(opts ...binding.BindBodyOption) Option {
return func(h *handler) {
h.bindBodyOptions = append(h.bindBodyOptions, opts...)
}
}

View File

@@ -9,6 +9,7 @@ type ResourceDef interface {
// resolveRequest is unexported to seal the interface. It returns a slice so a
// single def can fan out (e.g. a telemetry query touching multiple signals).
resolveRequest(ec coretypes.ExtractorContext) []coretypes.ResolvedResource
requiresBody() bool
}
func ResolveRequest(defs []ResourceDef, ec coretypes.ExtractorContext) []coretypes.ResolvedResource {
@@ -20,6 +21,17 @@ func ResolveRequest(defs []ResourceDef, ec coretypes.ExtractorContext) []coretyp
return resolved
}
// RequiresBody reports whether any def needs the decoded request body.
func RequiresBody(defs []ResourceDef) bool {
for _, def := range defs {
if def.requiresBody() {
return true
}
}
return false
}
// BasicResourceDef checks a single resource for one verb.
type BasicResourceDef struct {
Resource coretypes.Resource
@@ -42,6 +54,10 @@ func (def BasicResourceDef) resolveRequest(ec coretypes.ExtractorContext) []core
}
}
func (def BasicResourceDef) requiresBody() bool {
return def.ID.RequiresBody
}
// AttachDetachSiblingResourceDef checks an attach/detach between peer resources;
// both source and target are authz-checked.
type AttachDetachSiblingResourceDef struct {
@@ -72,6 +88,10 @@ func (def AttachDetachSiblingResourceDef) resolveRequest(ec coretypes.ExtractorC
}
}
func (def AttachDetachSiblingResourceDef) requiresBody() bool {
return def.SourceIDs.RequiresBody || def.TargetIDs.RequiresBody
}
// AttachDetachParentChildResourceDef authz-checks only the parent; the child
// rides along for audit context.
type AttachDetachParentChildResourceDef struct {
@@ -101,11 +121,20 @@ func (def AttachDetachParentChildResourceDef) resolveRequest(ec coretypes.Extrac
}
}
func (def AttachDetachParentChildResourceDef) requiresBody() bool {
return def.ParentID.RequiresBody || def.ChildIDs.RequiresBody
}
type TelemetryResourceDef struct {
Verb coretypes.Verb
Category coretypes.ActionCategory
Selector coretypes.SelectorFunc
Resources coretypes.ResourceExtractor
Verb coretypes.Verb
Category coretypes.ActionCategory
Selector coretypes.SelectorFunc
Resources coretypes.ResourceExtractor
RequiresBody bool
}
func (def TelemetryResourceDef) requiresBody() bool {
return def.RequiresBody
}
func (def TelemetryResourceDef) resolveRequest(ec coretypes.ExtractorContext) []coretypes.ResolvedResource {

View File

@@ -5,7 +5,9 @@ import (
"io"
"log/slog"
"net/http"
"reflect"
"github.com/SigNoz/signoz/pkg/http/binding"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/gorilla/mux"
@@ -23,8 +25,8 @@ func NewResource(logger *slog.Logger) *Resource {
func (middleware *Resource) Wrap(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
defs := resourceDefsFromRequest(req)
if len(defs) == 0 {
provider := handlerFromRequest(req)
if provider == nil || len(provider.ResourceDefs()) == 0 {
next.ServeHTTP(rw, req)
return
}
@@ -36,18 +38,40 @@ func (middleware *Resource) Wrap(next http.Handler) http.Handler {
req.Body = io.NopCloser(bytes.NewReader(body))
}
extractorCtx := coretypes.ExtractorContext{
Request: req,
RequestBody: body,
}
resolved := handler.ResolveRequest(defs, extractorCtx)
defs := provider.ResourceDefs()
ctx := coretypes.NewContextWithResolvedResources(req.Context(), resolved)
var decoded any
var decodeErr error
if handler.RequiresBody(defs) {
decoded, decodeErr = decodeBody(provider.Request(), body, provider.BindBodyOptions()...)
}
extractorCtx := coretypes.ExtractorContext{Request: req, RequestBody: body, DecodedRequestBody: decoded}
var resolved []coretypes.ResolvedResource
if decodeErr != nil {
// authz renders the error inside the audit middleware, so the request is still logged
resolved = []coretypes.ResolvedResource{coretypes.NewResolvedResourceWithError(coretypes.Verb{}, coretypes.ActionCategory{}, decodeErr)}
} else {
resolved = handler.ResolveRequest(defs, extractorCtx)
}
ctx := coretypes.NewContextWithExtractorContext(req.Context(), extractorCtx)
ctx = coretypes.NewContextWithResolvedResources(ctx, resolved)
next.ServeHTTP(rw, req.WithContext(ctx))
})
}
func resourceDefsFromRequest(req *http.Request) []handler.ResourceDef {
func decodeBody(prototype any, body []byte, opts ...binding.BindBodyOption) (any, error) {
decoded := reflect.New(reflect.TypeOf(prototype).Elem()).Interface()
if err := binding.JSON.BindBody(bytes.NewReader(body), decoded, opts...); err != nil {
return nil, err
}
return decoded, nil
}
func handlerFromRequest(req *http.Request) handler.Handler {
route := mux.CurrentRoute(req)
if route == nil {
return nil
@@ -63,5 +87,5 @@ func resourceDefsFromRequest(req *http.Request) []handler.ResourceDef {
return nil
}
return provider.ResourceDefs()
return provider
}

View File

@@ -246,7 +246,7 @@
"resourceType": "containerApps",
"metrics": {},
"logs": {
"categoryGroups": ["ContainerAppConsoleLogs", "ContainerAppSystemLogs"]
"categoryGroups": ["allLogs"]
}
}
},

View File

@@ -8,6 +8,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/serviceaccounttypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
@@ -222,8 +223,8 @@ func (handler *handler) CreateServiceAccountRole(rw http.ResponseWriter, r *http
return
}
req := new(serviceaccounttypes.PostableServiceAccountRole)
if err := binding.JSON.BindBody(r.Body, req); err != nil {
req, err := coretypes.BodyFromContext[serviceaccounttypes.PostableServiceAccountRole](r.Context())
if err != nil {
render.Error(rw, err)
return
}

View File

@@ -17,7 +17,6 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/spantypes"
"github.com/SigNoz/signoz/pkg/types/telemetrystoretypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
@@ -40,11 +39,18 @@ func stripKeyAlias(name string) string {
return keyAliasRe.ReplaceAllString(name, "")
}
// unwrapVariant returns the concrete value inside the chcol.Variant envelope the driver scans a
// Dynamic column — a JSON path such as body_v2.level — into.
func unwrapVariant(val any) any {
if v, ok := val.(chcol.Variant); ok {
// unwrapVariant decodes a scan envelope: a chcol.Variant to its value, a chcol.JSON column to a map.
// The attributes bag decodes flat so its dotted keys merge with the legacy attribute maps and a
// scalar-and-object key stays two keys; every other JSON column decodes nested.
func unwrapVariant(name string, val any) any {
switch v := val.(type) {
case chcol.Variant:
return v.Any()
case chcol.JSON:
if name == "attributes" {
return v.ValuesByPath()
}
return v.NestedMap()
}
return val
}
@@ -54,11 +60,11 @@ func unwrapVariant(val any) any {
// series. JSON goes through encoding/json for its sorted map keys: ClickHouse groups documents by
// structure, so two rows it considers equal have to produce the same label.
func labelValue(val any) string {
val = unwrapVariant(val)
val = unwrapVariant("", val)
if val == nil {
return ""
}
if v, ok := val.(telemetrystoretypes.JSONValue); ok {
if v, ok := val.(map[string]any); ok {
if raw, err := json.Marshal(v); err == nil {
return string(raw)
}
@@ -204,7 +210,7 @@ func readAsTimeSeries(rows driver.Rows, queryWindow *qbtypes.TimeRange, step qbt
Value: *val,
})
case *telemetrystoretypes.JSONValue, *chcol.Variant:
case *chcol.JSON, *chcol.Variant:
val := labelValue(derefValue(ptr))
lblVals = append(lblVals, val)
lblObjs = append(lblObjs, &qbtypes.Label{
@@ -478,7 +484,7 @@ func readAsScalar(rows driver.Rows, queryName string) (*qbtypes.ScalarData, erro
// 2. deref each slot into the output row
row := make([]any, len(scan))
for i, cell := range scan {
row[i] = unwrapVariant(derefValue(cell))
row[i] = unwrapVariant(cd[i].Name, derefValue(cell))
}
data = append(data, row)
}
@@ -536,7 +542,7 @@ func readAsRaw(rows driver.Rows, queryName string) (*qbtypes.RawData, error) {
name := stripKeyAlias(colNames[i])
// de-reference the typed pointer to any
val := unwrapVariant(reflect.ValueOf(cellPtr).Elem().Interface())
val := unwrapVariant(name, reflect.ValueOf(cellPtr).Elem().Interface())
// special-case: timestamp column
if name == "timestamp" || name == "timestamp_datetime" {
@@ -576,8 +582,6 @@ func flattenJSONPaths(prefix string, m map[string]any, out map[string]any) {
switch child := v.(type) {
case map[string]any:
flattenJSONPaths(key, child, out)
case telemetrystoretypes.JSONValue:
flattenJSONPaths(key, child, out)
default:
out[key] = v
}
@@ -593,7 +597,7 @@ func mergeSpanAttributeColumns(data map[string]any) {
attrStr, hasStr := data["attributes_string"]
attrNum, hasNum := data["attributes_number"]
attrBool, hasBool := data["attributes_bool"]
attrJSON, _ := data["attributes"].(telemetrystoretypes.JSONValue)
attrJSON, _ := data["attributes"].(map[string]any)
// todo(nitya): move to resource json
resStr, hasRes := data["resources_string"]
if hasStr || hasNum || hasBool || attrJSON != nil || hasRes {

View File

@@ -3,14 +3,9 @@ package querier
import (
"reflect"
"testing"
"time"
"github.com/ClickHouse/clickhouse-go/v2/lib/chcol"
cmock "github.com/SigNoz/clickhouse-go-mock"
"github.com/SigNoz/signoz/pkg/telemetrystore"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/spantypes"
"github.com/SigNoz/signoz/pkg/types/telemetrystoretypes"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -83,101 +78,47 @@ func TestMergeSpanAttributeColumns_ParsesEventsAndLinks(t *testing.T) {
}
}
// A ClickHouse query can put a JSON column in the result of any request type — e.g.
// `select * from signoz_logs.logs_v2` on a body_v2 stack, where `*` covers body_v2.
func TestConsume_JSONColumn(t *testing.T) {
ts := time.Date(2026, 8, 14, 10, 0, 0, 0, time.UTC)
body := `{"level":"error","attrs":{"code":500}}`
wantBody := telemetrystoretypes.JSONValue{
"level": "error",
"attrs": map[string]any{"code": float64(500)},
func TestUnwrapVariant(t *testing.T) {
j := chcol.NewJSON()
j.SetValueAtPath("level", "error")
j.SetValueAtPath("attrs.code", int64(500))
testCases := []struct {
name string
column string
input any
want any
}{
{name: "VariantScalar", column: "", input: chcol.NewDynamicWithType("error", "String"), want: "error"},
{name: "EmptyDynamic", column: "", input: chcol.Dynamic{}, want: nil},
{name: "PlainValuePassthrough", column: "", input: uint64(3), want: uint64(3)},
{name: "JSONColumnNested", column: "body_v2", input: *j, want: map[string]any{"level": "error", "attrs": map[string]any{"code": int64(500)}}},
{name: "AttributesColumnFlat", column: "attributes", input: *j, want: map[string]any{"level": "error", "attrs.code": int64(500)}},
}
// the scalar reader reuses its scan slots across rows, so each row must still carry its own body
t.Run("scalar", func(t *testing.T) {
rows := telemetrystore.WrapRows(cmock.NewRows([]cmock.ColumnType{
{Name: "body_v2", Type: "JSON"},
{Name: "__result_0", Type: "UInt64"},
}, [][]any{{body, uint64(3)}, {`{"level":"warn"}`, uint64(1)}}))
payload, err := consume(rows, qbtypes.RequestTypeScalar, nil, qbtypes.Step{}, "A")
require.NoError(t, err)
data := payload.(*qbtypes.ScalarData)
require.Len(t, data.Data, 2)
assert.Equal(t, wantBody, data.Data[0][0])
assert.Equal(t, uint64(3), data.Data[0][1])
assert.Equal(t, telemetrystoretypes.JSONValue{"level": "warn"}, data.Data[1][0])
assert.Equal(t, uint64(1), data.Data[1][1])
})
t.Run("time series", func(t *testing.T) {
rows := telemetrystore.WrapRows(cmock.NewRows([]cmock.ColumnType{
{Name: "ts", Type: "DateTime"},
{Name: "body_v2", Type: "JSON"},
{Name: "__result_0", Type: "UInt64"},
}, [][]any{{ts, body, uint64(3)}}))
payload, err := consume(rows, qbtypes.RequestTypeTimeSeries, nil, qbtypes.Step{}, "A")
require.NoError(t, err)
data := payload.(*qbtypes.TimeSeriesData)
require.Len(t, data.Aggregations, 1)
require.Len(t, data.Aggregations[0].Series, 1)
require.Len(t, data.Aggregations[0].Series[0].Values, 1)
assert.Equal(t, float64(3), data.Aggregations[0].Series[0].Values[0].Value)
})
// grouping by a JSON column is legal in ClickHouse, so each document has to label its own
// series rather than being dropped, which would merge every group into one
t.Run("time series grouped by the JSON column", func(t *testing.T) {
rows := telemetrystore.WrapRows(cmock.NewRows([]cmock.ColumnType{
{Name: "ts", Type: "DateTime"},
{Name: "body_v2", Type: "JSON"},
{Name: "__result_0", Type: "UInt64"},
}, [][]any{
{ts, `{"level":"error"}`, uint64(7)},
{ts, `{"level":"warn"}`, uint64(2)},
}))
payload, err := consume(rows, qbtypes.RequestTypeTimeSeries, nil, qbtypes.Step{}, "A")
require.NoError(t, err)
data := payload.(*qbtypes.TimeSeriesData)
require.Len(t, data.Aggregations, 1)
require.Len(t, data.Aggregations[0].Series, 2)
got := map[string]float64{}
for _, series := range data.Aggregations[0].Series {
require.Len(t, series.Labels, 1)
require.Len(t, series.Values, 1)
got[series.Labels[0].Value.(string)] = series.Values[0].Value
}
assert.Equal(t, map[string]float64{`{"level":"error"}`: 7, `{"level":"warn"}`: 2}, got)
})
t.Run("raw", func(t *testing.T) {
rows := telemetrystore.WrapRows(cmock.NewRows([]cmock.ColumnType{
{Name: "timestamp", Type: "DateTime"},
{Name: "body_v2", Type: "JSON"},
}, [][]any{{ts, body}}))
payload, err := consume(rows, qbtypes.RequestTypeRaw, nil, qbtypes.Step{}, "A")
require.NoError(t, err)
data := payload.(*qbtypes.RawData)
require.Len(t, data.Rows, 1)
assert.Equal(t, ts, data.Rows[0].Timestamp.UTC())
assert.Equal(t, wantBody, data.Rows[0].Data["body_v2"])
})
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.want, unwrapVariant(testCase.column, testCase.input))
})
}
}
// A JSON path (e.g. `body_v2.level`) comes back as a Dynamic column, which the driver scans
// into a chcol.Variant envelope rather than the value itself.
func TestUnwrapVariant(t *testing.T) {
assert.Equal(t, "error", unwrapVariant(chcol.NewDynamicWithType("error", "String")))
assert.Nil(t, unwrapVariant(chcol.Dynamic{}))
assert.Equal(t, uint64(3), unwrapVariant(uint64(3)))
func TestLabelValue(t *testing.T) {
testCases := []struct {
name string
input any
want string
}{
{name: "Nil", input: nil, want: ""},
{name: "Variant", input: chcol.NewDynamicWithType("error", "String"), want: "error"},
{name: "JSONMap", input: map[string]any{"level": "error", "attrs": map[string]any{"code": 500}}, want: `{"attrs":{"code":500},"level":"error"}`},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.want, labelValue(testCase.input))
})
}
}
func TestMergeSpanAttributeColumns_EmptyEventsAndLinks(t *testing.T) {
@@ -207,7 +148,7 @@ func TestMergeSpanAttributeColumns_JSONColumn(t *testing.T) {
{
name: "JSONOnly_FlattensNestedPaths_PreservesTypes",
data: map[string]any{
"attributes": telemetrystoretypes.JSONValue{
"attributes": map[string]any{
"http": map[string]any{"route": "/api/pay", "retry": map[string]any{"count": float64(3)}},
"cache.hit": true,
},
@@ -219,7 +160,7 @@ func TestMergeSpanAttributeColumns_JSONColumn(t *testing.T) {
data: map[string]any{
"attributes_string": map[string]string{"http.route": "/old", "only.map": "m"},
"attributes_number": map[string]float64{"http.status": 500},
"attributes": telemetrystoretypes.JSONValue{"http": map[string]any{"route": "/new"}, "only.json": "j"},
"attributes": map[string]any{"http": map[string]any{"route": "/new"}, "only.json": "j"},
},
want: map[string]any{"http.route": "/old", "only.map": "m", "http.status": float64(500), "only.json": "j"},
},
@@ -229,7 +170,7 @@ func TestMergeSpanAttributeColumns_JSONColumn(t *testing.T) {
"attributes_string": map[string]string{"http.route": "/map"},
"attributes_number": map[string]float64{"http.status": 200},
"attributes_bool": map[string]bool{"cache.hit": true},
"attributes": telemetrystoretypes.JSONValue{},
"attributes": map[string]any{},
},
want: map[string]any{"http.route": "/map", "http.status": float64(200), "cache.hit": true},
},
@@ -237,28 +178,28 @@ func TestMergeSpanAttributeColumns_JSONColumn(t *testing.T) {
name: "MapOnly_NilJSON_BehavesAsAbsent",
data: map[string]any{
"attributes_string": map[string]string{"http.route": "/map"},
"attributes": telemetrystoretypes.JSONValue(nil),
"attributes": map[string]any(nil),
},
want: map[string]any{"http.route": "/map"},
},
{
name: "Arrays_StayLeafValues",
data: map[string]any{
"attributes": telemetrystoretypes.JSONValue{"http": map[string]any{"tags": []any{"a", "b"}, "codes": []any{float64(1), float64(2)}}},
"attributes": map[string]any{"http": map[string]any{"tags": []any{"a", "b"}, "codes": []any{float64(1), float64(2)}}},
},
want: map[string]any{"http.tags": []any{"a", "b"}, "http.codes": []any{float64(1), float64(2)}},
},
{
name: "TopLevelArrayOfMaps_StaysNativeLeaf",
data: map[string]any{
"attributes": telemetrystoretypes.JSONValue{"key": []any{map[string]any{"a": float64(1)}, map[string]any{"b": float64(2)}}},
"attributes": map[string]any{"key": []any{map[string]any{"a": float64(1)}, map[string]any{"b": float64(2)}}},
},
want: map[string]any{"key": []any{map[string]any{"a": float64(1)}, map[string]any{"b": float64(2)}}},
},
{
name: "NestedArrayOfMaps_StaysNativeLeaf_NoIndexPaths",
data: map[string]any{
"attributes": telemetrystoretypes.JSONValue{"http": map[string]any{"items": []any{map[string]any{"a": float64(1)}}}},
"attributes": map[string]any{"http": map[string]any{"items": []any{map[string]any{"a": float64(1)}}}},
},
want: map[string]any{"http.items": []any{map[string]any{"a": float64(1)}}},
},
@@ -266,28 +207,28 @@ func TestMergeSpanAttributeColumns_JSONColumn(t *testing.T) {
name: "DualWritten_NestedArray_IndexKeysAndJSONArrayCoexist",
data: map[string]any{
"attributes_number": map[string]float64{"http.items.0.a": 1},
"attributes": telemetrystoretypes.JSONValue{"http": map[string]any{"items": []any{map[string]any{"a": float64(1)}}}},
"attributes": map[string]any{"http": map[string]any{"items": []any{map[string]any{"a": float64(1)}}}},
},
want: map[string]any{"http.items.0.a": float64(1), "http.items": []any{map[string]any{"a": float64(1)}}},
},
{
name: "JSONNull_KeptAsNil",
data: map[string]any{
"attributes": telemetrystoretypes.JSONValue{"k": nil},
"attributes": map[string]any{"k": nil},
},
want: map[string]any{"k": nil},
},
{
name: "KeyIsLeafValue_NotFlattened",
data: map[string]any{
"attributes": telemetrystoretypes.JSONValue{"http": "plaintext"},
"attributes": map[string]any{"http": "plaintext"},
},
want: map[string]any{"http": "plaintext"},
},
{
name: "KeyIsParent_FlattensToDottedPath",
data: map[string]any{
"attributes": telemetrystoretypes.JSONValue{"http": map[string]any{"route": "/a"}},
"attributes": map[string]any{"http": map[string]any{"route": "/a"}},
},
want: map[string]any{"http.route": "/a"},
},

View File

@@ -16,7 +16,6 @@ import (
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/types/featuretypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrystoretypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -1202,7 +1201,7 @@ func (q *querier) postProcessLogBody(ctx context.Context, orgID valuer.UUID, res
// carried one. Anything that is not a decoded document — the legacy string body, a NULL cell —
// is legal under these names and left alone.
func stripEmptyBodyMessage(val any) {
bodyMap, ok := val.(telemetrystoretypes.JSONValue)
bodyMap, ok := val.(map[string]any)
if !ok {
return
}

View File

@@ -13,7 +13,6 @@ import (
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
@@ -50,6 +49,11 @@ type rule struct {
OrgID string `bun:"org_id,type:text"`
}
type routePolicyRuleData struct {
PreferredChannels []string `json:"preferredChannels"`
Labels map[string]string `json:"labels"`
}
type addRoutePolicies struct {
sqlstore sqlstore.SQLStore
sqlschema sqlschema.SQLSchema
@@ -187,20 +191,20 @@ func (migration *addRoutePolicies) migrateRulesToRoutePolicies(ctx context.Conte
func (migration *addRoutePolicies) convertRulesToRoutes(rules []*rule, channelsByOrg map[string][]string) ([]*expressionRoute, error) {
var routes []*expressionRoute
for _, r := range rules {
var gettableRule ruletypes.GettableRule
if err := json.Unmarshal([]byte(r.Data), &gettableRule); err != nil {
var ruleData routePolicyRuleData
if err := json.Unmarshal([]byte(r.Data), &ruleData); err != nil {
return nil, errors.NewInternalf(errors.CodeInternal, "failed to unmarshal rule data for rule ID %s: %v", r.ID, err)
}
if len(gettableRule.PreferredChannels) == 0 {
if len(ruleData.PreferredChannels) == 0 {
channels, exists := channelsByOrg[r.OrgID]
if !exists || len(channels) == 0 {
continue
}
gettableRule.PreferredChannels = channels
ruleData.PreferredChannels = channels
}
severity := "critical"
if v, ok := gettableRule.Labels["severity"]; ok {
if v, ok := ruleData.Labels["severity"]; ok {
severity = v
}
expression := fmt.Sprintf(`%s == "%s" && %s == "%s"`, "threshold.name", severity, "ruleId", r.ID.String())
@@ -218,7 +222,7 @@ func (migration *addRoutePolicies) convertRulesToRoutes(rules []*rule, channelsB
},
Expression: expression,
ExpressionKind: "rule",
Channels: gettableRule.PreferredChannels,
Channels: ruleData.PreferredChannels,
Name: r.ID.StringValue(),
Enabled: true,
OrgID: r.OrgID,

View File

@@ -97,8 +97,8 @@ func New(ctx context.Context, providerSettings factory.ProviderSettings, config
options.MaxIdleConns = config.Connection.MaxIdleConns
options.MaxOpenConns = config.Connection.MaxOpenConns
options.DialTimeout = config.Connection.DialTimeout
// This is to avoid the driver decoding issues with JSON columns
options.Settings["output_format_native_write_json_as_string"] = 1
// Native flattened JSON serialization (CH 25.6+): clickhouse-go mis-decodes JSON(max_dynamic_paths=0) columns without it.
options.Settings["output_format_native_use_flattened_dynamic_and_json_serialization"] = 1
chConn, err := clickhouse.Open(options)
if err != nil {
@@ -184,7 +184,7 @@ func (p *provider) Query(ctx context.Context, query string, args ...interface{})
}
return &rowsWithHooks{
Rows: telemetrystore.WrapRows(rows),
Rows: rows,
ctx: ctx,
event: event,
onClose: func() { telemetrystore.WrapAfterQuery(p.hooks, ctx, event) },

View File

@@ -1,39 +0,0 @@
package telemetrystore
import (
"reflect"
"strings"
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
"github.com/SigNoz/signoz/pkg/types/telemetrystoretypes"
)
// WrapRows reports JSONValue as the scan type of every JSON column. Nested JSON — Array(JSON),
// Map(String, JSON) — is not covered.
func WrapRows(rows driver.Rows) driver.Rows {
return &rowsWithJSONScanType{Rows: rows}
}
type rowsWithJSONScanType struct {
driver.Rows
}
func (r *rowsWithJSONScanType) ColumnTypes() []driver.ColumnType {
colTypes := r.Rows.ColumnTypes()
wrapped := make([]driver.ColumnType, len(colTypes))
for i, colType := range colTypes {
wrapped[i] = colType
if strings.HasPrefix(strings.ToUpper(colType.DatabaseTypeName()), "JSON") {
wrapped[i] = jsonColumnType{ColumnType: colType}
}
}
return wrapped
}
type jsonColumnType struct {
driver.ColumnType
}
func (jsonColumnType) ScanType() reflect.Type {
return reflect.TypeFor[telemetrystoretypes.JSONValue]()
}

View File

@@ -1,23 +0,0 @@
package telemetrystoretest
import (
"context"
"github.com/ClickHouse/clickhouse-go/v2"
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
"github.com/SigNoz/signoz/pkg/telemetrystore"
)
// conn wraps rows the way the clickhouse provider does, so mocked JSON columns report the scan
// type they do in production.
type conn struct {
clickhouse.Conn
}
func (c conn) Query(ctx context.Context, query string, args ...any) (driver.Rows, error) {
rows, err := c.Conn.Query(ctx, query, args...)
if err != nil {
return nil, err
}
return telemetrystore.WrapRows(rows), nil
}

View File

@@ -32,7 +32,7 @@ func New(_ telemetrystore.Config, matcher sqlmock.QueryMatcher) *Provider {
// ClickhouseDB returns the mock Clickhouse connection.
func (p *Provider) ClickhouseDB() clickhouse.Conn {
return conn{Conn: p.clickhouseDB.(clickhouse.Conn)}
return p.clickhouseDB.(clickhouse.Conn)
}
// Cluster returns the cluster name.

View File

@@ -364,12 +364,26 @@ func (provider *provider) gc(ctx context.Context, org *types.Organization) error
}
func (provider *provider) flushLastObservedAt(ctx context.Context, org *types.Organization) error {
accessTokenToLastObservedAt, err := provider.listLastObservedAtDesc(ctx, org.ID)
tokens, err := provider.tokenStore.ListByOrgID(ctx, org.ID)
if err != nil {
return err
}
if err := provider.tokenStore.UpdateLastObservedAtByAccessToken(ctx, accessTokenToLastObservedAt); err != nil {
observedTokens := make([]*authtypes.StorableToken, 0, len(tokens))
for _, token := range tokens {
cachedLastObservedAt, ok := provider.lastObservedAtCache.Get(lastObservedAtCacheKey(token.AccessToken, token.UserID))
if !ok {
continue
}
if err := token.UpdateLastObservedAt(cachedLastObservedAt); err != nil {
continue
}
observedTokens = append(observedTokens, token)
}
if err := provider.tokenStore.UpdateLastObservedAt(ctx, observedTokens); err != nil {
return err
}

View File

@@ -232,15 +232,16 @@ func (store *store) ListByUserID(ctx context.Context, userID valuer.UUID) ([]*au
return tokens, nil
}
func (store *store) UpdateLastObservedAtByAccessToken(ctx context.Context, accessTokenToLastObservedAt []map[string]any) error {
if len(accessTokenToLastObservedAt) == 0 {
func (store *store) UpdateLastObservedAt(ctx context.Context, tokens []*authtypes.StorableToken) error {
if len(tokens) == 0 {
return nil
}
values := store.
sqlstore.
BunDBCtx(ctx).
NewValues(&accessTokenToLastObservedAt)
NewValues(&tokens).
Column("id", "last_observed_at", "updated_at")
_, err := store.
sqlstore.
@@ -250,8 +251,8 @@ func (store *store) UpdateLastObservedAtByAccessToken(ctx context.Context, acces
Model((*authtypes.StorableToken)(nil)).
TableExpr("update_cte").
Set("last_observed_at = update_cte.last_observed_at").
Where("auth_token.access_token = update_cte.access_token").
Where("auth_token.user_id = update_cte.user_id").
Set("updated_at = update_cte.updated_at").
Where("auth_token.id = update_cte.id").
Exec(ctx)
if err != nil {
return err

View File

@@ -0,0 +1,62 @@
package alertmanagertypes
import (
"maps"
"net/textproto"
"slices"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/prometheus/alertmanager/config"
)
// ChannelEmailConfig carries no SMTP transport fields: the smarthost,
// credentials and TLS settings come from the deployment's global config, so a
// channel can only choose recipients and body.
type ChannelEmailConfig struct {
SendResolved *bool `json:"sendResolved,omitempty"`
To string `json:"to" required:"true"`
HTML valuer.UnsetOrNonEmptyString `json:"html"`
Headers map[string]string `json:"headers,omitempty"`
}
func (c ChannelEmailConfig) Validate() error {
if c.To == "" {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.to is required for an email channel")
}
// A read reports header names as textproto canonicalizes them, turning
// "subject" into "Subject", so a name that is not already in that form is
// rejected rather than answered with one the caller never sent.
for _, header := range slices.Sorted(maps.Keys(c.Headers)) {
if canonical := textproto.CanonicalMIMEHeaderKey(header); canonical != header {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.headers name %q must be written as %q", header, canonical)
}
}
return nil
}
func (c ChannelEmailConfig) toUndefaultedReceiver(displayName string) (*Receiver, error) {
return &Receiver{Receiver: &config.Receiver{
Name: displayName,
EmailConfigs: []*config.EmailConfig{{
NotifierConfig: config.NotifierConfig{VSendResolved: resolveSendResolved(c.SendResolved, config.DefaultEmailConfig.VSendResolved)},
To: c.To,
HTML: c.HTML.StringValue(),
Headers: c.Headers,
}},
}}, nil
}
func newChannelEmailConfigFromReceiver(_ string, receiver *Receiver) (ChannelSpec, error) {
email := receiver.EmailConfigs[0]
sendResolved := email.VSendResolved
return &ChannelEmailConfig{
SendResolved: &sendResolved,
To: email.To,
HTML: valuer.UnsetIfEmpty(email.HTML),
Headers: email.Headers,
}, nil
}

View File

@@ -5,10 +5,59 @@ import (
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/prometheus/alertmanager/config"
commoncfg "github.com/prometheus/common/config"
)
type ChannelGoogleChatConfig struct {
SendResolved *bool `json:"sendResolved,omitempty"`
WebhookURL string `json:"webhookUrl" required:"true" format:"password"`
Title valuer.UnsetOrNonEmptyString `json:"title"`
Text valuer.UnsetOrNonEmptyString `json:"text"`
}
func (c ChannelGoogleChatConfig) Validate() error {
if c.WebhookURL == "" {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.webhookUrl is required for a googlechat channel")
}
return nil
}
func (c ChannelGoogleChatConfig) toUndefaultedReceiver(displayName string) (*Receiver, error) {
webhookURL, err := parseSecretURL(c.WebhookURL)
if err != nil {
return nil, err
}
return &Receiver{
Receiver: &config.Receiver{Name: displayName},
GoogleChatConfigs: []*GoogleChatReceiverConfig{{
NotifierConfig: config.NotifierConfig{VSendResolved: resolveSendResolved(c.SendResolved, DefaultGoogleChatReceiverConfig.VSendResolved)},
WebhookURL: webhookURL,
Title: c.Title.StringValue(),
Text: c.Text.StringValue(),
}},
}, nil
}
func newChannelGoogleChatConfigFromReceiver(name string, receiver *Receiver) (ChannelSpec, error) {
googlechat := receiver.GoogleChatConfigs[0]
sendResolved := googlechat.VSendResolved
if err := rejectAnyHTTPAuth(name, googlechat.HTTPConfig); err != nil {
return nil, err
}
return &ChannelGoogleChatConfig{
SendResolved: &sendResolved,
WebhookURL: formatSecretURL(googlechat.WebhookURL),
Title: valuer.UnsetIfEmpty(googlechat.Title),
Text: valuer.UnsetIfEmpty(googlechat.Text),
}, nil
}
type GoogleChatReceiverConfig struct {
config.NotifierConfig `yaml:",inline" json:",inline"`

View File

@@ -6,10 +6,64 @@ import (
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/prometheus/alertmanager/config"
commoncfg "github.com/prometheus/common/config"
)
type ChannelIncidentIOConfig struct {
SendResolved *bool `json:"sendResolved,omitempty"`
URL string `json:"url" required:"true"`
Token string `json:"token" required:"true" format:"password"`
Title valuer.UnsetOrNonEmptyString `json:"title"`
Description valuer.UnsetOrNonEmptyString `json:"description"`
Metadata map[string]string `json:"metadata,omitempty"`
}
func (c ChannelIncidentIOConfig) Validate() error {
if c.URL == "" {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.url is required for an incidentio channel")
}
if c.Token == "" {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.token is required for an incidentio channel")
}
return nil
}
func (c ChannelIncidentIOConfig) toUndefaultedReceiver(displayName string) (*Receiver, error) {
return &Receiver{
Receiver: &config.Receiver{Name: displayName},
IncidentIOConfigs: []*IncidentIOReceiverConfig{{
NotifierConfig: config.NotifierConfig{VSendResolved: resolveSendResolved(c.SendResolved, DefaultIncidentIOReceiverConfig.VSendResolved)},
URL: c.URL,
Token: config.Secret(c.Token),
Title: c.Title.StringValue(),
Description: c.Description.StringValue(),
Metadata: c.Metadata,
}},
}, nil
}
func newChannelIncidentIOConfigFromReceiver(name string, receiver *Receiver) (ChannelSpec, error) {
incidentio := receiver.IncidentIOConfigs[0]
sendResolved := incidentio.VSendResolved
if err := rejectAnyHTTPAuth(name, incidentio.HTTPConfig); err != nil {
return nil, err
}
return &ChannelIncidentIOConfig{
SendResolved: &sendResolved,
URL: incidentio.URL,
Token: string(incidentio.Token),
Title: valuer.UnsetIfEmpty(incidentio.Title),
Description: valuer.UnsetIfEmpty(incidentio.Description),
Metadata: incidentio.Metadata,
}, nil
}
// incidentIOEventsPathPrefix is the path of incident.io's HTTP alert source
// endpoint (Alert Events V2 API). The full URL is per-source:
// https://api.incident.io/v2/alert_events/http/<source_config_id>.

View File

@@ -7,11 +7,147 @@ import (
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/prometheus/alertmanager/config"
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/common/model"
)
type ChannelJiraConfig struct {
SendResolved *bool `json:"sendResolved,omitempty"`
// Site is the Jira Cloud base URL, https://<site>.atlassian.net. Only Jira
// Cloud is supported; the REST base is derived from it.
Site string `json:"site" required:"true"`
Project string `json:"project" required:"true"`
IssueType string `json:"issueType" required:"true"`
Summary valuer.UnsetOrNonEmptyString `json:"summary"`
Description valuer.UnsetOrNonEmptyString `json:"description"`
Priority string `json:"priority"`
Labels []string `json:"labels,omitempty"`
ResolveTransition string `json:"resolveTransition"`
ReopenTransition string `json:"reopenTransition"`
ReopenDuration valuer.UnsetOrNonEmptyString `json:"reopenDuration"`
WontFixResolution string `json:"wontFixResolution"`
CustomFields map[string]any `json:"customFields,omitempty"`
Email string `json:"email" required:"true"`
APIToken string `json:"apiToken" required:"true" format:"password"`
}
func (c ChannelJiraConfig) Validate() error {
for _, required := range []struct {
value string
field string
}{
{c.Site, "site"},
{c.Project, "project"},
{c.IssueType, "issueType"},
{c.Email, "email"},
{c.APIToken, "apiToken"},
} {
if required.value == "" {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.%s is required for a jira channel", required.field)
}
}
if !c.ReopenDuration.IsZero() {
reopenDuration, err := model.ParseDuration(c.ReopenDuration.StringValue())
if err != nil {
return errors.WrapInvalidInputf(err, ErrCodeAlertmanagerChannelInvalid, "config.spec.reopenDuration %q is not a valid duration", c.ReopenDuration)
}
// A read reports the duration as model.Duration formats it, collapsing
// "72h" into "3d", so a value that is not already in that form is rejected
// rather than answered with one the caller never sent.
if canonical := reopenDuration.String(); canonical != c.ReopenDuration.StringValue() {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.reopenDuration %q must be written as %q", c.ReopenDuration, canonical)
}
}
return nil
}
func (c ChannelJiraConfig) toUndefaultedReceiver(displayName string) (*Receiver, error) {
// Seeded from upstream's default rather than a zero value: FollowRedirects
// and EnableHTTP2 marshal unconditionally, so a zero value would persist them
// as false and read back as a config ChannelJiraConfig cannot represent.
httpConfig := commoncfg.DefaultHTTPClientConfig
httpConfig.BasicAuth = &commoncfg.BasicAuth{
Username: c.Email,
Password: commoncfg.Secret(c.APIToken),
}
jira := &JiraReceiverConfig{
// JiraReceiverConfig seeds no send_resolved of its own, so unset means off.
NotifierConfig: config.NotifierConfig{VSendResolved: resolveSendResolved(c.SendResolved, false)},
Site: c.Site,
Project: c.Project,
IssueType: c.IssueType,
Summary: c.Summary.StringValue(),
Description: c.Description.StringValue(),
Priority: c.Priority,
Labels: c.Labels,
ResolveTransition: c.ResolveTransition,
ReopenTransition: c.ReopenTransition,
WontFixResolution: c.WontFixResolution,
CustomFields: c.CustomFields,
HTTPConfig: &httpConfig,
}
if !c.ReopenDuration.IsZero() {
reopenDuration, err := model.ParseDuration(c.ReopenDuration.StringValue())
if err != nil {
return nil, errors.WrapInvalidInputf(err, ErrCodeAlertmanagerChannelInvalid, "parse reopenDuration %q", c.ReopenDuration)
}
jira.ReopenDuration = reopenDuration
}
return &Receiver{
Receiver: &config.Receiver{Name: displayName},
JiraConfigs: []*JiraReceiverConfig{jira},
}, nil
}
func newChannelJiraConfigFromReceiver(name string, receiver *Receiver) (ChannelSpec, error) {
jira := receiver.JiraConfigs[0]
sendResolved := jira.VSendResolved
if err := rejectUnsupportedHTTPConfig(name, jira.HTTPConfig); err != nil {
return nil, err
}
if jira.HTTPConfig != nil && jira.HTTPConfig.Authorization != nil {
return nil, errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "channel %q sets http_config.authorization, which is not supported", name)
}
if err := rejectHTTPBasicAuthBeyondPassword(name, jira.HTTPConfig); err != nil {
return nil, err
}
spec := &ChannelJiraConfig{
SendResolved: &sendResolved,
Site: jira.Site,
Project: jira.Project,
IssueType: jira.IssueType,
Summary: valuer.UnsetIfEmpty(jira.Summary),
Description: valuer.UnsetIfEmpty(jira.Description),
Priority: jira.Priority,
Labels: jira.Labels,
ResolveTransition: jira.ResolveTransition,
ReopenTransition: jira.ReopenTransition,
ReopenDuration: valuer.UnsetIfEmpty(jira.ReopenDuration.String()),
WontFixResolution: jira.WontFixResolution,
CustomFields: jira.CustomFields,
}
if jira.HTTPConfig != nil && jira.HTTPConfig.BasicAuth != nil {
spec.Email = jira.HTTPConfig.BasicAuth.Username
spec.APIToken = string(jira.HTTPConfig.BasicAuth.Password)
}
return spec, nil
}
const defaultJiraReopenDuration = model.Duration(3 * 24 * time.Hour)
// Service accounts authenticate against the api.atlassian.com gateway (keyed by

View File

@@ -2,10 +2,63 @@ package alertmanagertypes
import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/prometheus/alertmanager/config"
commoncfg "github.com/prometheus/common/config"
)
// ChannelJSMOpsConfig carries no API URL: JSM Ops is a single global gateway
// keyed by the integration API key, which the notifier pins itself.
type ChannelJSMOpsConfig struct {
SendResolved *bool `json:"sendResolved,omitempty"`
APIKey string `json:"apiKey" required:"true" format:"password"`
Message valuer.UnsetOrNonEmptyString `json:"message"`
Description valuer.UnsetOrNonEmptyString `json:"description"`
Priority string `json:"priority"`
// Tags is the comma-separated list JSM Ops attaches to the alert.
Tags valuer.UnsetOrNonEmptyString `json:"tags"`
}
func (c ChannelJSMOpsConfig) Validate() error {
if c.APIKey == "" {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.apiKey is required for a jsmops channel")
}
return nil
}
func (c ChannelJSMOpsConfig) toUndefaultedReceiver(displayName string) (*Receiver, error) {
return &Receiver{
Receiver: &config.Receiver{Name: displayName},
JSMOpsConfigs: []*JSMOpsReceiverConfig{{
NotifierConfig: config.NotifierConfig{VSendResolved: resolveSendResolved(c.SendResolved, DefaultJSMOpsReceiverConfig.VSendResolved)},
APIKey: config.Secret(c.APIKey),
Message: c.Message.StringValue(),
Description: c.Description.StringValue(),
Priority: c.Priority,
Tags: c.Tags.StringValue(),
}},
}, nil
}
func newChannelJSMOpsConfigFromReceiver(name string, receiver *Receiver) (ChannelSpec, error) {
jsmops := receiver.JSMOpsConfigs[0]
sendResolved := jsmops.VSendResolved
if err := rejectAnyHTTPAuth(name, jsmops.HTTPConfig); err != nil {
return nil, err
}
return &ChannelJSMOpsConfig{
SendResolved: &sendResolved,
APIKey: string(jsmops.APIKey),
Message: valuer.UnsetIfEmpty(jsmops.Message),
Description: valuer.UnsetIfEmpty(jsmops.Description),
Priority: jsmops.Priority,
Tags: valuer.UnsetIfEmpty(jsmops.Tags),
}, nil
}
// JSMOpsAPIBaseURL is the native JSM Ops integration-events gateway. It is a
// single global host keyed by the integration API key (no region/cloud id in
// the path). The trailing slash is required: the Opsgenie notifier appends

View File

@@ -0,0 +1,55 @@
package alertmanagertypes
import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/prometheus/alertmanager/config"
)
type ChannelMSTeamsConfig struct {
SendResolved *bool `json:"sendResolved,omitempty"`
WebhookURL string `json:"webhookUrl" required:"true" format:"password"`
Title valuer.UnsetOrNonEmptyString `json:"title"`
Text valuer.UnsetOrNonEmptyString `json:"text"`
}
func (c ChannelMSTeamsConfig) Validate() error {
if c.WebhookURL == "" {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.webhookUrl is required for an msteams channel")
}
return nil
}
func (c ChannelMSTeamsConfig) toUndefaultedReceiver(displayName string) (*Receiver, error) {
webhookURL, err := parseSecretURL(c.WebhookURL)
if err != nil {
return nil, err
}
return &Receiver{Receiver: &config.Receiver{
Name: displayName,
MSTeamsV2Configs: []*config.MSTeamsV2Config{{
NotifierConfig: config.NotifierConfig{VSendResolved: resolveSendResolved(c.SendResolved, config.DefaultMSTeamsV2Config.VSendResolved)},
WebhookURL: webhookURL,
Title: c.Title.StringValue(),
Text: c.Text.StringValue(),
}},
}}, nil
}
func newChannelMSTeamsConfigFromReceiver(name string, receiver *Receiver) (ChannelSpec, error) {
msteams := receiver.MSTeamsV2Configs[0]
sendResolved := msteams.VSendResolved
if err := rejectAnyHTTPAuth(name, msteams.HTTPConfig); err != nil {
return nil, err
}
return &ChannelMSTeamsConfig{
SendResolved: &sendResolved,
WebhookURL: formatSecretURL(msteams.WebhookURL),
Title: valuer.UnsetIfEmpty(msteams.Title),
Text: valuer.UnsetIfEmpty(msteams.Text),
}, nil
}

View File

@@ -0,0 +1,71 @@
package alertmanagertypes
import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/prometheus/alertmanager/config"
)
type ChannelOpsgenieConfig struct {
SendResolved *bool `json:"sendResolved,omitempty"`
APIKey string `json:"apiKey" required:"true" format:"password"`
APIURL string `json:"apiUrl"`
Message valuer.UnsetOrNonEmptyString `json:"message"`
Description valuer.UnsetOrNonEmptyString `json:"description"`
Source valuer.UnsetOrNonEmptyString `json:"source"`
Details map[string]string `json:"details,omitempty"`
Priority string `json:"priority"`
}
func (c ChannelOpsgenieConfig) Validate() error {
if c.APIKey == "" {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.apiKey is required for an opsgenie channel")
}
return nil
}
func (c ChannelOpsgenieConfig) toUndefaultedReceiver(displayName string) (*Receiver, error) {
var apiURL *config.URL
if c.APIURL != "" {
parsed, err := parseUpstreamURL(c.APIURL)
if err != nil {
return nil, err
}
apiURL = parsed
}
return &Receiver{Receiver: &config.Receiver{
Name: displayName,
OpsGenieConfigs: []*config.OpsGenieConfig{{
NotifierConfig: config.NotifierConfig{VSendResolved: resolveSendResolved(c.SendResolved, config.DefaultOpsGenieConfig.VSendResolved)},
APIKey: config.Secret(c.APIKey),
APIURL: apiURL,
Message: c.Message.StringValue(),
Description: c.Description.StringValue(),
Source: c.Source.StringValue(),
Priority: c.Priority,
Details: c.Details,
}},
}}, nil
}
func newChannelOpsgenieConfigFromReceiver(name string, receiver *Receiver) (ChannelSpec, error) {
opsgenie := receiver.OpsGenieConfigs[0]
sendResolved := opsgenie.VSendResolved
if err := rejectAnyHTTPAuth(name, opsgenie.HTTPConfig); err != nil {
return nil, err
}
return &ChannelOpsgenieConfig{
SendResolved: &sendResolved,
APIKey: string(opsgenie.APIKey),
APIURL: formatUpstreamURL(opsgenie.APIURL),
Message: valuer.UnsetIfEmpty(opsgenie.Message),
Description: valuer.UnsetIfEmpty(opsgenie.Description),
Source: valuer.UnsetIfEmpty(opsgenie.Source),
Priority: opsgenie.Priority,
Details: opsgenie.Details,
}, nil
}

View File

@@ -0,0 +1,92 @@
package alertmanagertypes
import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/prometheus/alertmanager/config"
)
type ChannelPagerdutyConfig struct {
SendResolved *bool `json:"sendResolved,omitempty"`
RoutingKey string `json:"routingKey" required:"true" format:"password"`
URL string `json:"url"`
Source valuer.UnsetOrNonEmptyString `json:"source"`
Client valuer.UnsetOrNonEmptyString `json:"client"`
ClientURL valuer.UnsetOrNonEmptyString `json:"clientUrl"`
Description valuer.UnsetOrNonEmptyString `json:"description"`
Severity string `json:"severity"`
Component string `json:"component"`
Group string `json:"group"`
Class string `json:"class"`
Details map[string]string `json:"details,omitempty"`
}
func (c ChannelPagerdutyConfig) Validate() error {
if c.RoutingKey == "" {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.routingKey is required for a pagerduty channel")
}
return nil
}
func (c ChannelPagerdutyConfig) toUndefaultedReceiver(displayName string) (*Receiver, error) {
var eventsURL *config.URL
if c.URL != "" {
parsed, err := parseUpstreamURL(c.URL)
if err != nil {
return nil, err
}
eventsURL = parsed
}
return &Receiver{Receiver: &config.Receiver{
Name: displayName,
PagerdutyConfigs: []*config.PagerdutyConfig{{
NotifierConfig: config.NotifierConfig{VSendResolved: resolveSendResolved(c.SendResolved, config.DefaultPagerdutyConfig.VSendResolved)},
RoutingKey: config.Secret(c.RoutingKey),
URL: eventsURL,
Source: c.Source.StringValue(),
Client: c.Client.StringValue(),
ClientURL: c.ClientURL.StringValue(),
Description: c.Description.StringValue(),
Severity: c.Severity,
Component: c.Component,
Group: c.Group,
Class: c.Class,
Details: newUpstreamDetails(c.Details),
}},
}}, nil
}
func newChannelPagerdutyConfigFromReceiver(name string, receiver *Receiver) (ChannelSpec, error) {
pagerduty := receiver.PagerdutyConfigs[0]
sendResolved := pagerduty.VSendResolved
if err := rejectAnyHTTPAuth(name, pagerduty.HTTPConfig); err != nil {
return nil, err
}
var details map[string]string
if len(pagerduty.Details) > 0 {
extracted, err := extractStringDetails(name, pagerduty.Details)
if err != nil {
return nil, err
}
details = extracted
}
return &ChannelPagerdutyConfig{
SendResolved: &sendResolved,
RoutingKey: string(pagerduty.RoutingKey),
URL: formatUpstreamURL(pagerduty.URL),
Source: valuer.UnsetIfEmpty(pagerduty.Source),
Client: valuer.UnsetIfEmpty(pagerduty.Client),
ClientURL: valuer.UnsetIfEmpty(pagerduty.ClientURL),
Description: valuer.UnsetIfEmpty(pagerduty.Description),
Severity: pagerduty.Severity,
Component: pagerduty.Component,
Group: pagerduty.Group,
Class: pagerduty.Class,
Details: details,
}, nil
}

View File

@@ -0,0 +1,182 @@
package alertmanagertypes
import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/prometheus/alertmanager/config"
)
type ChannelSlackConfig struct {
SendResolved *bool `json:"sendResolved,omitempty"`
APIURL string `json:"apiUrl" required:"true" format:"password"`
Channel string `json:"channel"`
Title valuer.UnsetOrNonEmptyString `json:"title"`
Text valuer.UnsetOrNonEmptyString `json:"text"`
Color valuer.UnsetOrNonEmptyString `json:"color"`
TitleLink valuer.UnsetOrNonEmptyString `json:"titleLink"`
Pretext valuer.UnsetOrNonEmptyString `json:"pretext"`
Fallback valuer.UnsetOrNonEmptyString `json:"fallback"`
Footer valuer.UnsetOrNonEmptyString `json:"footer"`
Fields []ChannelSlackField `json:"fields,omitempty"`
Actions []ChannelSlackAction `json:"actions,omitempty"`
}
type ChannelSlackField struct {
Title string `json:"title" required:"true"`
Value string `json:"value" required:"true"`
Short *bool `json:"short,omitempty"`
}
// ChannelSlackAction is a link button when URL is set, otherwise a message
// button that needs Name. Upstream clears whichever side is not in use.
type ChannelSlackAction struct {
Type string `json:"type" required:"true"`
Text string `json:"text" required:"true"`
URL string `json:"url"`
Style string `json:"style"`
Name string `json:"name"`
Value string `json:"value"`
Confirm *ChannelSlackConfirmation `json:"confirm,omitempty"`
}
type ChannelSlackConfirmation struct {
Text string `json:"text" required:"true"`
Title string `json:"title"`
OkText string `json:"okText"`
DismissText string `json:"dismissText"`
}
func (c ChannelSlackConfig) Validate() error {
if c.APIURL == "" {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.apiUrl is required for a slack channel")
}
for i, field := range c.Fields {
if field.Title == "" || field.Value == "" {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.fields[%d] requires title and value", i)
}
}
for i, action := range c.Actions {
if action.Type == "" || action.Text == "" {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.actions[%d] requires type and text", i)
}
if action.URL == "" && action.Name == "" {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.actions[%d] requires url or name", i)
}
if action.Confirm != nil && action.Confirm.Text == "" {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.actions[%d].confirm requires text", i)
}
}
return nil
}
func (c ChannelSlackConfig) toUndefaultedReceiver(displayName string) (*Receiver, error) {
apiURL, err := parseSecretURL(c.APIURL)
if err != nil {
return nil, err
}
return &Receiver{Receiver: &config.Receiver{
Name: displayName,
SlackConfigs: []*config.SlackConfig{{
NotifierConfig: config.NotifierConfig{VSendResolved: resolveSendResolved(c.SendResolved, config.DefaultSlackConfig.VSendResolved)},
APIURL: apiURL,
Channel: c.Channel,
Title: c.Title.StringValue(),
Text: c.Text.StringValue(),
Color: c.Color.StringValue(),
TitleLink: c.TitleLink.StringValue(),
Pretext: c.Pretext.StringValue(),
Fallback: c.Fallback.StringValue(),
Footer: c.Footer.StringValue(),
Fields: newUpstreamSlackFields(c.Fields),
Actions: newUpstreamSlackActions(c.Actions),
}},
}}, nil
}
func newChannelSlackConfigFromReceiver(name string, receiver *Receiver) (ChannelSpec, error) {
slack := receiver.SlackConfigs[0]
sendResolved := slack.VSendResolved
if err := rejectAnyHTTPAuth(name, slack.HTTPConfig); err != nil {
return nil, err
}
return &ChannelSlackConfig{
SendResolved: &sendResolved,
APIURL: formatSecretURL(slack.APIURL),
Channel: slack.Channel,
Title: valuer.UnsetIfEmpty(slack.Title),
Text: valuer.UnsetIfEmpty(slack.Text),
Color: valuer.UnsetIfEmpty(slack.Color),
TitleLink: valuer.UnsetIfEmpty(slack.TitleLink),
Pretext: valuer.UnsetIfEmpty(slack.Pretext),
Fallback: valuer.UnsetIfEmpty(slack.Fallback),
Footer: valuer.UnsetIfEmpty(slack.Footer),
Fields: newChannelSlackFields(slack.Fields),
Actions: newChannelSlackActions(slack.Actions),
}, nil
}
func newUpstreamSlackFields(fields []ChannelSlackField) []*config.SlackField {
if len(fields) == 0 {
return nil
}
upstream := make([]*config.SlackField, 0, len(fields))
for _, field := range fields {
upstream = append(upstream, &config.SlackField{Title: field.Title, Value: field.Value, Short: field.Short})
}
return upstream
}
func newChannelSlackFields(upstream []*config.SlackField) []ChannelSlackField {
if len(upstream) == 0 {
return nil
}
fields := make([]ChannelSlackField, 0, len(upstream))
for _, field := range upstream {
fields = append(fields, ChannelSlackField{Title: field.Title, Value: field.Value, Short: field.Short})
}
return fields
}
func newUpstreamSlackActions(actions []ChannelSlackAction) []*config.SlackAction {
if len(actions) == 0 {
return nil
}
upstream := make([]*config.SlackAction, 0, len(actions))
for _, action := range actions {
upstreamAction := &config.SlackAction{Type: action.Type, Text: action.Text, URL: action.URL, Style: action.Style, Name: action.Name, Value: action.Value}
if action.Confirm != nil {
upstreamAction.ConfirmField = &config.SlackConfirmationField{Text: action.Confirm.Text, Title: action.Confirm.Title, OkText: action.Confirm.OkText, DismissText: action.Confirm.DismissText}
}
upstream = append(upstream, upstreamAction)
}
return upstream
}
func newChannelSlackActions(upstream []*config.SlackAction) []ChannelSlackAction {
if len(upstream) == 0 {
return nil
}
actions := make([]ChannelSlackAction, 0, len(upstream))
for _, upstreamAction := range upstream {
action := ChannelSlackAction{Type: upstreamAction.Type, Text: upstreamAction.Text, URL: upstreamAction.URL, Style: upstreamAction.Style, Name: upstreamAction.Name, Value: upstreamAction.Value}
if upstreamAction.ConfirmField != nil {
action.Confirm = &ChannelSlackConfirmation{Text: upstreamAction.ConfirmField.Text, Title: upstreamAction.ConfirmField.Title, OkText: upstreamAction.ConfirmField.OkText, DismissText: upstreamAction.ConfirmField.DismissText}
}
actions = append(actions, action)
}
return actions
}

View File

@@ -0,0 +1,98 @@
package alertmanagertypes
import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/prometheus/alertmanager/config"
commoncfg "github.com/prometheus/common/config"
)
// ChannelWebhookConfig splits apart the two authentication modes the legacy API
// overloaded onto one password field, where an empty username meant the password
// was really a bearer token. Username or Password may be set without the other,
// as upstream allows, but not together with BearerToken.
type ChannelWebhookConfig struct {
SendResolved *bool `json:"sendResolved,omitempty"`
URL string `json:"url" required:"true" format:"password"`
Username string `json:"username"`
Password string `json:"password" format:"password"`
BearerToken string `json:"bearerToken" format:"password"`
}
func (c ChannelWebhookConfig) Validate() error {
if c.URL == "" {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.url is required for a webhook channel")
}
usesBasicAuth := c.Username != "" || c.Password != ""
if usesBasicAuth && c.BearerToken != "" {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.bearerToken cannot be combined with config.spec.username or config.spec.password")
}
return nil
}
func (c ChannelWebhookConfig) toUndefaultedReceiver(displayName string) (*Receiver, error) {
webhook := &config.WebhookConfig{
NotifierConfig: config.NotifierConfig{VSendResolved: resolveSendResolved(c.SendResolved, config.DefaultWebhookConfig.VSendResolved)},
URL: config.SecretTemplateURL(c.URL),
}
// Seeded from upstream's default rather than a zero value: FollowRedirects
// and EnableHTTP2 marshal unconditionally, so a zero value would persist
// them as false and read back as a config ChannelWebhookConfig cannot represent.
switch {
case c.Username != "" || c.Password != "":
httpConfig := commoncfg.DefaultHTTPClientConfig
httpConfig.BasicAuth = &commoncfg.BasicAuth{
Username: c.Username,
Password: commoncfg.Secret(c.Password),
}
webhook.HTTPConfig = &httpConfig
case c.BearerToken != "":
httpConfig := commoncfg.DefaultHTTPClientConfig
httpConfig.Authorization = &commoncfg.Authorization{
Type: bearerAuthorizationType,
Credentials: commoncfg.Secret(c.BearerToken),
}
webhook.HTTPConfig = &httpConfig
}
return &Receiver{Receiver: &config.Receiver{
Name: displayName,
WebhookConfigs: []*config.WebhookConfig{webhook},
}}, nil
}
func newChannelWebhookConfigFromReceiver(name string, receiver *Receiver) (ChannelSpec, error) {
upstream := receiver.WebhookConfigs[0]
sendResolved := upstream.VSendResolved
if err := rejectUnsupportedHTTPConfig(name, upstream.HTTPConfig); err != nil {
return nil, err
}
if err := rejectHTTPBasicAuthBeyondPassword(name, upstream.HTTPConfig); err != nil {
return nil, err
}
if err := rejectHTTPAuthorizationBeyondBearer(name, upstream.HTTPConfig); err != nil {
return nil, err
}
webhook := &ChannelWebhookConfig{
SendResolved: &sendResolved,
URL: string(upstream.URL),
}
if upstream.HTTPConfig != nil {
if basicAuth := upstream.HTTPConfig.BasicAuth; basicAuth != nil {
webhook.Username = basicAuth.Username
webhook.Password = string(basicAuth.Password)
}
if authorization := upstream.HTTPConfig.Authorization; authorization != nil {
webhook.BearerToken = string(authorization.Credentials)
}
}
return webhook, nil
}

View File

@@ -3,8 +3,6 @@ package alertmanagertypes
import (
"bytes"
"encoding/json"
"maps"
"net/textproto"
"net/url"
"reflect"
"slices"
@@ -14,7 +12,6 @@ import (
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/prometheus/alertmanager/config"
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/common/model"
"github.com/swaggest/jsonschema-go"
)
@@ -205,808 +202,6 @@ type ChannelSpec interface {
toUndefaultedReceiver(displayName string) (*Receiver, error)
}
type ChannelSlackConfig struct {
SendResolved *bool `json:"sendResolved,omitempty"`
APIURL string `json:"apiUrl" required:"true" format:"password"`
Channel string `json:"channel"`
Title valuer.UnsetOrNonEmptyString `json:"title"`
Text valuer.UnsetOrNonEmptyString `json:"text"`
Color valuer.UnsetOrNonEmptyString `json:"color"`
TitleLink valuer.UnsetOrNonEmptyString `json:"titleLink"`
Pretext valuer.UnsetOrNonEmptyString `json:"pretext"`
Fallback valuer.UnsetOrNonEmptyString `json:"fallback"`
Footer valuer.UnsetOrNonEmptyString `json:"footer"`
Fields []ChannelSlackField `json:"fields,omitempty"`
Actions []ChannelSlackAction `json:"actions,omitempty"`
}
type ChannelSlackField struct {
Title string `json:"title" required:"true"`
Value string `json:"value" required:"true"`
Short *bool `json:"short,omitempty"`
}
// ChannelSlackAction is a link button when URL is set, otherwise a message
// button that needs Name. Upstream clears whichever side is not in use.
type ChannelSlackAction struct {
Type string `json:"type" required:"true"`
Text string `json:"text" required:"true"`
URL string `json:"url"`
Style string `json:"style"`
Name string `json:"name"`
Value string `json:"value"`
Confirm *ChannelSlackConfirmation `json:"confirm,omitempty"`
}
type ChannelSlackConfirmation struct {
Text string `json:"text" required:"true"`
Title string `json:"title"`
OkText string `json:"okText"`
DismissText string `json:"dismissText"`
}
func (c ChannelSlackConfig) Validate() error {
if c.APIURL == "" {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.apiUrl is required for a slack channel")
}
for i, field := range c.Fields {
if field.Title == "" || field.Value == "" {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.fields[%d] requires title and value", i)
}
}
for i, action := range c.Actions {
if action.Type == "" || action.Text == "" {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.actions[%d] requires type and text", i)
}
if action.URL == "" && action.Name == "" {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.actions[%d] requires url or name", i)
}
if action.Confirm != nil && action.Confirm.Text == "" {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.actions[%d].confirm requires text", i)
}
}
return nil
}
func (c ChannelSlackConfig) toUndefaultedReceiver(displayName string) (*Receiver, error) {
apiURL, err := parseSecretURL(c.APIURL)
if err != nil {
return nil, err
}
return &Receiver{Receiver: &config.Receiver{
Name: displayName,
SlackConfigs: []*config.SlackConfig{{
NotifierConfig: config.NotifierConfig{VSendResolved: resolveSendResolved(c.SendResolved, config.DefaultSlackConfig.VSendResolved)},
APIURL: apiURL,
Channel: c.Channel,
Title: c.Title.StringValue(),
Text: c.Text.StringValue(),
Color: c.Color.StringValue(),
TitleLink: c.TitleLink.StringValue(),
Pretext: c.Pretext.StringValue(),
Fallback: c.Fallback.StringValue(),
Footer: c.Footer.StringValue(),
Fields: newUpstreamSlackFields(c.Fields),
Actions: newUpstreamSlackActions(c.Actions),
}},
}}, nil
}
func newChannelSlackConfigFromReceiver(name string, receiver *Receiver) (ChannelSpec, error) {
slack := receiver.SlackConfigs[0]
sendResolved := slack.VSendResolved
if err := rejectAnyHTTPAuth(name, slack.HTTPConfig); err != nil {
return nil, err
}
return &ChannelSlackConfig{
SendResolved: &sendResolved,
APIURL: formatSecretURL(slack.APIURL),
Channel: slack.Channel,
Title: valuer.UnsetIfEmpty(slack.Title),
Text: valuer.UnsetIfEmpty(slack.Text),
Color: valuer.UnsetIfEmpty(slack.Color),
TitleLink: valuer.UnsetIfEmpty(slack.TitleLink),
Pretext: valuer.UnsetIfEmpty(slack.Pretext),
Fallback: valuer.UnsetIfEmpty(slack.Fallback),
Footer: valuer.UnsetIfEmpty(slack.Footer),
Fields: newChannelSlackFields(slack.Fields),
Actions: newChannelSlackActions(slack.Actions),
}, nil
}
func newUpstreamSlackFields(fields []ChannelSlackField) []*config.SlackField {
if len(fields) == 0 {
return nil
}
upstream := make([]*config.SlackField, 0, len(fields))
for _, field := range fields {
upstream = append(upstream, &config.SlackField{Title: field.Title, Value: field.Value, Short: field.Short})
}
return upstream
}
func newChannelSlackFields(upstream []*config.SlackField) []ChannelSlackField {
if len(upstream) == 0 {
return nil
}
fields := make([]ChannelSlackField, 0, len(upstream))
for _, field := range upstream {
fields = append(fields, ChannelSlackField{Title: field.Title, Value: field.Value, Short: field.Short})
}
return fields
}
func newUpstreamSlackActions(actions []ChannelSlackAction) []*config.SlackAction {
if len(actions) == 0 {
return nil
}
upstream := make([]*config.SlackAction, 0, len(actions))
for _, action := range actions {
upstreamAction := &config.SlackAction{Type: action.Type, Text: action.Text, URL: action.URL, Style: action.Style, Name: action.Name, Value: action.Value}
if action.Confirm != nil {
upstreamAction.ConfirmField = &config.SlackConfirmationField{Text: action.Confirm.Text, Title: action.Confirm.Title, OkText: action.Confirm.OkText, DismissText: action.Confirm.DismissText}
}
upstream = append(upstream, upstreamAction)
}
return upstream
}
func newChannelSlackActions(upstream []*config.SlackAction) []ChannelSlackAction {
if len(upstream) == 0 {
return nil
}
actions := make([]ChannelSlackAction, 0, len(upstream))
for _, upstreamAction := range upstream {
action := ChannelSlackAction{Type: upstreamAction.Type, Text: upstreamAction.Text, URL: upstreamAction.URL, Style: upstreamAction.Style, Name: upstreamAction.Name, Value: upstreamAction.Value}
if upstreamAction.ConfirmField != nil {
action.Confirm = &ChannelSlackConfirmation{Text: upstreamAction.ConfirmField.Text, Title: upstreamAction.ConfirmField.Title, OkText: upstreamAction.ConfirmField.OkText, DismissText: upstreamAction.ConfirmField.DismissText}
}
actions = append(actions, action)
}
return actions
}
// ChannelEmailConfig carries no SMTP transport fields: the smarthost,
// credentials and TLS settings come from the deployment's global config, so a
// channel can only choose recipients and body.
type ChannelEmailConfig struct {
SendResolved *bool `json:"sendResolved,omitempty"`
To string `json:"to" required:"true"`
HTML valuer.UnsetOrNonEmptyString `json:"html"`
Headers map[string]string `json:"headers,omitempty"`
}
func (c ChannelEmailConfig) Validate() error {
if c.To == "" {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.to is required for an email channel")
}
// A read reports header names as textproto canonicalizes them, turning
// "subject" into "Subject", so a name that is not already in that form is
// rejected rather than answered with one the caller never sent.
for _, header := range slices.Sorted(maps.Keys(c.Headers)) {
if canonical := textproto.CanonicalMIMEHeaderKey(header); canonical != header {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.headers name %q must be written as %q", header, canonical)
}
}
return nil
}
func (c ChannelEmailConfig) toUndefaultedReceiver(displayName string) (*Receiver, error) {
return &Receiver{Receiver: &config.Receiver{
Name: displayName,
EmailConfigs: []*config.EmailConfig{{
NotifierConfig: config.NotifierConfig{VSendResolved: resolveSendResolved(c.SendResolved, config.DefaultEmailConfig.VSendResolved)},
To: c.To,
HTML: c.HTML.StringValue(),
Headers: c.Headers,
}},
}}, nil
}
func newChannelEmailConfigFromReceiver(_ string, receiver *Receiver) (ChannelSpec, error) {
email := receiver.EmailConfigs[0]
sendResolved := email.VSendResolved
return &ChannelEmailConfig{
SendResolved: &sendResolved,
To: email.To,
HTML: valuer.UnsetIfEmpty(email.HTML),
Headers: email.Headers,
}, nil
}
// ChannelWebhookConfig splits apart the two authentication modes the legacy API
// overloaded onto one password field, where an empty username meant the password
// was really a bearer token. Username or Password may be set without the other,
// as upstream allows, but not together with BearerToken.
type ChannelWebhookConfig struct {
SendResolved *bool `json:"sendResolved,omitempty"`
URL string `json:"url" required:"true" format:"password"`
Username string `json:"username"`
Password string `json:"password" format:"password"`
BearerToken string `json:"bearerToken" format:"password"`
}
func (c ChannelWebhookConfig) Validate() error {
if c.URL == "" {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.url is required for a webhook channel")
}
usesBasicAuth := c.Username != "" || c.Password != ""
if usesBasicAuth && c.BearerToken != "" {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.bearerToken cannot be combined with config.spec.username or config.spec.password")
}
return nil
}
func (c ChannelWebhookConfig) toUndefaultedReceiver(displayName string) (*Receiver, error) {
webhook := &config.WebhookConfig{
NotifierConfig: config.NotifierConfig{VSendResolved: resolveSendResolved(c.SendResolved, config.DefaultWebhookConfig.VSendResolved)},
URL: config.SecretTemplateURL(c.URL),
}
// Seeded from upstream's default rather than a zero value: FollowRedirects
// and EnableHTTP2 marshal unconditionally, so a zero value would persist
// them as false and read back as a config ChannelWebhookConfig cannot represent.
switch {
case c.Username != "" || c.Password != "":
httpConfig := commoncfg.DefaultHTTPClientConfig
httpConfig.BasicAuth = &commoncfg.BasicAuth{
Username: c.Username,
Password: commoncfg.Secret(c.Password),
}
webhook.HTTPConfig = &httpConfig
case c.BearerToken != "":
httpConfig := commoncfg.DefaultHTTPClientConfig
httpConfig.Authorization = &commoncfg.Authorization{
Type: bearerAuthorizationType,
Credentials: commoncfg.Secret(c.BearerToken),
}
webhook.HTTPConfig = &httpConfig
}
return &Receiver{Receiver: &config.Receiver{
Name: displayName,
WebhookConfigs: []*config.WebhookConfig{webhook},
}}, nil
}
func newChannelWebhookConfigFromReceiver(name string, receiver *Receiver) (ChannelSpec, error) {
upstream := receiver.WebhookConfigs[0]
sendResolved := upstream.VSendResolved
if err := rejectUnsupportedHTTPConfig(name, upstream.HTTPConfig); err != nil {
return nil, err
}
if err := rejectHTTPBasicAuthBeyondPassword(name, upstream.HTTPConfig); err != nil {
return nil, err
}
if err := rejectHTTPAuthorizationBeyondBearer(name, upstream.HTTPConfig); err != nil {
return nil, err
}
webhook := &ChannelWebhookConfig{
SendResolved: &sendResolved,
URL: string(upstream.URL),
}
if upstream.HTTPConfig != nil {
if basicAuth := upstream.HTTPConfig.BasicAuth; basicAuth != nil {
webhook.Username = basicAuth.Username
webhook.Password = string(basicAuth.Password)
}
if authorization := upstream.HTTPConfig.Authorization; authorization != nil {
webhook.BearerToken = string(authorization.Credentials)
}
}
return webhook, nil
}
type ChannelPagerdutyConfig struct {
SendResolved *bool `json:"sendResolved,omitempty"`
RoutingKey string `json:"routingKey" required:"true" format:"password"`
URL string `json:"url"`
Source valuer.UnsetOrNonEmptyString `json:"source"`
Client valuer.UnsetOrNonEmptyString `json:"client"`
ClientURL valuer.UnsetOrNonEmptyString `json:"clientUrl"`
Description valuer.UnsetOrNonEmptyString `json:"description"`
Severity string `json:"severity"`
Component string `json:"component"`
Group string `json:"group"`
Class string `json:"class"`
Details map[string]string `json:"details,omitempty"`
}
func (c ChannelPagerdutyConfig) Validate() error {
if c.RoutingKey == "" {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.routingKey is required for a pagerduty channel")
}
return nil
}
func (c ChannelPagerdutyConfig) toUndefaultedReceiver(displayName string) (*Receiver, error) {
var eventsURL *config.URL
if c.URL != "" {
parsed, err := parseUpstreamURL(c.URL)
if err != nil {
return nil, err
}
eventsURL = parsed
}
return &Receiver{Receiver: &config.Receiver{
Name: displayName,
PagerdutyConfigs: []*config.PagerdutyConfig{{
NotifierConfig: config.NotifierConfig{VSendResolved: resolveSendResolved(c.SendResolved, config.DefaultPagerdutyConfig.VSendResolved)},
RoutingKey: config.Secret(c.RoutingKey),
URL: eventsURL,
Source: c.Source.StringValue(),
Client: c.Client.StringValue(),
ClientURL: c.ClientURL.StringValue(),
Description: c.Description.StringValue(),
Severity: c.Severity,
Component: c.Component,
Group: c.Group,
Class: c.Class,
Details: newUpstreamDetails(c.Details),
}},
}}, nil
}
func newChannelPagerdutyConfigFromReceiver(name string, receiver *Receiver) (ChannelSpec, error) {
pagerduty := receiver.PagerdutyConfigs[0]
sendResolved := pagerduty.VSendResolved
if err := rejectAnyHTTPAuth(name, pagerduty.HTTPConfig); err != nil {
return nil, err
}
var details map[string]string
if len(pagerduty.Details) > 0 {
extracted, err := extractStringDetails(name, pagerduty.Details)
if err != nil {
return nil, err
}
details = extracted
}
return &ChannelPagerdutyConfig{
SendResolved: &sendResolved,
RoutingKey: string(pagerduty.RoutingKey),
URL: formatUpstreamURL(pagerduty.URL),
Source: valuer.UnsetIfEmpty(pagerduty.Source),
Client: valuer.UnsetIfEmpty(pagerduty.Client),
ClientURL: valuer.UnsetIfEmpty(pagerduty.ClientURL),
Description: valuer.UnsetIfEmpty(pagerduty.Description),
Severity: pagerduty.Severity,
Component: pagerduty.Component,
Group: pagerduty.Group,
Class: pagerduty.Class,
Details: details,
}, nil
}
type ChannelOpsgenieConfig struct {
SendResolved *bool `json:"sendResolved,omitempty"`
APIKey string `json:"apiKey" required:"true" format:"password"`
APIURL string `json:"apiUrl"`
Message valuer.UnsetOrNonEmptyString `json:"message"`
Description valuer.UnsetOrNonEmptyString `json:"description"`
Source valuer.UnsetOrNonEmptyString `json:"source"`
Details map[string]string `json:"details,omitempty"`
Priority string `json:"priority"`
}
func (c ChannelOpsgenieConfig) Validate() error {
if c.APIKey == "" {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.apiKey is required for an opsgenie channel")
}
return nil
}
func (c ChannelOpsgenieConfig) toUndefaultedReceiver(displayName string) (*Receiver, error) {
var apiURL *config.URL
if c.APIURL != "" {
parsed, err := parseUpstreamURL(c.APIURL)
if err != nil {
return nil, err
}
apiURL = parsed
}
return &Receiver{Receiver: &config.Receiver{
Name: displayName,
OpsGenieConfigs: []*config.OpsGenieConfig{{
NotifierConfig: config.NotifierConfig{VSendResolved: resolveSendResolved(c.SendResolved, config.DefaultOpsGenieConfig.VSendResolved)},
APIKey: config.Secret(c.APIKey),
APIURL: apiURL,
Message: c.Message.StringValue(),
Description: c.Description.StringValue(),
Source: c.Source.StringValue(),
Priority: c.Priority,
Details: c.Details,
}},
}}, nil
}
func newChannelOpsgenieConfigFromReceiver(name string, receiver *Receiver) (ChannelSpec, error) {
opsgenie := receiver.OpsGenieConfigs[0]
sendResolved := opsgenie.VSendResolved
if err := rejectAnyHTTPAuth(name, opsgenie.HTTPConfig); err != nil {
return nil, err
}
return &ChannelOpsgenieConfig{
SendResolved: &sendResolved,
APIKey: string(opsgenie.APIKey),
APIURL: formatUpstreamURL(opsgenie.APIURL),
Message: valuer.UnsetIfEmpty(opsgenie.Message),
Description: valuer.UnsetIfEmpty(opsgenie.Description),
Source: valuer.UnsetIfEmpty(opsgenie.Source),
Priority: opsgenie.Priority,
Details: opsgenie.Details,
}, nil
}
type ChannelMSTeamsConfig struct {
SendResolved *bool `json:"sendResolved,omitempty"`
WebhookURL string `json:"webhookUrl" required:"true" format:"password"`
Title valuer.UnsetOrNonEmptyString `json:"title"`
Text valuer.UnsetOrNonEmptyString `json:"text"`
}
func (c ChannelMSTeamsConfig) Validate() error {
if c.WebhookURL == "" {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.webhookUrl is required for an msteams channel")
}
return nil
}
func (c ChannelMSTeamsConfig) toUndefaultedReceiver(displayName string) (*Receiver, error) {
webhookURL, err := parseSecretURL(c.WebhookURL)
if err != nil {
return nil, err
}
return &Receiver{Receiver: &config.Receiver{
Name: displayName,
MSTeamsV2Configs: []*config.MSTeamsV2Config{{
NotifierConfig: config.NotifierConfig{VSendResolved: resolveSendResolved(c.SendResolved, config.DefaultMSTeamsV2Config.VSendResolved)},
WebhookURL: webhookURL,
Title: c.Title.StringValue(),
Text: c.Text.StringValue(),
}},
}}, nil
}
func newChannelMSTeamsConfigFromReceiver(name string, receiver *Receiver) (ChannelSpec, error) {
msteams := receiver.MSTeamsV2Configs[0]
sendResolved := msteams.VSendResolved
if err := rejectAnyHTTPAuth(name, msteams.HTTPConfig); err != nil {
return nil, err
}
return &ChannelMSTeamsConfig{
SendResolved: &sendResolved,
WebhookURL: formatSecretURL(msteams.WebhookURL),
Title: valuer.UnsetIfEmpty(msteams.Title),
Text: valuer.UnsetIfEmpty(msteams.Text),
}, nil
}
type ChannelGoogleChatConfig struct {
SendResolved *bool `json:"sendResolved,omitempty"`
WebhookURL string `json:"webhookUrl" required:"true" format:"password"`
Title valuer.UnsetOrNonEmptyString `json:"title"`
Text valuer.UnsetOrNonEmptyString `json:"text"`
}
func (c ChannelGoogleChatConfig) Validate() error {
if c.WebhookURL == "" {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.webhookUrl is required for a googlechat channel")
}
return nil
}
func (c ChannelGoogleChatConfig) toUndefaultedReceiver(displayName string) (*Receiver, error) {
webhookURL, err := parseSecretURL(c.WebhookURL)
if err != nil {
return nil, err
}
return &Receiver{
Receiver: &config.Receiver{Name: displayName},
GoogleChatConfigs: []*GoogleChatReceiverConfig{{
NotifierConfig: config.NotifierConfig{VSendResolved: resolveSendResolved(c.SendResolved, DefaultGoogleChatReceiverConfig.VSendResolved)},
WebhookURL: webhookURL,
Title: c.Title.StringValue(),
Text: c.Text.StringValue(),
}},
}, nil
}
func newChannelGoogleChatConfigFromReceiver(name string, receiver *Receiver) (ChannelSpec, error) {
googlechat := receiver.GoogleChatConfigs[0]
sendResolved := googlechat.VSendResolved
if err := rejectAnyHTTPAuth(name, googlechat.HTTPConfig); err != nil {
return nil, err
}
return &ChannelGoogleChatConfig{
SendResolved: &sendResolved,
WebhookURL: formatSecretURL(googlechat.WebhookURL),
Title: valuer.UnsetIfEmpty(googlechat.Title),
Text: valuer.UnsetIfEmpty(googlechat.Text),
}, nil
}
type ChannelJiraConfig struct {
SendResolved *bool `json:"sendResolved,omitempty"`
// Site is the Jira Cloud base URL, https://<site>.atlassian.net. Only Jira
// Cloud is supported; the REST base is derived from it.
Site string `json:"site" required:"true"`
Project string `json:"project" required:"true"`
IssueType string `json:"issueType" required:"true"`
Summary valuer.UnsetOrNonEmptyString `json:"summary"`
Description valuer.UnsetOrNonEmptyString `json:"description"`
Priority string `json:"priority"`
Labels []string `json:"labels,omitempty"`
ResolveTransition string `json:"resolveTransition"`
ReopenTransition string `json:"reopenTransition"`
ReopenDuration valuer.UnsetOrNonEmptyString `json:"reopenDuration"`
WontFixResolution string `json:"wontFixResolution"`
CustomFields map[string]any `json:"customFields,omitempty"`
Email string `json:"email" required:"true"`
APIToken string `json:"apiToken" required:"true" format:"password"`
}
func (c ChannelJiraConfig) Validate() error {
for _, required := range []struct {
value string
field string
}{
{c.Site, "site"},
{c.Project, "project"},
{c.IssueType, "issueType"},
{c.Email, "email"},
{c.APIToken, "apiToken"},
} {
if required.value == "" {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.%s is required for a jira channel", required.field)
}
}
if !c.ReopenDuration.IsZero() {
reopenDuration, err := model.ParseDuration(c.ReopenDuration.StringValue())
if err != nil {
return errors.WrapInvalidInputf(err, ErrCodeAlertmanagerChannelInvalid, "config.spec.reopenDuration %q is not a valid duration", c.ReopenDuration)
}
// A read reports the duration as model.Duration formats it, collapsing
// "72h" into "3d", so a value that is not already in that form is rejected
// rather than answered with one the caller never sent.
if canonical := reopenDuration.String(); canonical != c.ReopenDuration.StringValue() {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.reopenDuration %q must be written as %q", c.ReopenDuration, canonical)
}
}
return nil
}
func (c ChannelJiraConfig) toUndefaultedReceiver(displayName string) (*Receiver, error) {
// Seeded from upstream's default rather than a zero value: FollowRedirects
// and EnableHTTP2 marshal unconditionally, so a zero value would persist them
// as false and read back as a config ChannelJiraConfig cannot represent.
httpConfig := commoncfg.DefaultHTTPClientConfig
httpConfig.BasicAuth = &commoncfg.BasicAuth{
Username: c.Email,
Password: commoncfg.Secret(c.APIToken),
}
jira := &JiraReceiverConfig{
// JiraReceiverConfig seeds no send_resolved of its own, so unset means off.
NotifierConfig: config.NotifierConfig{VSendResolved: resolveSendResolved(c.SendResolved, false)},
Site: c.Site,
Project: c.Project,
IssueType: c.IssueType,
Summary: c.Summary.StringValue(),
Description: c.Description.StringValue(),
Priority: c.Priority,
Labels: c.Labels,
ResolveTransition: c.ResolveTransition,
ReopenTransition: c.ReopenTransition,
WontFixResolution: c.WontFixResolution,
CustomFields: c.CustomFields,
HTTPConfig: &httpConfig,
}
if !c.ReopenDuration.IsZero() {
reopenDuration, err := model.ParseDuration(c.ReopenDuration.StringValue())
if err != nil {
return nil, errors.WrapInvalidInputf(err, ErrCodeAlertmanagerChannelInvalid, "parse reopenDuration %q", c.ReopenDuration)
}
jira.ReopenDuration = reopenDuration
}
return &Receiver{
Receiver: &config.Receiver{Name: displayName},
JiraConfigs: []*JiraReceiverConfig{jira},
}, nil
}
func newChannelJiraConfigFromReceiver(name string, receiver *Receiver) (ChannelSpec, error) {
jira := receiver.JiraConfigs[0]
sendResolved := jira.VSendResolved
if err := rejectUnsupportedHTTPConfig(name, jira.HTTPConfig); err != nil {
return nil, err
}
if jira.HTTPConfig != nil && jira.HTTPConfig.Authorization != nil {
return nil, errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "channel %q sets http_config.authorization, which is not supported", name)
}
if err := rejectHTTPBasicAuthBeyondPassword(name, jira.HTTPConfig); err != nil {
return nil, err
}
spec := &ChannelJiraConfig{
SendResolved: &sendResolved,
Site: jira.Site,
Project: jira.Project,
IssueType: jira.IssueType,
Summary: valuer.UnsetIfEmpty(jira.Summary),
Description: valuer.UnsetIfEmpty(jira.Description),
Priority: jira.Priority,
Labels: jira.Labels,
ResolveTransition: jira.ResolveTransition,
ReopenTransition: jira.ReopenTransition,
ReopenDuration: valuer.UnsetIfEmpty(jira.ReopenDuration.String()),
WontFixResolution: jira.WontFixResolution,
CustomFields: jira.CustomFields,
}
if jira.HTTPConfig != nil && jira.HTTPConfig.BasicAuth != nil {
spec.Email = jira.HTTPConfig.BasicAuth.Username
spec.APIToken = string(jira.HTTPConfig.BasicAuth.Password)
}
return spec, nil
}
// ChannelJSMOpsConfig carries no API URL: JSM Ops is a single global gateway
// keyed by the integration API key, which the notifier pins itself.
type ChannelJSMOpsConfig struct {
SendResolved *bool `json:"sendResolved,omitempty"`
APIKey string `json:"apiKey" required:"true" format:"password"`
Message valuer.UnsetOrNonEmptyString `json:"message"`
Description valuer.UnsetOrNonEmptyString `json:"description"`
Priority string `json:"priority"`
// Tags is the comma-separated list JSM Ops attaches to the alert.
Tags valuer.UnsetOrNonEmptyString `json:"tags"`
}
func (c ChannelJSMOpsConfig) Validate() error {
if c.APIKey == "" {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.apiKey is required for a jsmops channel")
}
return nil
}
func (c ChannelJSMOpsConfig) toUndefaultedReceiver(displayName string) (*Receiver, error) {
return &Receiver{
Receiver: &config.Receiver{Name: displayName},
JSMOpsConfigs: []*JSMOpsReceiverConfig{{
NotifierConfig: config.NotifierConfig{VSendResolved: resolveSendResolved(c.SendResolved, DefaultJSMOpsReceiverConfig.VSendResolved)},
APIKey: config.Secret(c.APIKey),
Message: c.Message.StringValue(),
Description: c.Description.StringValue(),
Priority: c.Priority,
Tags: c.Tags.StringValue(),
}},
}, nil
}
func newChannelJSMOpsConfigFromReceiver(name string, receiver *Receiver) (ChannelSpec, error) {
jsmops := receiver.JSMOpsConfigs[0]
sendResolved := jsmops.VSendResolved
if err := rejectAnyHTTPAuth(name, jsmops.HTTPConfig); err != nil {
return nil, err
}
return &ChannelJSMOpsConfig{
SendResolved: &sendResolved,
APIKey: string(jsmops.APIKey),
Message: valuer.UnsetIfEmpty(jsmops.Message),
Description: valuer.UnsetIfEmpty(jsmops.Description),
Priority: jsmops.Priority,
Tags: valuer.UnsetIfEmpty(jsmops.Tags),
}, nil
}
type ChannelIncidentIOConfig struct {
SendResolved *bool `json:"sendResolved,omitempty"`
URL string `json:"url" required:"true"`
Token string `json:"token" required:"true" format:"password"`
Title valuer.UnsetOrNonEmptyString `json:"title"`
Description valuer.UnsetOrNonEmptyString `json:"description"`
Metadata map[string]string `json:"metadata,omitempty"`
}
func (c ChannelIncidentIOConfig) Validate() error {
if c.URL == "" {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.url is required for an incidentio channel")
}
if c.Token == "" {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.token is required for an incidentio channel")
}
return nil
}
func (c ChannelIncidentIOConfig) toUndefaultedReceiver(displayName string) (*Receiver, error) {
return &Receiver{
Receiver: &config.Receiver{Name: displayName},
IncidentIOConfigs: []*IncidentIOReceiverConfig{{
NotifierConfig: config.NotifierConfig{VSendResolved: resolveSendResolved(c.SendResolved, DefaultIncidentIOReceiverConfig.VSendResolved)},
URL: c.URL,
Token: config.Secret(c.Token),
Title: c.Title.StringValue(),
Description: c.Description.StringValue(),
Metadata: c.Metadata,
}},
}, nil
}
func newChannelIncidentIOConfigFromReceiver(name string, receiver *Receiver) (ChannelSpec, error) {
incidentio := receiver.IncidentIOConfigs[0]
sendResolved := incidentio.VSendResolved
if err := rejectAnyHTTPAuth(name, incidentio.HTTPConfig); err != nil {
return nil, err
}
return &ChannelIncidentIOConfig{
SendResolved: &sendResolved,
URL: incidentio.URL,
Token: string(incidentio.Token),
Title: valuer.UnsetIfEmpty(incidentio.Title),
Description: valuer.UnsetIfEmpty(incidentio.Description),
Metadata: incidentio.Metadata,
}, nil
}
// ════════════════════════════════════════════════════════════════════════
// Helpers
// ════════════════════════════════════════════════════════════════════════

View File

@@ -258,6 +258,6 @@ type TokenStore interface {
// Delete a token by userID.
DeleteByUserID(context.Context, valuer.UUID) error
// Update last observed at by access token.
UpdateLastObservedAtByAccessToken(context.Context, []map[string]any) error
// Update last observed at of the given tokens.
UpdateLastObservedAt(context.Context, []*StorableToken) error
}

View File

@@ -1,8 +1,10 @@
package coretypes
import (
"context"
"net/http"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/gorilla/mux"
"github.com/tidwall/gjson"
)
@@ -12,24 +14,71 @@ const (
PhaseResponse
)
var (
errCodeExtractorContextNotFound = errors.MustNewCode("extractor_context_not_found")
errCodeRequestTypeUndeclared = errors.MustNewCode("request_type_undeclared")
errCodeRequestTypeMismatch = errors.MustNewCode("request_type_mismatch")
)
type ExtractPhase int
type extractorContextKey struct{}
// ExtractorContext carries everything an extractor may read: Request + RequestBody
// are filled pre-handler, ResponseBody post-handler.
// are filled pre-handler, ResponseBody post-handler. DecodedRequestBody is
// RequestBody decoded by the resource middleware into the route's declared request type.
type ExtractorContext struct {
Request *http.Request
RequestBody []byte
ResponseBody []byte
Request *http.Request
RequestBody []byte
DecodedRequestBody any
ResponseBody []byte
}
func NewContextWithExtractorContext(ctx context.Context, ec ExtractorContext) context.Context {
return context.WithValue(ctx, extractorContextKey{}, ec)
}
func ExtractorContextFromContext(ctx context.Context) (ExtractorContext, error) {
ec, ok := ctx.Value(extractorContextKey{}).(ExtractorContext)
if !ok {
return ExtractorContext{}, errors.New(errors.TypeInternal, errCodeExtractorContextNotFound, "extractor context not found in context")
}
return ec, nil
}
func BodyAs[T any](ec ExtractorContext) (*T, error) {
if ec.DecodedRequestBody == nil {
return nil, errors.New(errors.TypeInternal, errCodeRequestTypeUndeclared, "route does not declare a request type")
}
typed, ok := ec.DecodedRequestBody.(*T)
if !ok {
return nil, errors.Newf(errors.TypeInternal, errCodeRequestTypeMismatch, "route declares request type %T, expected %T", ec.DecodedRequestBody, (*T)(nil))
}
return typed, nil
}
func BodyFromContext[T any](ctx context.Context) (*T, error) {
ec, err := ExtractorContextFromContext(ctx)
if err != nil {
return nil, err
}
return BodyAs[T](ec)
}
type ResourceIDExtractor struct {
Phase ExtractPhase
Fn func(ExtractorContext) (string, error)
Phase ExtractPhase
RequiresBody bool
Fn func(ExtractorContext) (string, error)
}
type ResourceIDsExtractor struct {
Phase ExtractPhase
Fn func(ExtractorContext) ([]string, error)
Phase ExtractPhase
RequiresBody bool
Fn func(ExtractorContext) ([]string, error)
}
func NewResourceIDExtractor(phase ExtractPhase, fn func(ExtractorContext) (string, error)) ResourceIDExtractor {
@@ -50,7 +99,7 @@ func OneID(extractor ResourceIDExtractor) ResourceIDsExtractor {
return ResourceIDsExtractor{}
}
return ResourceIDsExtractor{Phase: extractor.Phase, Fn: func(ec ExtractorContext) ([]string, error) {
return ResourceIDsExtractor{Phase: extractor.Phase, RequiresBody: extractor.RequiresBody, Fn: func(ec ExtractorContext) ([]string, error) {
id, err := extractor.Fn(ec)
if err != nil || id == "" {
return nil, err
@@ -75,6 +124,28 @@ func PathParam(name string) ResourceIDExtractor {
}}
}
func BodyField[T any](pick func(*T) string) ResourceIDExtractor {
return ResourceIDExtractor{Phase: PhaseRequest, RequiresBody: true, Fn: func(ec ExtractorContext) (string, error) {
req, err := BodyAs[T](ec)
if err != nil {
return "", err
}
return pick(req), nil
}}
}
func BodyFields[T any](pick func(*T) []string) ResourceIDsExtractor {
return ResourceIDsExtractor{Phase: PhaseRequest, RequiresBody: true, Fn: func(ec ExtractorContext) ([]string, error) {
req, err := BodyAs[T](ec)
if err != nil {
return nil, err
}
return pick(req), nil
}}
}
func BodyJSONPath(path string) ResourceIDExtractor {
return ResourceIDExtractor{Phase: PhaseRequest, Fn: func(ec ExtractorContext) (string, error) {
return gjson.GetBytes(ec.RequestBody, path).String(), nil

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