mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-02 15:10:34 +01:00
Compare commits
2 Commits
fix/ux-iss
...
inline-log
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ffbb1ddb8 | ||
|
|
571e23910e |
@@ -359,7 +359,8 @@ function CustomTimePickerPopoverContent({
|
||||
<Clock
|
||||
color={Color.BG_ROBIN_400}
|
||||
className="timezone-container__clock-icon"
|
||||
size={14}
|
||||
height={12}
|
||||
width={12}
|
||||
/>
|
||||
|
||||
<span className="timezone__name">{timezone.name}</span>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Dot } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import Noz from 'components/Noz/Noz';
|
||||
import { NOZ_TOOLTIP_TITLE } from 'components/Noz/Noz.constants';
|
||||
import { Popover } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { AIAssistantEvents } from 'container/AIAssistant/events';
|
||||
@@ -109,7 +110,7 @@ function HeaderRightSection({
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
<TooltipSimple title="Noz">
|
||||
<TooltipSimple title={NOZ_TOOLTIP_TITLE}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
|
||||
@@ -483,12 +483,8 @@ $custom-border-color: #2c3044;
|
||||
.option-checkbox {
|
||||
width: 100%;
|
||||
|
||||
// @signozhq/ui Checkbox renders children inside a <label> that is
|
||||
// content-sized by default. Make it fill the row (min-width: 0 lets it
|
||||
// shrink) so the option text below can truncate instead of overflowing.
|
||||
> label {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
> span:not(.ant-checkbox) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.all-option-text {
|
||||
@@ -505,12 +501,7 @@ $custom-border-color: #2c3044;
|
||||
width: 100%;
|
||||
|
||||
.option-label-text {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
margin-bottom: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.option-badge {
|
||||
@@ -523,30 +514,26 @@ $custom-border-color: #2c3044;
|
||||
}
|
||||
}
|
||||
|
||||
// Size the buttons to the row's resting content height (20px) and fully
|
||||
// override antd's default 32px Button box, so revealing them on hover
|
||||
// never changes the row height.
|
||||
.only-btn,
|
||||
.only-btn {
|
||||
display: none;
|
||||
}
|
||||
.toggle-btn {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 18px;
|
||||
min-height: 0;
|
||||
padding: 0 6px;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: unset;
|
||||
}
|
||||
.only-btn:hover {
|
||||
background-color: unset;
|
||||
}
|
||||
.toggle-btn:hover {
|
||||
background-color: unset;
|
||||
}
|
||||
|
||||
.option-content:hover {
|
||||
.only-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 21px;
|
||||
}
|
||||
.toggle-btn {
|
||||
display: none;
|
||||
@@ -561,6 +548,9 @@ $custom-border-color: #2c3044;
|
||||
.option-checkbox:hover {
|
||||
.toggle-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 21px;
|
||||
}
|
||||
.option-badge {
|
||||
display: none;
|
||||
|
||||
2
frontend/src/components/Noz/Noz.constants.ts
Normal file
2
frontend/src/components/Noz/Noz.constants.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
/** Shared hover copy for every Noz entry point (header, floating trigger, sidebar). */
|
||||
export const NOZ_TOOLTIP_TITLE = 'Noz, your AI teammate';
|
||||
@@ -5,6 +5,7 @@ import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import ROUTES from 'constants/routes';
|
||||
import Noz from 'components/Noz/Noz';
|
||||
import { NOZ_TOOLTIP_TITLE } from 'components/Noz/Noz.constants';
|
||||
|
||||
import { AIAssistantEvents, AIAssistantOpenSource } from '../events';
|
||||
import { normalizePage } from '../hooks/useAIAssistantAnalyticsContext';
|
||||
@@ -42,16 +43,15 @@ export default function AIAssistantTrigger(): JSX.Element | null {
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipSimple title="Noz">
|
||||
<TooltipSimple title={NOZ_TOOLTIP_TITLE}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
className={`${styles.trigger} noz-wave`}
|
||||
onClick={handleOpen}
|
||||
aria-label="Open Noz"
|
||||
>
|
||||
<Noz size={24} />
|
||||
</Button>
|
||||
prefix={<Noz size={24} />}
|
||||
/>
|
||||
</TooltipSimple>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
.count-label {
|
||||
color: var(--l1-foreground) !important;
|
||||
color: var(--l1-foreground);
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 24px;
|
||||
line-height: 36px;
|
||||
|
||||
@@ -153,7 +153,6 @@
|
||||
font-size: 10px;
|
||||
color: var(--l2-foreground);
|
||||
margin-top: 4px;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -47,10 +47,18 @@
|
||||
}
|
||||
|
||||
.ant-tabs-tab-active {
|
||||
.overview-btn,
|
||||
.variables-btn,
|
||||
.overview-btn {
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
background: var(--l1-border);
|
||||
}
|
||||
|
||||
.variables-btn {
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
background: var(--l1-border);
|
||||
}
|
||||
|
||||
.public-dashboard-btn {
|
||||
color: var(--primary-background);
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
background: var(--l1-border);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,15 +127,6 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
|
||||
.sidenav-beta-tag {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.variable-type-btn + .variable-type-btn {
|
||||
@@ -186,7 +177,6 @@
|
||||
|
||||
.multiple-values-section {
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 0;
|
||||
|
||||
.typography-variables {
|
||||
@@ -203,7 +193,6 @@
|
||||
|
||||
.all-option-section {
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 0;
|
||||
|
||||
.typography-variables {
|
||||
|
||||
@@ -518,6 +518,7 @@ function VariableItem({
|
||||
size={14}
|
||||
style={{
|
||||
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
|
||||
marginTop: 1,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
@@ -613,6 +614,7 @@ function VariableItem({
|
||||
size={14}
|
||||
style={{
|
||||
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
|
||||
marginTop: 1,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
}
|
||||
.ant-btn-default {
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
.ant-tabs-tab-active {
|
||||
|
||||
@@ -72,20 +72,8 @@
|
||||
.alert-rule-scope {
|
||||
margin-bottom: 12px;
|
||||
|
||||
// `.createForm label` styles field labels (font-weight 500, 14px,
|
||||
// 6px bottom padding). Those bleed into the @signozhq/ui RadioGroup
|
||||
// option labels, making them bold and vertically misaligned with the
|
||||
// radio control. Reset them back to plain option-text styling.
|
||||
label {
|
||||
padding: 0;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
// Loosen the design-system default (grid gap 0.5rem) between options.
|
||||
.silence-alerts-radio-group {
|
||||
margin-top: 8px;
|
||||
gap: 12px;
|
||||
.ant-radio-wrapper {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,7 +144,10 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
|
||||
.ant-btn {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-created-at {
|
||||
|
||||
@@ -54,7 +54,8 @@ import {
|
||||
} from './PlannedDowntimeutils';
|
||||
|
||||
import './PlannedDowntime.styles.scss';
|
||||
import { RadioGroupItem, RadioGroup } from '@signozhq/ui/radio-group';
|
||||
import { RadioGroupItem } from '@signozhq/ui/radio-group';
|
||||
import { RadioGroup } from '@signozhq/ui/radio-group';
|
||||
|
||||
dayjs.locale('en');
|
||||
dayjs.extend(utc);
|
||||
@@ -470,7 +471,7 @@ export function PlannedDowntimeForm(
|
||||
initialValue="specific"
|
||||
className="alert-rule-scope"
|
||||
>
|
||||
<RadioGroup className="silence-alerts-radio-group">
|
||||
<RadioGroup>
|
||||
<RadioGroupItem value="all">All alert rules</RadioGroupItem>
|
||||
<RadioGroupItem value="specific">Specific alert rules</RadioGroupItem>
|
||||
</RadioGroup>
|
||||
|
||||
@@ -35,7 +35,10 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
|
||||
.ant-btn {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.routing-policies-table {
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Pin, PinOff } from '@signozhq/icons';
|
||||
|
||||
import { SidebarItem } from '../sideNav.types';
|
||||
|
||||
import './NavItem.styles.scss';
|
||||
import './NavItem.styles.scss';
|
||||
|
||||
export default function NavItem({
|
||||
@@ -27,7 +26,7 @@ export default function NavItem({
|
||||
showIcon?: boolean;
|
||||
dataTestId?: string;
|
||||
}): JSX.Element {
|
||||
const { label, icon, isBeta, isNew, isEarlyAccess } = item;
|
||||
const { label, icon, isBeta, isNew, isEarlyAccess, tooltip } = item;
|
||||
|
||||
const handleTogglePinClick = (
|
||||
event: React.MouseEvent<SVGSVGElement, MouseEvent>,
|
||||
@@ -36,7 +35,7 @@ export default function NavItem({
|
||||
onTogglePin?.(item);
|
||||
};
|
||||
|
||||
return (
|
||||
const navItem = (
|
||||
<div
|
||||
className={cx(
|
||||
'nav-item',
|
||||
@@ -107,6 +106,15 @@ export default function NavItem({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Only non-pinnable items set `tooltip`; it would nest with the pin tooltip.
|
||||
return tooltip ? (
|
||||
<Tooltip title={tooltip} placement="right">
|
||||
{navItem}
|
||||
</Tooltip>
|
||||
) : (
|
||||
navItem
|
||||
);
|
||||
}
|
||||
|
||||
NavItem.defaultProps = {
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
} from './sideNav.types';
|
||||
import { Style } from '@signozhq/design-tokens';
|
||||
import Noz from 'components/Noz/Noz';
|
||||
import { NOZ_TOOLTIP_TITLE } from 'components/Noz/Noz.constants';
|
||||
|
||||
export const getStartedMenuItem = {
|
||||
key: ROUTES.GET_STARTED,
|
||||
@@ -97,6 +98,7 @@ export const aiAssistantMenuItem = {
|
||||
icon: <Noz size={16} />,
|
||||
itemKey: 'ai-assistant',
|
||||
isEarlyAccess: true,
|
||||
tooltip: NOZ_TOOLTIP_TITLE,
|
||||
};
|
||||
|
||||
export const shortcutMenuItem = {
|
||||
|
||||
@@ -15,6 +15,8 @@ export interface SidebarItem {
|
||||
isBeta?: boolean;
|
||||
isNew?: boolean;
|
||||
isEarlyAccess?: boolean;
|
||||
/** Hover copy for the whole item row (e.g. Noz's early-access tagline). */
|
||||
tooltip?: ReactNode;
|
||||
isPinned?: boolean;
|
||||
children?: SidebarItem[];
|
||||
isExternal?: boolean;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
139deg,
|
||||
color-mix(in srgb, var(--card) 80%, transparent) 0%,
|
||||
color-mix(in srgb, var(--card) 90%, transparent) 98.68%
|
||||
) !important;
|
||||
);
|
||||
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(20px);
|
||||
padding: 0;
|
||||
@@ -34,9 +34,9 @@
|
||||
}
|
||||
|
||||
.refresh-interval-text {
|
||||
padding: 12px 14px 8px 14px !important;
|
||||
padding: 12px 14px 8px 14px;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 13px;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 18px; /* 163.636% */
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
}
|
||||
|
||||
.ant-tabs-nav {
|
||||
padding-left: 16px;
|
||||
padding: 0 8px;
|
||||
margin-bottom: 0px;
|
||||
|
||||
&::before {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/contextlinks"
|
||||
@@ -85,19 +86,35 @@ func (r *ThresholdRule) prepareQueryRange(ctx context.Context, ts time.Time) (*q
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func (r *ThresholdRule) prepareParamsForLogs(ctx context.Context, ts time.Time, lbls ruletypes.Labels) url.Values {
|
||||
// logSamples* bound the recent log lines we attach to a firing log-based alert.
|
||||
// They are kept small to bound the notification payload size.
|
||||
const (
|
||||
// logSamplesMaxCount is the number of most-recent matching log records sampled.
|
||||
logSamplesMaxCount = 5
|
||||
// logSampleBodyMaxLen truncates each sampled body (in runes) so a single large
|
||||
// record cannot blow up the annotation/notification.
|
||||
logSampleBodyMaxLen = 512
|
||||
)
|
||||
|
||||
// logsQueryParams extracts, for a log-based alert, the evaluation window and the
|
||||
// per-group where clause: the rule's filter combined with the breaching group's
|
||||
// label values (lbls). The same where clause backs both the related-logs link and
|
||||
// the sample-logs query, so they always refer to the same set of logs. ok is false
|
||||
// when the rule is not a single log builder query (e.g. a formula or non-logs
|
||||
// signal), in which case there is nothing to link to or sample.
|
||||
func (r *ThresholdRule) logsQueryParams(ctx context.Context, ts time.Time, lbls ruletypes.Labels) (start, end time.Time, whereClause string, ok bool) {
|
||||
selectedQuery := r.SelectedQuery(ctx)
|
||||
|
||||
qr, err := r.prepareQueryRange(ctx, ts)
|
||||
if err != nil {
|
||||
return nil
|
||||
return time.Time{}, time.Time{}, "", false
|
||||
}
|
||||
start := time.UnixMilli(int64(qr.Start))
|
||||
end := time.UnixMilli(int64(qr.End))
|
||||
start = time.UnixMilli(int64(qr.Start))
|
||||
end = time.UnixMilli(int64(qr.End))
|
||||
|
||||
// TODO(srikanthccv): handle formula queries
|
||||
if selectedQuery < "A" || selectedQuery > "Z" {
|
||||
return nil
|
||||
return time.Time{}, time.Time{}, "", false
|
||||
}
|
||||
|
||||
var q qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]
|
||||
@@ -112,7 +129,7 @@ func (r *ThresholdRule) prepareParamsForLogs(ctx context.Context, ts time.Time,
|
||||
}
|
||||
|
||||
if q.Signal != telemetrytypes.SignalLogs {
|
||||
return nil
|
||||
return time.Time{}, time.Time{}, "", false
|
||||
}
|
||||
|
||||
filterExpr := ""
|
||||
@@ -120,11 +137,153 @@ func (r *ThresholdRule) prepareParamsForLogs(ctx context.Context, ts time.Time,
|
||||
filterExpr = q.Filter.Expression
|
||||
}
|
||||
|
||||
whereClause := contextlinks.PrepareFilterExpression(lbls.Map(), filterExpr, q.GroupBy)
|
||||
whereClause = contextlinks.PrepareFilterExpression(lbls.Map(), filterExpr, q.GroupBy)
|
||||
return start, end, whereClause, true
|
||||
}
|
||||
|
||||
func (r *ThresholdRule) prepareParamsForLogs(ctx context.Context, ts time.Time, lbls ruletypes.Labels) url.Values {
|
||||
start, end, whereClause, ok := r.logsQueryParams(ctx, ts, lbls)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return contextlinks.PrepareParamsForLogsV5(start, end, whereClause)
|
||||
}
|
||||
|
||||
// fetchLogSamples returns up to logSamplesMaxCount of the most-recent log records
|
||||
// matching the alert's filter for the breaching group (lbls), newest first. It
|
||||
// reuses the same where clause as the related-logs link, so the samples are exactly
|
||||
// the logs that link points to.
|
||||
//
|
||||
// Sampling is best-effort enrichment: any failure is logged and yields no samples
|
||||
// rather than failing the rule evaluation.
|
||||
func (r *ThresholdRule) fetchLogSamples(ctx context.Context, ts time.Time, lbls ruletypes.Labels) []*qbtypes.RawRow {
|
||||
start, end, whereClause, ok := r.logsQueryParams(ctx, ts, lbls)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
req := &qbtypes.QueryRangeRequest{
|
||||
Start: uint64(start.UnixMilli()),
|
||||
End: uint64(end.UnixMilli()),
|
||||
RequestType: qbtypes.RequestTypeRaw,
|
||||
CompositeQuery: qbtypes.CompositeQuery{
|
||||
Queries: []qbtypes.QueryEnvelope{
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
Name: "log_samples",
|
||||
Filter: &qbtypes.Filter{Expression: whereClause},
|
||||
Limit: logSamplesMaxCount,
|
||||
// timestamp,id DESC => most recent first. Both keys with an
|
||||
// identical direction are also what enables the window-list
|
||||
// fast path for raw log queries.
|
||||
Order: []qbtypes.OrderBy{
|
||||
{
|
||||
Direction: qbtypes.OrderDirectionDesc,
|
||||
Key: qbtypes.OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "timestamp", Materialized: true}},
|
||||
},
|
||||
{
|
||||
Direction: qbtypes.OrderDirectionDesc,
|
||||
Key: qbtypes.OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "id", Materialized: true}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
NoCache: true,
|
||||
}
|
||||
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "rules",
|
||||
instrumentationtypes.CodeFunctionName: "fetchLogSamples",
|
||||
})
|
||||
|
||||
resp, err := r.querier.QueryRange(ctx, r.orgID, req)
|
||||
if err != nil {
|
||||
r.logger.WarnContext(ctx, "failed to fetch log samples for alert annotation", errors.Attr(err))
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, item := range resp.Data.Results {
|
||||
if raw, ok := item.(*qbtypes.RawData); ok {
|
||||
return raw.Rows
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// formatLogSamples renders sampled log records as a compact, monospaced markdown
|
||||
// block: one line per record as "[RFC3339] SEVERITY body". Records without a body
|
||||
// are skipped (mirroring Datadog), each body is collapsed to a single line and
|
||||
// truncated to logSampleBodyMaxLen. Returns "" when there is nothing to show.
|
||||
func formatLogSamples(rows []*qbtypes.RawRow) string {
|
||||
lines := make([]string, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
if row == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
body := strings.TrimSpace(rawRowStringField(row, "body"))
|
||||
if body == "" {
|
||||
continue
|
||||
}
|
||||
body = truncateRunes(logSampleSingleLine(body), logSampleBodyMaxLen)
|
||||
|
||||
var sb strings.Builder
|
||||
if !row.Timestamp.IsZero() {
|
||||
sb.WriteString("[")
|
||||
sb.WriteString(row.Timestamp.UTC().Format(time.RFC3339))
|
||||
sb.WriteString("] ")
|
||||
}
|
||||
if sev := strings.TrimSpace(rawRowStringField(row, "severity_text")); sev != "" {
|
||||
sb.WriteString(sev)
|
||||
sb.WriteString(" ")
|
||||
}
|
||||
sb.WriteString(body)
|
||||
lines = append(lines, sb.String())
|
||||
}
|
||||
|
||||
if len(lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "```\n" + strings.Join(lines, "\n") + "\n```"
|
||||
}
|
||||
|
||||
// rawRowStringField returns the named field from a raw row as a string, or "" if it
|
||||
// is absent or not a string.
|
||||
func rawRowStringField(row *qbtypes.RawRow, key string) string {
|
||||
if row == nil || row.Data == nil {
|
||||
return ""
|
||||
}
|
||||
if v, ok := row.Data[key]; ok {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// logSampleSingleLine collapses newlines so a multi-line log body renders as one
|
||||
// line within the samples block.
|
||||
func logSampleSingleLine(s string) string {
|
||||
replacer := strings.NewReplacer("\r\n", " ", "\n", " ", "\r", " ")
|
||||
return replacer.Replace(s)
|
||||
}
|
||||
|
||||
// truncateRunes shortens s to at most max runes, appending an ellipsis when trimmed.
|
||||
func truncateRunes(s string, max int) string {
|
||||
if max <= 0 {
|
||||
return s
|
||||
}
|
||||
runes := []rune(s)
|
||||
if len(runes) <= max {
|
||||
return s
|
||||
}
|
||||
return string(runes[:max]) + "…"
|
||||
}
|
||||
|
||||
func (r *ThresholdRule) prepareParamsForTraces(ctx context.Context, ts time.Time, lbls ruletypes.Labels) url.Values {
|
||||
selectedQuery := r.SelectedQuery(ctx)
|
||||
|
||||
@@ -352,6 +511,14 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (int, error) {
|
||||
r.logger.InfoContext(ctx, "adding logs link to annotations", slog.String("annotation.link", link))
|
||||
annotations = append(annotations, ruletypes.Label{Name: ruletypes.AnnotationRelatedLogs, Value: link})
|
||||
}
|
||||
// Attach a few recent matching log lines so responders see what fired
|
||||
// the alert without leaving the notification. Skipped for no-data
|
||||
// alerts, which by definition have no matching logs.
|
||||
if !smpl.IsMissing {
|
||||
if samples := formatLogSamples(r.fetchLogSamples(ctx, ts, smpl.Metric)); samples != "" {
|
||||
annotations = append(annotations, ruletypes.Label{Name: ruletypes.AnnotationRelatedLogsSamples, Value: samples})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lbs := lb.Labels()
|
||||
|
||||
72
pkg/query-service/rules/threshold_rule_log_samples_test.go
Normal file
72
pkg/query-service/rules/threshold_rule_log_samples_test.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package rules
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestFormatLogSamples(t *testing.T) {
|
||||
ts := time.Date(2026, time.June, 1, 12, 0, 3, 0, time.UTC)
|
||||
|
||||
t.Run("nil and empty yield empty string", func(t *testing.T) {
|
||||
assert.Equal(t, "", formatLogSamples(nil))
|
||||
assert.Equal(t, "", formatLogSamples([]*qbtypes.RawRow{}))
|
||||
})
|
||||
|
||||
t.Run("skips nil rows and rows without a usable body", func(t *testing.T) {
|
||||
rows := []*qbtypes.RawRow{
|
||||
nil,
|
||||
{Timestamp: ts, Data: map[string]any{"body": ""}},
|
||||
{Timestamp: ts, Data: map[string]any{"body": " "}},
|
||||
{Timestamp: ts, Data: map[string]any{"severity_text": "ERROR"}}, // no body key
|
||||
{Timestamp: ts, Data: map[string]any{"body": 42}}, // body not a string
|
||||
}
|
||||
assert.Equal(t, "", formatLogSamples(rows))
|
||||
})
|
||||
|
||||
t.Run("renders timestamp, severity and body inside a code block", func(t *testing.T) {
|
||||
rows := []*qbtypes.RawRow{
|
||||
{Timestamp: ts, Data: map[string]any{"severity_text": "ERROR", "body": "payment failed"}},
|
||||
}
|
||||
want := "```\n[2026-06-01T12:00:03Z] ERROR payment failed\n```"
|
||||
assert.Equal(t, want, formatLogSamples(rows))
|
||||
})
|
||||
|
||||
t.Run("omits severity when absent and collapses a multi-line body", func(t *testing.T) {
|
||||
rows := []*qbtypes.RawRow{
|
||||
{Timestamp: ts, Data: map[string]any{"body": "line1\nline2\r\nline3"}},
|
||||
}
|
||||
want := "```\n[2026-06-01T12:00:03Z] line1 line2 line3\n```"
|
||||
assert.Equal(t, want, formatLogSamples(rows))
|
||||
})
|
||||
|
||||
t.Run("omits the timestamp prefix when zero", func(t *testing.T) {
|
||||
rows := []*qbtypes.RawRow{
|
||||
{Data: map[string]any{"body": "no ts"}},
|
||||
}
|
||||
assert.Equal(t, "```\nno ts\n```", formatLogSamples(rows))
|
||||
})
|
||||
|
||||
t.Run("renders one line per record and preserves input order", func(t *testing.T) {
|
||||
rows := []*qbtypes.RawRow{
|
||||
{Timestamp: ts, Data: map[string]any{"body": "first"}},
|
||||
{Timestamp: ts.Add(-time.Second), Data: map[string]any{"body": "second"}},
|
||||
}
|
||||
want := "```\n[2026-06-01T12:00:03Z] first\n[2026-06-01T12:00:02Z] second\n```"
|
||||
assert.Equal(t, want, formatLogSamples(rows))
|
||||
})
|
||||
|
||||
t.Run("truncates a long body to logSampleBodyMaxLen runes plus ellipsis", func(t *testing.T) {
|
||||
long := strings.Repeat("a", logSampleBodyMaxLen+50)
|
||||
rows := []*qbtypes.RawRow{
|
||||
{Timestamp: ts, Data: map[string]any{"body": long}},
|
||||
}
|
||||
out := formatLogSamples(rows)
|
||||
assert.Contains(t, out, strings.Repeat("a", logSampleBodyMaxLen)+"…")
|
||||
assert.NotContains(t, out, strings.Repeat("a", logSampleBodyMaxLen+1))
|
||||
})
|
||||
}
|
||||
@@ -30,12 +30,13 @@ const (
|
||||
// {{ .Annotations.value }}, {{ .Annotations.threshold.value }}, etc. in
|
||||
// their channel templates.
|
||||
const (
|
||||
AnnotationTitleTemplate = "_title_template"
|
||||
AnnotationBodyTemplate = "_body_template"
|
||||
AnnotationRelatedLogs = "related_logs"
|
||||
AnnotationRelatedTraces = "related_traces"
|
||||
AnnotationValue = "value"
|
||||
AnnotationThresholdValue = "threshold.value"
|
||||
AnnotationCompareOp = "compare_op"
|
||||
AnnotationMatchType = "match_type"
|
||||
AnnotationTitleTemplate = "_title_template"
|
||||
AnnotationBodyTemplate = "_body_template"
|
||||
AnnotationRelatedLogs = "related_logs"
|
||||
AnnotationRelatedLogsSamples = "related_logs_samples"
|
||||
AnnotationRelatedTraces = "related_traces"
|
||||
AnnotationValue = "value"
|
||||
AnnotationThresholdValue = "threshold.value"
|
||||
AnnotationCompareOp = "compare_op"
|
||||
AnnotationMatchType = "match_type"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user