Compare commits

...

5 Commits

Author SHA1 Message Date
aks07
b01571ebcf chore(sentry): drop the tab-close case from the request failure comment 2026-11-11 11:51:27 +05:30
aks07
9139da3762 fix(sentry): stop reporting 401, 403 and network axios failures 2026-11-11 11:29:20 +05:30
Swapnil Nakade
da8d66b5b9 feat(audit): emit audit logs per signoz semantic conventions (#13477)
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
- using semantic conventions described in `signoz-semanatic-conventions`
repository for audit

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
contributes to https://github.com/SigNoz/keystone-pod/issues/188
2026-11-10 21:26:00 +00:00
Swapnil Nakade
f1d6c988c9 feat(audit): adding kafka audit exporter (#13357)
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
- add `kafka` auditor provider that produces audit logs to a
kafka/redpanda topic as `otlp_proto`
- uses franz-go v1.21.7.
- no delivery timeout or retry cap: on franz-go < v1.22.2 these can ack
a record that was never written (twmb/franz-go#1484).
- added unit tests

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

<!--Please delete paragraphs that you did not use before submitting.-->
2026-11-10 13:38:39 +00:00
Tushar Vats
f02b5521c6 feat(querier): heatmaps for the logs and traces signals (#13318)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

Extends the metrics heatmap to logs, traces and audit logs, so a heatmap
can be built ad hoc from raw events with any filter instead of from a
pre-generated metric.

- The aggregation is the bare field to bucket, e.g. `duration_nano`.
Each cell counts events. Any other aggregation, such as `count()`, is
rejected, and a query takes one aggregation.
- Rows without the field are excluded by default: `<field> EXISTS` is
ANDed onto the filter, so it also applies to the resource filter and the
group limit. A present but non-numeric value counts as 0.
- `bucketOptions` is optional and defaults to the log axis, as for
metrics. Distribution requests work the same way.
- The heatmap is the time series query with the value bucket joining the
group keys, so a row is one cell and the response shape is unchanged.
Results are cached, and a cached axis is trimmed to the requested
window.
- The bucket expressions move to `pkg/querybuilder/heatmap.go`, shared
with metrics. The value is read once through a `WITH` alias instead of
being repeated in every bound. The metrics SQL is unchanged.
- A disabled query in a heatmap request feeds a formula, so it builds as
a time series, as metrics already did.
- A field nothing carries draws an empty chart with the key-not-found
warning.

#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/338

#### Additional Information

- No API schema change.
- Integration tests (`querierlogs/17_heatmap.py`,
`queriertraces/16_heatmap.py`, `auditquerier/02_heatmap.py`) cover
linear, log and default axes, group by, rows without the field, and the
rejections.
- Backend only. Dashboards first, explorers later.
2026-11-10 09:05:04 +00:00
41 changed files with 2339 additions and 371 deletions

View File

@@ -9,6 +9,7 @@ import (
"github.com/SigNoz/signoz/cmd"
"github.com/SigNoz/signoz/ee/auditor/fileauditor"
"github.com/SigNoz/signoz/ee/auditor/kafkaauditor"
"github.com/SigNoz/signoz/ee/auditor/otlphttpauditor"
"github.com/SigNoz/signoz/ee/authn/callbackauthn/oidccallbackauthn"
"github.com/SigNoz/signoz/ee/authn/callbackauthn/samlcallbackauthn"
@@ -156,6 +157,9 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
if err := factories.Add(fileauditor.NewFactory(licensing, version.Info)); err != nil {
panic(err)
}
if err := factories.Add(kafkaauditor.NewFactory(licensing, version.Info)); err != nil {
panic(err)
}
return factories
},
func(ctx context.Context, providerSettings factory.ProviderSettings, flagger pkgflagger.Flagger, licensing licensing.Licensing, telemetryStore telemetrystore.TelemetryStore, retentionGetter retention.Getter, orgGetter organization.Getter, zeus zeus.Zeus) (factory.NamedMap[factory.ProviderFactory[meterreporter.Reporter, meterreporter.Config]], string) {

View File

@@ -436,6 +436,7 @@ auditor:
# Specifies the auditor provider to use.
# noop: discards all audit events (community default).
# otlphttp: exports audit events via OTLP HTTP (enterprise).
# kafka: exports audit events to a Kafka topic as OTLP protobuf (enterprise).
provider: noop
# The async channel capacity for audit events. Events are dropped when full (fail-open).
buffer_size: 1000
@@ -461,6 +462,18 @@ auditor:
max_interval: 30s
# The total maximum time spent retrying.
max_elapsed_time: 60s
kafka:
# The seed broker addresses (host:port) used to discover the cluster.
brokers:
- localhost:9092
# The topic audit events are written to. It must already exist.
topic: ""
# The client id the producer reports to the brokers.
client_id: signoz
# The compression codec for produced batches: none, gzip, snappy, lz4 or zstd.
compression: lz4
# The maximum time to drain buffered events on shutdown. Undelivered events are dropped after it.
shutdown_timeout: 30s
##################### Cloud Integration #####################
cloudintegration:

View File

@@ -0,0 +1,31 @@
package kafkaauditor
import (
"context"
"log/slog"
"github.com/twmb/franz-go/pkg/kgo"
"github.com/SigNoz/signoz/pkg/auditor"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/audittypes"
)
// Produces the whole batch as a single record so the collector's otlp_proto unmarshaler reads it as one ExportLogsServiceRequest.
func (provider *provider) export(ctx context.Context, events []audittypes.AuditEvent) error {
logs := audittypes.NewPLogsFromAuditEvents(events, "signoz", provider.build.Version(), "signoz.audit")
value, err := provider.marshaler.MarshalLogs(logs)
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, auditor.ErrCodeAuditExportFailed, "failed to marshal audit logs")
}
record := &kgo.Record{Topic: provider.config.Kafka.Topic, Value: value}
if err := provider.client.ProduceSync(ctx, record).FirstErr(); err != nil {
provider.settings.Logger().ErrorContext(ctx, "audit export failed", errors.Attr(err), slog.Int("dropped_log_records", len(events)))
return errors.Wrapf(err, errors.TypeInternal, auditor.ErrCodeAuditExportFailed, "failed to produce audit logs to topic %q", provider.config.Kafka.Topic)
}
return nil
}

View File

@@ -0,0 +1,132 @@
package kafkaauditor
import (
"context"
"log/slog"
"github.com/twmb/franz-go/pkg/kgo"
"github.com/twmb/franz-go/plugin/kslog"
"go.opentelemetry.io/collector/pdata/plog"
"github.com/SigNoz/signoz/pkg/auditor"
"github.com/SigNoz/signoz/pkg/auditor/auditorserver"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/types/audittypes"
"github.com/SigNoz/signoz/pkg/version"
)
var _ auditor.Auditor = (*provider)(nil)
type provider struct {
settings factory.ScopedProviderSettings
config auditor.Config
licensing licensing.Licensing
build version.Build
server *auditorserver.Server
marshaler plog.ProtoMarshaler
client *kgo.Client
}
func NewFactory(licensing licensing.Licensing, build version.Build) factory.ProviderFactory[auditor.Auditor, auditor.Config] {
return factory.NewProviderFactory(factory.MustNewName("kafka"), func(ctx context.Context, providerSettings factory.ProviderSettings, config auditor.Config) (auditor.Auditor, error) {
return newProvider(ctx, providerSettings, config, licensing, build)
})
}
func newProvider(_ context.Context, providerSettings factory.ProviderSettings, config auditor.Config, licensing licensing.Licensing, build version.Build) (auditor.Auditor, error) {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/ee/auditor/kafkaauditor")
// Acks from all in-sync replicas, idempotent writes and unbounded record retries are franz-go defaults and are kept as is.
// A delivery timeout or retry limit lets franz-go < v1.22.2 ack a record that was never written (twmb/franz-go#1484).
client, err := kgo.NewClient(
kgo.SeedBrokers(config.Kafka.Brokers...),
kgo.ClientID(config.Kafka.ClientID),
kgo.ProducerBatchCompression(compressionCodec(config.Kafka.Compression)),
kgo.ProducerLinger(0),
kgo.WithLogger(kslog.New(settings.Logger())),
)
if err != nil {
return nil, err
}
provider := &provider{
settings: settings,
config: config,
licensing: licensing,
build: build,
marshaler: plog.ProtoMarshaler{},
client: client,
}
server, err := auditorserver.New(settings,
auditorserver.Config{
BufferSize: config.BufferSize,
BatchSize: config.BatchSize,
FlushInterval: config.FlushInterval,
},
provider.export,
)
if err != nil {
client.Close()
return nil, err
}
provider.server = server
return provider, nil
}
func (provider *provider) Start(ctx context.Context) error {
return provider.server.Start(ctx)
}
func (provider *provider) Audit(ctx context.Context, event audittypes.AuditEvent) {
if event.PrincipalAttributes.PrincipalOrgID.IsZero() {
provider.settings.Logger().WarnContext(ctx, "audit event dropped as org_id is zero")
return
}
if _, err := provider.licensing.GetActive(ctx, event.PrincipalAttributes.PrincipalOrgID); err != nil {
return
}
provider.server.Add(ctx, event)
}
func (provider *provider) Healthy() <-chan struct{} {
return provider.server.Healthy()
}
func (provider *provider) Stop(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, provider.config.Kafka.ShutdownTimeout)
defer cancel()
stoppedC := make(chan error, 1)
go func() { stoppedC <- provider.server.Stop(ctx) }()
select {
case err := <-stoppedC:
provider.client.Close()
return err
case <-ctx.Done():
provider.settings.Logger().WarnContext(ctx, "audit drain timed out, closing kafka client", slog.Duration("shutdown_timeout", provider.config.Kafka.ShutdownTimeout))
// Closing fails buffered and in-flight records, so the blocked export and the rest of the drain return immediately.
provider.client.Close()
return <-stoppedC
}
}
func compressionCodec(name string) kgo.CompressionCodec {
switch name {
case "gzip":
return kgo.GzipCompression()
case "snappy":
return kgo.SnappyCompression()
case "lz4":
return kgo.Lz4Compression()
case "zstd":
return kgo.ZstdCompression()
default:
return kgo.NoCompression()
}
}

View File

@@ -0,0 +1,227 @@
package kafkaauditor
import (
"context"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/twmb/franz-go/pkg/kerr"
"github.com/twmb/franz-go/pkg/kfake"
"github.com/twmb/franz-go/pkg/kgo"
"go.opentelemetry.io/collector/pdata/plog"
"github.com/SigNoz/signoz/pkg/auditor"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/types/audittypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/version"
)
func newTestCluster(t *testing.T, topics ...string) *kfake.Cluster {
t.Helper()
cluster, err := kfake.NewCluster(kfake.NumBrokers(1), kfake.SeedTopics(1, topics...))
require.NoError(t, err)
t.Cleanup(cluster.Close)
return cluster
}
func newTestProvider(t *testing.T, brokers []string, topic string, compression string) *provider {
t.Helper()
config := auditor.Config{
Provider: "kafka",
BufferSize: 10,
BatchSize: 5,
FlushInterval: 50 * time.Millisecond,
Kafka: auditor.KafkaConfig{
Brokers: brokers,
Topic: topic,
ClientID: "signoz-test",
Compression: compression,
ShutdownTimeout: 30 * time.Second,
},
}
auditor, err := newProvider(context.Background(), instrumentationtest.New().ToProviderSettings(), config, nil, version.Info)
require.NoError(t, err)
return auditor.(*provider)
}
func newTestEvent(resource coretypes.Resource, action coretypes.Verb, body string) audittypes.AuditEvent {
return audittypes.AuditEvent{
Timestamp: time.Now(),
Body: body,
EventName: audittypes.NewEventName(action),
AuditAttributes: audittypes.AuditAttributes{
Action: action,
Outcome: audittypes.OutcomeSuccess,
},
ResourceAttributes: audittypes.ResourceAttributes{
Resource: resource,
},
}
}
// Polls the topic until the consumed records hold wantLogRecords audit log records in total.
func consumeRecords(t *testing.T, brokers []string, topic string, wantLogRecords int) []*kgo.Record {
t.Helper()
client, err := kgo.NewClient(
kgo.SeedBrokers(brokers...),
kgo.ConsumeTopics(topic),
kgo.ConsumeResetOffset(kgo.NewOffset().AtStart()),
)
require.NoError(t, err)
defer client.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var records []*kgo.Record
gotLogRecords := 0
for gotLogRecords < wantLogRecords {
fetches := client.PollFetches(ctx)
require.NoError(t, ctx.Err(), "timed out waiting for %d log records, got %d", wantLogRecords, gotLogRecords)
require.Empty(t, fetches.Errors())
for _, record := range fetches.Records() {
logs, err := (&plog.ProtoUnmarshaler{}).UnmarshalLogs(record.Value)
require.NoError(t, err)
gotLogRecords += logs.LogRecordCount()
records = append(records, record)
}
}
return records
}
func TestExport(t *testing.T) {
testCases := []struct {
name string
compression string
compressionType uint8
}{
{name: "CompressionNone", compression: "none", compressionType: 0},
{name: "CompressionGzip", compression: "gzip", compressionType: 1},
{name: "CompressionSnappy", compression: "snappy", compressionType: 2},
{name: "CompressionLz4", compression: "lz4", compressionType: 3},
{name: "CompressionZstd", compression: "zstd", compressionType: 4},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
cluster := newTestCluster(t, "tenant_audit")
provider := newTestProvider(t, cluster.ListenAddrs(), "tenant_audit", testCase.compression)
defer provider.client.Close()
events := []audittypes.AuditEvent{
newTestEvent(coretypes.ResourceUser, coretypes.VerbUpdate, "user updated"),
newTestEvent(coretypes.ResourceMetaResourceDashboard, coretypes.VerbCreate, "dashboard created"),
newTestEvent(coretypes.ResourceMetaResourceDashboard, coretypes.VerbDelete, "dashboard deleted"),
}
require.NoError(t, provider.export(context.Background(), events))
records := consumeRecords(t, cluster.ListenAddrs(), "tenant_audit", len(events))
require.Len(t, records, 1)
assert.Equal(t, "tenant_audit", records[0].Topic)
assert.Equal(t, testCase.compressionType, records[0].Attrs.CompressionType())
logs, err := (&plog.ProtoUnmarshaler{}).UnmarshalLogs(records[0].Value)
require.NoError(t, err)
assert.Equal(t, len(events), logs.LogRecordCount())
serviceName, ok := logs.ResourceLogs().At(0).Resource().Attributes().Get("service.name")
require.True(t, ok)
assert.Equal(t, "signoz", serviceName.Str())
})
}
}
func TestExport_RecordTooLarge(t *testing.T) {
cluster := newTestCluster(t, "large_audit")
provider := newTestProvider(t, cluster.ListenAddrs(), "large_audit", "none")
defer provider.client.Close()
event := newTestEvent(coretypes.ResourceUser, coretypes.VerbCreate, strings.Repeat("a", 2<<20))
err := provider.export(context.Background(), []audittypes.AuditEvent{event})
require.Error(t, err)
assert.True(t, errors.Is(err, kerr.MessageTooLarge))
}
func TestExport_UnknownTopic(t *testing.T) {
cluster := newTestCluster(t)
provider := newTestProvider(t, cluster.ListenAddrs(), "missing_audit", "lz4")
defer provider.client.Close()
err := provider.export(context.Background(), []audittypes.AuditEvent{newTestEvent(coretypes.ResourceUser, coretypes.VerbCreate, "user created")})
require.Error(t, err)
assert.True(t, errors.Is(err, kerr.UnknownTopicOrPartition))
}
func TestExport_BrokerDown_RetriesUntilContextDone(t *testing.T) {
cluster := newTestCluster(t, "down_audit")
brokers := cluster.ListenAddrs()
cluster.Close()
provider := newTestProvider(t, brokers, "down_audit", "lz4")
defer provider.client.Close()
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
start := time.Now()
err := provider.export(ctx, []audittypes.AuditEvent{newTestEvent(coretypes.ResourceUser, coretypes.VerbCreate, "user created")})
require.Error(t, err)
assert.True(t, errors.Is(err, context.DeadlineExceeded))
assert.GreaterOrEqual(t, time.Since(start), 500*time.Millisecond)
}
func TestStop_DrainsQueuedEvents(t *testing.T) {
cluster := newTestCluster(t, "drain_audit")
provider := newTestProvider(t, cluster.ListenAddrs(), "drain_audit", "lz4")
done := make(chan error, 1)
go func() { done <- provider.Start(context.Background()) }()
<-provider.Healthy()
for range 7 {
provider.server.Add(context.Background(), newTestEvent(coretypes.ResourceUser, coretypes.VerbUpdate, "user updated"))
}
require.NoError(t, provider.Stop(context.Background()))
require.NoError(t, <-done)
records := consumeRecords(t, cluster.ListenAddrs(), "drain_audit", 7)
assert.NotEmpty(t, records)
}
func TestStop_BrokerDown_ClosesAfterShutdownTimeout(t *testing.T) {
cluster := newTestCluster(t, "stuck_audit")
brokers := cluster.ListenAddrs()
cluster.Close()
provider := newTestProvider(t, brokers, "stuck_audit", "lz4")
provider.config.Kafka.ShutdownTimeout = 500 * time.Millisecond
done := make(chan error, 1)
go func() { done <- provider.Start(context.Background()) }()
<-provider.Healthy()
for range 7 {
provider.server.Add(context.Background(), newTestEvent(coretypes.ResourceUser, coretypes.VerbUpdate, "user updated"))
}
start := time.Now()
require.NoError(t, provider.Stop(context.Background()))
require.NoError(t, <-done)
assert.GreaterOrEqual(t, time.Since(start), 500*time.Millisecond)
assert.Less(t, time.Since(start), 5*time.Second)
}

View File

@@ -1,7 +1,9 @@
import type { ErrorEvent, StackFrame } from '@sentry/react';
import { AxiosError, AxiosHeaders } from 'axios';
import {
hasNoAppFrames,
isExpectedRequestFailure,
isExternalIntegrationError,
SENTRY_IGNORE_ERRORS,
} from '../sentryFilters';
@@ -138,3 +140,36 @@ describe('SENTRY_IGNORE_ERRORS', () => {
).toBe(true);
});
});
describe('isExpectedRequestFailure', () => {
function axiosErrorWithStatus(status: number): AxiosError {
const config = { headers: new AxiosHeaders() };
return new AxiosError(
`Request failed with status code ${status}`,
AxiosError.ERR_BAD_REQUEST,
config,
undefined,
{ status, statusText: '', headers: {}, config, data: {} },
);
}
it.each([401, 403])('drops a %i response', (status) => {
expect(isExpectedRequestFailure(axiosErrorWithStatus(status))).toBe(true);
});
it('drops a network error', () => {
expect(
isExpectedRequestFailure(
new AxiosError('Network Error', AxiosError.ERR_NETWORK),
),
).toBe(true);
});
it.each([400, 404, 500, 503])('keeps a %i response', (status) => {
expect(isExpectedRequestFailure(axiosErrorWithStatus(status))).toBe(false);
});
it('keeps an error that is not from axios', () => {
expect(isExpectedRequestFailure(new Error('Network Error'))).toBe(false);
});
});

View File

@@ -40,6 +40,7 @@ import defaultRoutes, {
} from './routes';
import {
hasNoAppFrames,
isExpectedRequestFailure,
isExternalIntegrationError,
SENTRY_IGNORE_ERRORS,
} from './sentryFilters';
@@ -341,6 +342,9 @@ function App(): JSX.Element {
if (error?.name === 'AbortError') {
return null;
}
if (isExpectedRequestFailure(hint?.originalException)) {
return null;
}
// Ignore benign Monaco cancellation errors (name 'Canceled').
if (error?.name === 'Canceled') {
return null;

View File

@@ -1,4 +1,5 @@
import type { ErrorEvent } from '@sentry/react';
import { AxiosError, isAxiosError } from 'axios';
export const SENTRY_IGNORE_ERRORS = [
// A tab that outlived a deploy requests hashed assets the new build no longer
@@ -14,6 +15,18 @@ export const SENTRY_IGNORE_ERRORS = [
/Called on script loaded before session recording is available/,
];
// Not UI bugs: 401/403 come from the session or role, ERR_NETWORK from a
// dropped connection.
export function isExpectedRequestFailure(error: unknown): boolean {
if (!isAxiosError(error)) {
return false;
}
const status = error.response?.status;
return (
error.code === AxiosError.ERR_NETWORK || status === 401 || status === 403
);
}
const APP_ASSETS_PATH = '/assets/';
// Frames without a position are `[native code]`, which says nothing about the

9
go.mod
View File

@@ -10,6 +10,7 @@ require (
github.com/SigNoz/clickhouse-go-mock v0.14.0
github.com/SigNoz/govaluate v0.0.0-20240203125216-988004ccc7fd
github.com/SigNoz/signoz-otel-collector v0.144.6
github.com/SigNoz/signoz-semantic-conventions/gen/go v0.0.1
github.com/antlr4-go/antlr/v4 v4.13.1
github.com/antonmedv/expr v1.15.3
github.com/cespare/xxhash/v2 v2.3.0
@@ -61,6 +62,9 @@ require (
github.com/swaggest/jsonschema-go v0.3.78
github.com/swaggest/rest v0.2.75
github.com/tidwall/gjson v1.18.0
github.com/twmb/franz-go v1.21.7
github.com/twmb/franz-go/pkg/kfake v0.0.0-20260421215025-4e7a1e1569ac
github.com/twmb/franz-go/plugin/kslog v1.0.0
github.com/uptrace/bun v1.2.9
github.com/uptrace/bun/dialect/pgdialect v1.2.9
github.com/uptrace/bun/dialect/sqlitedialect v1.2.9
@@ -144,6 +148,7 @@ require (
github.com/swaggest/refl v1.4.0 // indirect
github.com/swaggest/usecase v1.3.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/twmb/franz-go/pkg/kmsg v1.13.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
github.com/uptrace/opentelemetry-go-extra/otelsql v0.3.2 // indirect
github.com/x448/float16 v0.8.4 // indirect
@@ -243,7 +248,7 @@ require (
github.com/josharian/intern v1.0.0 // indirect
github.com/jpillora/backoff v1.0.0 // indirect
github.com/jtolds/gls v4.20.0+incompatible // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/klauspost/compress v1.19.2 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
@@ -272,7 +277,7 @@ require (
github.com/openfga/openfga v1.14.1
github.com/paulmach/orb v0.12.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pierrec/lz4/v4 v4.1.25 // indirect
github.com/pierrec/lz4/v4 v4.1.26 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/pressly/goose/v3 v3.27.0 // indirect

20
go.sum
View File

@@ -102,6 +102,8 @@ github.com/SigNoz/govaluate v0.0.0-20240203125216-988004ccc7fd h1:Bk43AsDYe0fhkb
github.com/SigNoz/govaluate v0.0.0-20240203125216-988004ccc7fd/go.mod h1:nxRcH/OEdM8QxzH37xkGzomr1O0JpYBRS6pwjsWW6Pc=
github.com/SigNoz/signoz-otel-collector v0.144.6 h1:AgblCIDlLKGtssS4ywvyEByH9fbJ7hFqZvPvUVJKwp8=
github.com/SigNoz/signoz-otel-collector v0.144.6/go.mod h1:YRQw6ETaq7fm8ts4Itgb6wC0bVhc0+nC6PFI0MaiH9k=
github.com/SigNoz/signoz-semantic-conventions/gen/go v0.0.1 h1:eKJ4vmSLYIfufgh+HaiJxXZts12Xz4jr81HMYYoQvMU=
github.com/SigNoz/signoz-semantic-conventions/gen/go v0.0.1/go.mod h1:RSsdmH4/6H+EHeNPyqJ59dGdpKS0hl2p3T5g1NbehOU=
github.com/Yiling-J/theine-go v0.6.2 h1:1GeoXeQ0O0AUkiwj2S9Jc0Mzx+hpqzmqsJ4kIC4M9AY=
github.com/Yiling-J/theine-go v0.6.2/go.mod h1:08QpMa5JZ2pKN+UJCRrCasWYO1IKCdl54Xa836rpmDU=
github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c=
@@ -675,8 +677,8 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE=
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/knadh/koanf v1.5.0 h1:q2TSd/3Pyc/5yP9ldIrSdIz26MCcyNQzW0pEAugLPNs=
@@ -853,8 +855,8 @@ github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8
github.com/perses/spec v0.1.2 h1:yGoygcR3ZusuGDCmRMwsVXCMvMwi1qZndKV6NYNreEw=
github.com/perses/spec v0.1.2/go.mod h1:NoGI5jmGwRdkdPgyYSZJTBL4/Py+dqIPKS2QV8NOvGE=
github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0=
github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY=
github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
@@ -1055,6 +1057,16 @@ github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GH
github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/twmb/franz-go v1.21.7 h1:/DkA/o8wQN55gZWtpj2QNb9SIdxwFR7M+NecQWMdmc0=
github.com/twmb/franz-go v1.21.7/go.mod h1:89kLt1uhE1GkyossLHGdpAMFNK9mV8GYk1lfWu9FiNs=
github.com/twmb/franz-go/pkg/kadm v1.18.0 h1:WRf/LZmDdcDXwX7WMbtDU++v+b3NzYh2bCGoPMmzirw=
github.com/twmb/franz-go/pkg/kadm v1.18.0/go.mod h1:XeLhGoLXLFzK8/ryv5FfpxPxGwj4oFEGpPJMB/x6KDE=
github.com/twmb/franz-go/pkg/kfake v0.0.0-20260421215025-4e7a1e1569ac h1:6Dn8at7J4+5A8H7NgVLcPj4dfVm4pRtUZMHxXmBKxJ4=
github.com/twmb/franz-go/pkg/kfake v0.0.0-20260421215025-4e7a1e1569ac/go.mod h1:6ofUZL6jAhajJ5WAaJTX9rK3B1ozGDOwKyZczdGK8uE=
github.com/twmb/franz-go/pkg/kmsg v1.13.1 h1:fG5kItwysTk5UXqVwb64EpQEy3TydF3vYYK21nUQ+bI=
github.com/twmb/franz-go/pkg/kmsg v1.13.1/go.mod h1:+DPt4NC8RmI6hqb8G09+3giKObE6uD2Eya6CfqBpeJY=
github.com/twmb/franz-go/plugin/kslog v1.0.0 h1:I64oEmF+0PDvmyLgwrlOtg4mfpSE9GwlcLxM4af2t60=
github.com/twmb/franz-go/plugin/kslog v1.0.0/go.mod h1:8pMjK3OJJJNNYddBSbnXZkIK5dCKFIk9GcVVCDgvnQc=
github.com/twmb/murmur3 v1.1.8 h1:8Yt9taO/WN3l08xErzjeschgZU2QSrwm1kclYq+0aRg=
github.com/twmb/murmur3 v1.1.8/go.mod h1:Qq/R7NUyOfr65zD+6Q5IHKsJLwP7exErjN6lyyq3OSQ=
github.com/ua-parser/uap-go v0.0.0-20240611065828-3a4781585db6 h1:SIKIoA4e/5Y9ZOl0DCe3eVMLPOQzJxgZpfdHHeauNTM=

View File

@@ -23,7 +23,7 @@ func newTestSettings() factory.ScopedProviderSettings {
func newTestEvent(resource coretypes.Resource, action coretypes.Verb) audittypes.AuditEvent {
return audittypes.AuditEvent{
Timestamp: time.Now(),
EventName: audittypes.NewEventName(resource.Kind(), action),
EventName: audittypes.NewEventName(action),
AuditAttributes: audittypes.AuditAttributes{
Action: action,
Outcome: audittypes.OutcomeSuccess,

View File

@@ -27,6 +27,8 @@ type Config struct {
OTLPHTTP OTLPHTTPConfig `mapstructure:"otlphttp"`
File FileConfig `mapstructure:"file"`
Kafka KafkaConfig `mapstructure:"kafka"`
}
// OTLPHTTPConfig holds configuration for the OTLP HTTP exporter provider.
@@ -54,6 +56,23 @@ type FileConfig struct {
Path string `mapstructure:"path"`
}
type KafkaConfig struct {
// Brokers are the seed broker addresses (host:port) used to discover the cluster.
Brokers []string `mapstructure:"brokers"`
// Topic must already exist; the producer does not create it.
Topic string `mapstructure:"topic"`
// ClientID identifies this producer in broker logs and metrics.
ClientID string `mapstructure:"client_id"`
// Compression is the codec for produced batches: none, gzip, snappy, lz4 or zstd.
Compression string `mapstructure:"compression"`
// ShutdownTimeout bounds draining buffered events on stop; events still undelivered when it expires are dropped.
ShutdownTimeout time.Duration `mapstructure:"shutdown_timeout"`
}
// RetryConfig configures exponential backoff for the OTLP HTTP exporter.
type RetryConfig struct {
// Enabled controls whether retries are attempted on transient failures.
@@ -89,6 +108,12 @@ func newConfig() factory.Config {
MaxElapsedTime: time.Minute,
},
},
Kafka: KafkaConfig{
Brokers: []string{"localhost:9092"},
ClientID: "signoz",
Compression: "lz4",
ShutdownTimeout: 30 * time.Second,
},
}
}
@@ -125,5 +150,25 @@ func (c Config) Validate() error {
}
}
if c.Provider == "kafka" {
if len(c.Kafka.Brokers) == 0 {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "auditor::kafka::brokers must be set when provider is kafka")
}
if c.Kafka.Topic == "" {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "auditor::kafka::topic must be set when provider is kafka")
}
switch c.Kafka.Compression {
case "none", "gzip", "snappy", "lz4", "zstd":
default:
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "auditor::kafka::compression must be one of none, gzip, snappy, lz4 or zstd, got %q", c.Kafka.Compression)
}
if c.Kafka.ShutdownTimeout <= 0 {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "auditor::kafka::shutdown_timeout must be greater than 0")
}
}
return nil
}

View File

@@ -109,10 +109,9 @@ func (middleware *Audit) emitAuditEvent(req *http.Request, writer responseCaptur
statusCode := writer.StatusCode()
span := trace.SpanFromContext(req.Context())
var errorType, errorCode string
var errorType string
if statusCode >= 400 {
errorType = render.ErrorTypeFromStatusCode(statusCode)
errorCode = render.ErrorCodeFromBody(writer.BodyBytes())
}
extractorCtx := coretypes.ExtractorContext{Request: req, ResponseBody: writer.BodyBytes()}
@@ -122,14 +121,18 @@ func (middleware *Audit) emitAuditEvent(req *http.Request, writer responseCaptur
continue
}
resource.ResolveResponse(extractorCtx)
verb, category := resource.Verb(), resource.Category()
if !audittypes.IsAudited(verb) {
continue
}
resource.ResolveResponse(extractorCtx)
switch typed := resource.(type) {
case coretypes.ResolvedResourceWithTargetResource:
for _, sourceID := range typed.SourceIDs() {
for _, targetID := range typed.TargetIDs() {
attributesList := []audittypes.ResourceAttributes{
attributesList := []audittypes.RelatedResourceAttributes{
audittypes.NewRelatedResourceAttributes(
typed.SourceResource(),
sourceID,
@@ -158,9 +161,9 @@ func (middleware *Audit) emitAuditEvent(req *http.Request, writer responseCaptur
verb,
category,
claims,
attributes,
attributes.Resource,
attributes.Target,
errorType,
errorCode,
))
}
}
@@ -179,8 +182,8 @@ func (middleware *Audit) emitAuditEvent(req *http.Request, writer responseCaptur
category,
claims,
attributes,
audittypes.TargetAttributes{},
errorType,
errorCode,
))
}
}

View File

@@ -7,7 +7,6 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
jsoniter "github.com/json-iterator/go"
"github.com/tidwall/gjson"
)
const (
@@ -45,18 +44,6 @@ func Success(rw http.ResponseWriter, httpCode int, data interface{}) {
_, _ = rw.Write(body)
}
func ErrorCodeFromBody(body []byte) string {
code := gjson.GetBytes(body, "error.code").String()
// This should never return empty since we only call this function on responses that were generated by us.
// If it does return empty, the codebase has failed to use render package for error responses somewhere, and we should fix that instead of trying to handle it here.
if code == "" {
return errors.CodeUnset.String()
}
return code
}
func ErrorTypeFromStatusCode(statusCode int) string {
// We are losing the exact type information here, but we can at least capture the error code and message for better observability.
// To get the exact type, we would need some changes in the render package to include the error type in the response, which we can consider in the future if there is a need for it.

View File

@@ -60,31 +60,6 @@ func TestSuccess(t *testing.T) {
assert.Equal(t, expected, actual)
}
func TestErrorCodeFromBody(t *testing.T) {
testCases := []struct {
name string
body []byte
wantCode string
}{
{
name: "ValidErrorResponse",
body: []byte(`{"status":"error","error":{"code":"authz_forbidden","message":"only admins can access this resource"}}`),
wantCode: "authz_forbidden",
},
{
name: "InvalidJSON",
body: []byte(`not json`),
wantCode: "unset",
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.wantCode, ErrorCodeFromBody(testCase.body))
})
}
}
func TestError(t *testing.T) {
listener, err := net.Listen("tcp", "localhost:0")
require.NoError(t, err)

View File

@@ -915,13 +915,15 @@ func (bc *bucketCache) filterResultToTimeRange(result *qbtypes.Result, q qbtypes
// as empty rows that the same request never has when it bypasses the cache.
//
// PromQL and ClickHouse queries name their own buckets, and a histogram's are
// its `le` labels, so only a gauge, sum or summary axis is cut.
// its `le` labels, so only a gauge, sum or summary axis and an event axis are cut.
func (bc *bucketCache) trimHeatmapAxisToTheWindow(q qbtypes.Query, tsData *qbtypes.TimeSeriesData) {
metricQuery, isMetricQuery := q.(*builderQuery[qbtypes.MetricAggregation])
if !isMetricQuery {
return
}
if metricQuery.spec.Aggregations[0].Type == metrictypes.HistogramType {
switch query := q.(type) {
case *builderQuery[qbtypes.MetricAggregation]:
if query.spec.Aggregations[0].Type == metrictypes.HistogramType {
return
}
case *builderQuery[qbtypes.LogAggregation], *builderQuery[qbtypes.TraceAggregation]:
default:
return
}

View File

@@ -77,7 +77,7 @@ func newBuilderQuery[T any](
func (q *builderQuery[T]) Fingerprint() string {
if (q.spec.Signal == telemetrytypes.SignalTraces ||
q.spec.Signal == telemetrytypes.SignalLogs) && q.kind != qbtypes.RequestTypeTimeSeries {
q.spec.Signal == telemetrytypes.SignalLogs) && !q.kind.IsBuiltFromTimeSeries() {
// No caching for non-timeseries queries
return ""
}
@@ -150,6 +150,12 @@ func (q *builderQuery[T]) Fingerprint() string {
parts = append(parts, fmt.Sprintf("aggs=[%s]", strings.Join(aggParts, ",")))
}
// A metrics heatmap carries its axis on the aggregation, which the loop above
// already fingerprinted. Logs and traces carry it on the spec.
if q.kind.CountsIntoBuckets() && q.spec.Signal != telemetrytypes.SignalMetrics {
parts = append(parts, "buckets="+fingerprintHeatmapBucketing(q.spec.BucketOptions.ToHeatmapBucketing()))
}
// Add filter if present
if q.spec.Filter != nil && q.spec.Filter.Expression != "" {
parts = append(parts, fmt.Sprintf("filter=%s", q.spec.Filter.Expression))

View File

@@ -191,6 +191,14 @@ func postProcessBuilderQuery[T any](
req *qbtypes.QueryRangeRequest,
) *qbtypes.Result {
// A disabled query feeds a formula as a time series, so only an enabled one
// came back bucketed.
if req.RequestType.CountsIntoBuckets() && !query.Disabled {
if tsData, ok := result.Value.(*qbtypes.TimeSeriesData); ok {
resolveHeatmapBucketAxis(tsData, query.BucketOptions.ToHeatmapBucketing())
}
}
// the statement's top-N already skipped offset groups by their whole-window value; only reorder here
result = q.applySeriesLimit(result, query.Limit, 0, query.Order)

View File

@@ -257,27 +257,30 @@ func (q *querier) buildQueries(
if !ok {
return nil, nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid AI builder query spec %T", query.Spec)
}
requestType := requestTypeForQuery(req.RequestType, spec.Disabled)
spec.ShiftBy = extractShiftFromBuilderQuery(spec)
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType)
bq := newBuilderQuery(q.logger, q.telemetryStore, orgID, q.aiTraceStmtBuilder, query.Type, spec, timeRange, req.RequestType, tmplVars, builderConfig{})
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, requestType)
bq := newBuilderQuery(q.logger, q.telemetryStore, orgID, q.aiTraceStmtBuilder, query.Type, spec, timeRange, requestType, tmplVars, builderConfig{})
queries[spec.Name] = bq
steps[spec.Name] = spec.StepInterval
case qbtypes.QueryTypeBuilder:
switch spec := query.Spec.(type) {
case qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]:
requestType := requestTypeForQuery(req.RequestType, spec.Disabled)
spec.ShiftBy = extractShiftFromBuilderQuery(spec)
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType)
bq := newBuilderQuery(q.logger, q.telemetryStore, orgID, q.traceStmtBuilder, query.Type, spec, timeRange, req.RequestType, tmplVars, builderConfig{})
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, requestType)
bq := newBuilderQuery(q.logger, q.telemetryStore, orgID, q.traceStmtBuilder, query.Type, spec, timeRange, requestType, tmplVars, builderConfig{})
queries[spec.Name] = bq
steps[spec.Name] = spec.StepInterval
case qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]:
requestType := requestTypeForQuery(req.RequestType, spec.Disabled)
spec.ShiftBy = extractShiftFromBuilderQuery(spec)
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType)
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, requestType)
stmtBuilder := q.logStmtBuilder
if spec.Source == telemetrytypes.SourceAudit {
stmtBuilder = q.auditStmtBuilder
}
bq := newBuilderQuery(q.logger, q.telemetryStore, orgID, stmtBuilder, query.Type, spec, timeRange, req.RequestType, tmplVars, q.builderConfig)
bq := newBuilderQuery(q.logger, q.telemetryStore, orgID, stmtBuilder, query.Type, spec, timeRange, requestType, tmplVars, q.builderConfig)
queries[spec.Name] = bq
steps[spec.Name] = spec.StepInterval
case qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]:
@@ -288,13 +291,7 @@ func (q *querier) buildQueries(
if missingMetricQuerySet[spec.Name] {
continue
}
requestType := req.RequestType
if requestType.CountsIntoBuckets() && spec.Disabled {
// A disabled query in a heatmap request feeds a formula, and the
// formula converts time series into heatmap data, so its inputs
// run as time series queries.
requestType = qbtypes.RequestTypeTimeSeries
}
requestType := requestTypeForQuery(req.RequestType, spec.Disabled)
spec.ShiftBy = extractShiftFromBuilderQuery(spec)
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, requestType)
var bq *builderQuery[qbtypes.MetricAggregation]
@@ -1196,6 +1193,17 @@ func clampStep(qe *qbtypes.QueryEnvelope, recommended, min uint64, warnings *[]s
}
}
// requestTypeForQuery downgrades a disabled query in a heatmap request to a
// time series one. A disabled query feeds a formula rather than the chart, and
// the formula is what turns time series into cells, so its inputs state no
// bucket axis of their own.
func requestTypeForQuery(requestType qbtypes.RequestType, disabled bool) qbtypes.RequestType {
if requestType.CountsIntoBuckets() && disabled {
return qbtypes.RequestTypeTimeSeries
}
return requestType
}
// extractShiftFromBuilderQuery extracts the shift value from timeShift function if present.
func extractShiftFromBuilderQuery[T any](spec qbtypes.QueryBuilderQuery[T]) int64 {
for _, fn := range spec.Functions {

136
pkg/querybuilder/heatmap.go Normal file
View File

@@ -0,0 +1,136 @@
package querybuilder
import (
"context"
"fmt"
"math"
"strconv"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/huandu/go-sqlbuilder"
)
// heatmapBucketValueAlias names the value a heatmap's bounds are cut from. The
// bounds read it several times, so it is declared once as a WITH expression
// alias rather than repeated.
const heatmapBucketValueAlias = "__bucket_value"
// RenderHeatmapBucketExprs renders the bucket (min, max] that valueExpr falls
// in. Only the bucket under everything the axis covers is open below, and only
// the one over it is open above.
//
// valueExpr is repeated several times in each expression, so it has to be free
// of placeholders — HeatmapBucketColumns passes an alias for that reason.
func RenderHeatmapBucketExprs(bucketing qbtypes.HeatmapBucketing, valueExpr string) (minExpr, maxExpr string) {
if bucketing.Kind == qbtypes.BucketsKindLinear {
return renderLinearBucketExprs(bucketing, valueExpr)
}
return renderLogBucketExprs(valueExpr)
}
func renderLinearBucketExprs(bucketing qbtypes.HeatmapBucketing, valueExpr string) (string, string) {
maxValue := FormatHeatmapFloat(bucketing.MaxValue)
numBuckets := strconv.Itoa(bucketing.NumBuckets)
index := fmt.Sprintf("least(greatest(ceil(%s * %s / %s), 1), %s)", valueExpr, numBuckets, maxValue, numBuckets)
minExpr := fmt.Sprintf(
"multiIf(%s <= 0, toFloat64('-Inf'), %s > %s, toFloat64(%s), (%s - 1) * %s / %s)",
valueExpr, valueExpr, maxValue, maxValue, index, maxValue, numBuckets,
)
maxExpr := fmt.Sprintf(
"multiIf(%s <= 0, toFloat64(0), %s > %s, toFloat64('+Inf'), %s * %s / %s)",
valueExpr, valueExpr, maxValue, index, maxValue, numBuckets,
)
return minExpr, maxExpr
}
// ClickHouse buckets at MaxLogScale whatever HeatmapBucketing.LogScale asks for;
// postprocessing folds the axis down afterwards.
func renderLogBucketExprs(valueExpr string) (string, string) {
bucketsPerDoubling := FormatHeatmapFloat(math.Exp2(qbtypes.MaxLogScale))
lowest := FormatHeatmapFloat(qbtypes.MinLogUpperBound)
highest := FormatHeatmapFloat(qbtypes.MaxLogUpperBound)
minExpr := fmt.Sprintf(
"multiIf(%s <= 0, toFloat64('-Inf'), %s <= %s, toFloat64(0), %s > %s, toFloat64(%s), pow(2, (ceil(log2(%s) * %s) - 1) / %s))",
valueExpr, valueExpr, lowest, valueExpr, highest, highest, valueExpr, bucketsPerDoubling, bucketsPerDoubling,
)
maxExpr := fmt.Sprintf(
"multiIf(%s <= 0, toFloat64(0), %s <= %s, %s, %s > %s, toFloat64('+Inf'), pow(2, ceil(log2(%s) * %s) / %s))",
valueExpr, valueExpr, lowest, lowest, valueExpr, highest, valueExpr, bucketsPerDoubling, bucketsPerDoubling,
)
return minExpr, maxExpr
}
// FormatHeatmapFloat renders a float64 as the shortest literal that reads back
// as the same value, so an upper bound computed from it is identical on every
// row.
func FormatHeatmapFloat(v float64) string {
return strconv.FormatFloat(v, 'g', -1, 64)
}
// HeatmapFieldMayBeAbsent reports whether some row can lack key. A key that
// resolves to a table column every row carries, such as a span's duration,
// cannot, as the column is read first, and an EXISTS on it would drop its
// zeros instead.
func HeatmapFieldMayBeAbsent(
ctx context.Context,
info qbtypes.QueryInfo,
storage qbtypes.Storage,
key *telemetrytypes.TelemetryFieldKey,
keys map[string][]*telemetrytypes.TelemetryFieldKey,
) (bool, error) {
resolved, err := Resolve(ctx, info, storage, key, qbtypes.FilterOperatorUnknown, nil, keys)
if err != nil {
return false, err
}
for _, field := range resolved.Fields {
read, err := LogicalRead(ctx, info, storage, field)
if err != nil {
return false, err
}
if read.WhenAbsent == qbtypes.AlwaysPresent {
return false, nil
}
}
return true, nil
}
// HeatmapBucketColumns adds the bucket a row's value falls in to sb's select
// list, as the (min, max] pair a heatmap statement reports. key is the field
// the axis is cut from, already adjusted for context and data type.
//
// The bounds read the value through an expression alias rather than repeating
// it, so the returned fragment has to be declared alongside the caller's CTEs.
// It carries no arguments of its own.
//
// A row without a numeric value counts as 0.
func HeatmapBucketColumns(
ctx context.Context,
info qbtypes.QueryInfo,
storage qbtypes.Storage,
sb *sqlbuilder.SelectBuilder,
key *telemetrytypes.TelemetryFieldKey,
bucketOptions *qbtypes.BucketOptions,
keys map[string][]*telemetrytypes.TelemetryFieldKey,
) (valueFragment string, groupByAliases []string, warnings []string, err error) {
// Resolved directly rather than through ResolveColumn: a key nothing carries
// puts every row at 0, and the warning is the only thing that says why.
resolved, err := Resolve(ctx, info, storage, key, qbtypes.FilterOperatorUnknown, nil, keys)
if err != nil {
return "", nil, nil, err
}
valueExpr, err := Column(ctx, info, storage, resolved, telemetrytypes.FieldDataTypeFloat64)
if err != nil {
return "", nil, nil, err
}
bucketMin, bucketMax := RenderHeatmapBucketExprs(bucketOptions.ToHeatmapBucketing(), heatmapBucketValueAlias)
sb.SelectMore(sqlbuilder.Escape(fmt.Sprintf("%s AS %s", bucketMin, qbtypes.HeatmapBucketMinColumn)))
sb.SelectMore(sqlbuilder.Escape(fmt.Sprintf("%s AS %s", bucketMax, qbtypes.HeatmapBucketMaxColumn)))
valueFragment = fmt.Sprintf("ifNull(%s, 0) AS %s", valueExpr, heatmapBucketValueAlias)
return valueFragment, []string{qbtypes.HeatmapBucketMinColumn, qbtypes.HeatmapBucketMaxColumn}, resolved.Warnings, nil
}

View File

@@ -110,6 +110,8 @@ func (b *auditQueryStatementBuilder) Build(
stmt, err = b.buildListQuery(ctx, orgID, q, query, start, end, keys, variables)
case qbtypes.RequestTypeTimeSeries:
stmt, err = b.buildTimeSeriesQuery(ctx, orgID, q, query, start, end, keys, variables)
case qbtypes.RequestTypeHeatmap, qbtypes.RequestTypeDistribution:
stmt, err = b.buildHeatmapTimeSeriesQuery(ctx, orgID, q, query, start, end, keys, variables)
case qbtypes.RequestTypeScalar:
stmt, err = b.buildScalarQuery(ctx, orgID, q, query, start, end, keys, false, variables)
default:
@@ -322,6 +324,48 @@ func (b *auditQueryStatementBuilder) buildTimeSeriesQuery(
start, end uint64,
keys map[string][]*telemetrytypes.TelemetryFieldKey,
variables map[string]qbtypes.VariableItem,
) (*qbtypes.Statement, error) {
return b.buildTimeSeriesOrHeatmapQuery(ctx, orgID, sb, query, start, end, keys, variables, nil)
}
// buildHeatmapTimeSeriesQuery counts rows per (time bucket, group, value
// bucket) of the field its aggregation names.
func (b *auditQueryStatementBuilder) buildHeatmapTimeSeriesQuery(
ctx context.Context,
orgID valuer.UUID,
sb *sqlbuilder.SelectBuilder,
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation],
start, end uint64,
keys map[string][]*telemetrytypes.TelemetryFieldKey,
variables map[string]qbtypes.VariableItem,
) (*qbtypes.Statement, error) {
heatmapField, countQuery, err := qbtypes.HeatmapCountQuery(query)
if err != nil {
return nil, err
}
info := querybuilder.NewQueryInfo(ctx, orgID, b.fl, telemetrytypes.SignalLogs, nil, start, end)
mayBeAbsent, err := querybuilder.HeatmapFieldMayBeAbsent(ctx, info, b.storage, heatmapField, keys)
if err != nil {
return nil, err
}
if mayBeAbsent {
countQuery = qbtypes.RequireHeatmapField(countQuery, *heatmapField)
}
return b.buildTimeSeriesOrHeatmapQuery(ctx, orgID, sb, countQuery, start, end, keys, variables, heatmapField)
}
// buildTimeSeriesOrHeatmapQuery builds one row per (time bucket, group). A
// non-nil heatmapField makes it a heatmap instead: the bucket a row's
// heatmapField falls in joins the group keys, so a row is one cell.
func (b *auditQueryStatementBuilder) buildTimeSeriesOrHeatmapQuery(
ctx context.Context,
orgID valuer.UUID,
sb *sqlbuilder.SelectBuilder,
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation],
start, end uint64,
keys map[string][]*telemetrytypes.TelemetryFieldKey,
variables map[string]qbtypes.VariableItem,
heatmapField *telemetrytypes.TelemetryFieldKey,
) (*qbtypes.Statement, error) {
var (
cteFragments []string
@@ -357,6 +401,17 @@ func (b *auditQueryStatementBuilder) buildTimeSeriesQuery(
fieldNames = append(fieldNames, sqlbuilder.Escape(clickhousesql.Identifier(gb.Name)))
}
var bucketAliases []string
if heatmapField != nil {
valueFragment, aliases, warnings, err := querybuilder.HeatmapBucketColumns(ctx, info, b.storage, sb, heatmapField, query.BucketOptions, keys)
if err != nil {
return nil, err
}
cteFragments = append(cteFragments, valueFragment)
bucketAliases = aliases
columnWarnings = append(columnWarnings, warnings...)
}
allAggChArgs := make([]any, 0)
for i, agg := range query.Aggregations {
rewritten, chArgs, aggWarnings, err := b.aggExprRewriter.Rewrite(ctx, orgID, start, end, agg.Expression, uint64(query.StepInterval.Seconds()), keys)
@@ -393,6 +448,7 @@ func (b *auditQueryStatementBuilder) buildTimeSeriesQuery(
sb.GroupBy("ts")
sb.GroupBy(querybuilder.GroupByKeys(query.GroupBy)...)
sb.GroupBy(bucketAliases...)
if query.Having != nil && query.Having.Expression != "" {
rewriter := querybuilder.NewHavingExpressionRewriter()
rewrittenExpr, err := rewriter.RewriteForLogs(query.Having.Expression, query.Aggregations)
@@ -420,6 +476,7 @@ func (b *auditQueryStatementBuilder) buildTimeSeriesQuery(
} else {
sb.GroupBy("ts")
sb.GroupBy(querybuilder.GroupByKeys(query.GroupBy)...)
sb.GroupBy(bucketAliases...)
if query.Having != nil && query.Having.Expression != "" {
rewriter := querybuilder.NewHavingExpressionRewriter()
rewrittenExpr, err := rewriter.RewriteForLogs(query.Having.Expression, query.Aggregations)

View File

@@ -13,6 +13,7 @@ import (
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes/telemetrytypestest"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -34,16 +35,15 @@ func auditFieldKeyMap() map[string][]*telemetrytypes.TelemetryFieldKey {
return map[string][]*telemetrytypes.TelemetryFieldKey{
"service.name": {key("service.name", res, str, false)},
"signoz.audit.action": {key("signoz.audit.action", attr, str, true)},
"signoz.audit.verb": {key("signoz.audit.verb", attr, str, true)},
"signoz.audit.outcome": {key("signoz.audit.outcome", attr, str, true)},
"signoz.audit.principal.email": {key("signoz.audit.principal.email", attr, str, true)},
"signoz.audit.principal.id": {key("signoz.audit.principal.id", attr, str, true)},
"signoz.audit.principal.type": {key("signoz.audit.principal.type", attr, str, true)},
"signoz.audit.resource.kind": {key("signoz.audit.resource.kind", res, str, false)},
"signoz.audit.resource.id": {key("signoz.audit.resource.id", res, str, false)},
"signoz.principal.email": {key("signoz.principal.email", attr, str, true)},
"signoz.principal.id": {key("signoz.principal.id", attr, str, true)},
"signoz.principal.type": {key("signoz.principal.type", attr, str, true)},
"signoz.resource.kind": {key("signoz.resource.kind", res, str, false)},
"signoz.resource.id": {key("signoz.resource.id", res, str, false)},
"signoz.audit.action_category": {key("signoz.audit.action_category", attr, str, false)},
"signoz.audit.error.type": {key("signoz.audit.error.type", attr, str, false)},
"signoz.audit.error.code": {key("signoz.audit.error.code", attr, str, false)},
"exception.type": {key("exception.type", attr, str, false)},
"http.request.method": {key("http.request.method", attr, str, false)},
"http.response.status_code": {key("http.response.status_code", attr, i64, false)},
}
@@ -87,12 +87,12 @@ func TestStatementBuilder(t *testing.T) {
Signal: telemetrytypes.SignalLogs,
Source: telemetrytypes.SourceAudit,
Filter: &qbtypes.Filter{
Expression: "signoz.audit.principal.id = '019a-1234-abcd-5678'",
Expression: "signoz.principal.id = '019a-1234-abcd-5678'",
},
Limit: 100,
},
expected: qbtypes.Statement{
Query: "SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, event_name, attributes_string, attributes_number, attributes_bool, resource, scope_string FROM signoz_audit.distributed_logs WHERE (`attribute_string_signoz$$audit$$principal$$id` = ? AND `attribute_string_signoz$$audit$$principal$$id_exists` = true) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Query: "SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, event_name, attributes_string, attributes_number, attributes_bool, resource, scope_string FROM signoz_audit.distributed_logs WHERE (`attribute_string_signoz$$principal$$id` = ? AND `attribute_string_signoz$$principal$$id_exists` = true) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"019a-1234-abcd-5678", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 100},
},
},
@@ -121,13 +121,13 @@ func TestStatementBuilder(t *testing.T) {
Signal: telemetrytypes.SignalLogs,
Source: telemetrytypes.SourceAudit,
Filter: &qbtypes.Filter{
Expression: "signoz.audit.resource.kind = 'dashboard' AND signoz.audit.resource.id = '019b-5678-efgh-9012'",
Expression: "signoz.resource.kind = 'dashboard' AND signoz.resource.id = '019b-5678-efgh-9012'",
},
Limit: 100,
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_audit.distributed_logs_resource WHERE ((simpleJSONExtractString(labels, 'signoz.audit.resource.kind') = ? AND labels LIKE ? AND labels LIKE ?) AND (simpleJSONExtractString(labels, 'signoz.audit.resource.id') = ? AND labels LIKE ? AND labels LIKE ?)) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, event_name, attributes_string, attributes_number, attributes_bool, resource, scope_string FROM signoz_audit.distributed_logs WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"dashboard", "%signoz.audit.resource.kind%", "%signoz.audit.resource.kind\":\"dashboard%", "019b-5678-efgh-9012", "%signoz.audit.resource.id%", "%signoz.audit.resource.id\":\"019b-5678-efgh-9012%", uint64(1747945619), uint64(1747983448), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 100},
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_audit.distributed_logs_resource WHERE ((simpleJSONExtractString(labels, 'signoz.resource.kind') = ? AND labels LIKE ? AND labels LIKE ?) AND (simpleJSONExtractString(labels, 'signoz.resource.id') = ? AND labels LIKE ? AND labels LIKE ?)) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, event_name, attributes_string, attributes_number, attributes_bool, resource, scope_string FROM signoz_audit.distributed_logs WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"dashboard", "%signoz.resource.kind%", "%signoz.resource.kind\":\"dashboard%", "019b-5678-efgh-9012", "%signoz.resource.id%", "%signoz.resource.id\":\"019b-5678-efgh-9012%", uint64(1747945619), uint64(1747983448), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 100},
},
},
// List: all dashboard deletions (compliance — resource.kind + action AND)
@@ -138,13 +138,13 @@ func TestStatementBuilder(t *testing.T) {
Signal: telemetrytypes.SignalLogs,
Source: telemetrytypes.SourceAudit,
Filter: &qbtypes.Filter{
Expression: "signoz.audit.resource.kind = 'dashboard' AND signoz.audit.action = 'delete'",
Expression: "signoz.resource.kind = 'dashboard' AND signoz.audit.verb = 'delete'",
},
Limit: 100,
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_audit.distributed_logs_resource WHERE (simpleJSONExtractString(labels, 'signoz.audit.resource.kind') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, event_name, attributes_string, attributes_number, attributes_bool, resource, scope_string FROM signoz_audit.distributed_logs WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND (`attribute_string_signoz$$audit$$action` = ? AND `attribute_string_signoz$$audit$$action_exists` = true) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"dashboard", "%signoz.audit.resource.kind%", "%signoz.audit.resource.kind\":\"dashboard%", uint64(1747945619), uint64(1747983448), "delete", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 100},
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_audit.distributed_logs_resource WHERE (simpleJSONExtractString(labels, 'signoz.resource.kind') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, event_name, attributes_string, attributes_number, attributes_bool, resource, scope_string FROM signoz_audit.distributed_logs WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND (`attribute_string_signoz$$audit$$verb` = ? AND `attribute_string_signoz$$audit$$verb_exists` = true) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"dashboard", "%signoz.resource.kind%", "%signoz.resource.kind\":\"dashboard%", uint64(1747945619), uint64(1747983448), "delete", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 100},
},
},
// List: all actions by service accounts (materialized principal.type)
@@ -155,12 +155,12 @@ func TestStatementBuilder(t *testing.T) {
Signal: telemetrytypes.SignalLogs,
Source: telemetrytypes.SourceAudit,
Filter: &qbtypes.Filter{
Expression: "signoz.audit.principal.type = 'service_account'",
Expression: "signoz.principal.type = 'service_account'",
},
Limit: 100,
},
expected: qbtypes.Statement{
Query: "SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, event_name, attributes_string, attributes_number, attributes_bool, resource, scope_string FROM signoz_audit.distributed_logs WHERE (`attribute_string_signoz$$audit$$principal$$type` = ? AND `attribute_string_signoz$$audit$$principal$$type_exists` = true) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Query: "SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, event_name, attributes_string, attributes_number, attributes_bool, resource, scope_string FROM signoz_audit.distributed_logs WHERE (`attribute_string_signoz$$principal$$type` = ? AND `attribute_string_signoz$$principal$$type_exists` = true) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"service_account", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 100},
},
},
@@ -173,14 +173,14 @@ func TestStatementBuilder(t *testing.T) {
Source: telemetrytypes.SourceAudit,
StepInterval: qbtypes.Step{Duration: 60 * time.Second},
Filter: &qbtypes.Filter{
Expression: "signoz.audit.outcome = 'failure' AND signoz.audit.action = 'update'",
Expression: "signoz.audit.outcome = 'failure' AND signoz.audit.verb = 'update'",
},
Aggregations: []qbtypes.LogAggregation{
{Expression: "count()"},
},
},
expected: qbtypes.Statement{
Query: "SELECT count() AS __result_0 FROM signoz_audit.distributed_logs WHERE ((`attribute_string_signoz$$audit$$outcome` = ? AND `attribute_string_signoz$$audit$$outcome_exists` = true) AND (`attribute_string_signoz$$audit$$action` = ? AND `attribute_string_signoz$$audit$$action_exists` = true)) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? ORDER BY __result_0 DESC",
Query: "SELECT count() AS __result_0 FROM signoz_audit.distributed_logs WHERE ((`attribute_string_signoz$$audit$$outcome` = ? AND `attribute_string_signoz$$audit$$outcome_exists` = true) AND (`attribute_string_signoz$$audit$$verb` = ? AND `attribute_string_signoz$$audit$$verb_exists` = true)) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? ORDER BY __result_0 DESC",
Args: []any{"failure", "update", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448)},
},
},
@@ -199,12 +199,12 @@ func TestStatementBuilder(t *testing.T) {
Expression: "signoz.audit.outcome = 'failure'",
},
GroupBy: []qbtypes.GroupByKey{
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "signoz.audit.principal.email"}},
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "signoz.principal.email"}},
},
Limit: 5,
},
expected: qbtypes.Statement{
Query: "WITH __limit_cte AS (SELECT toString(multiIf(`attribute_string_signoz$$audit$$principal$$email_exists` = true, `attribute_string_signoz$$audit$$principal$$email`, NULL)) AS `signoz.audit.principal.email`, count() AS __result_0 FROM signoz_audit.distributed_logs WHERE (`attribute_string_signoz$$audit$$outcome` = ? AND `attribute_string_signoz$$audit$$outcome_exists` = true) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY `signoz.audit.principal.email` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 60 SECOND) AS ts, toString(multiIf(`attribute_string_signoz$$audit$$principal$$email_exists` = true, `attribute_string_signoz$$audit$$principal$$email`, NULL)) AS `signoz.audit.principal.email`, count() AS __result_0 FROM signoz_audit.distributed_logs WHERE (`attribute_string_signoz$$audit$$outcome` = ? AND `attribute_string_signoz$$audit$$outcome_exists` = true) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? AND (`signoz.audit.principal.email`) GLOBAL IN (SELECT `signoz.audit.principal.email` FROM __limit_cte) GROUP BY ts, `signoz.audit.principal.email`",
Query: "WITH __limit_cte AS (SELECT toString(multiIf(`attribute_string_signoz$$principal$$email_exists` = true, `attribute_string_signoz$$principal$$email`, NULL)) AS `signoz.principal.email`, count() AS __result_0 FROM signoz_audit.distributed_logs WHERE (`attribute_string_signoz$$audit$$outcome` = ? AND `attribute_string_signoz$$audit$$outcome_exists` = true) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY `signoz.principal.email` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 60 SECOND) AS ts, toString(multiIf(`attribute_string_signoz$$principal$$email_exists` = true, `attribute_string_signoz$$principal$$email`, NULL)) AS `signoz.principal.email`, count() AS __result_0 FROM signoz_audit.distributed_logs WHERE (`attribute_string_signoz$$audit$$outcome` = ? AND `attribute_string_signoz$$audit$$outcome_exists` = true) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? AND (`signoz.principal.email`) GLOBAL IN (SELECT `signoz.principal.email` FROM __limit_cte) GROUP BY ts, `signoz.principal.email`",
Args: []any{"failure", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 5, "failure", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448)},
},
},
@@ -224,3 +224,59 @@ func TestStatementBuilder(t *testing.T) {
})
}
}
func TestStatementBuilderHeatmap(t *testing.T) {
statementBuilder := newTestAuditStatementBuilder(t)
testCases := []struct {
name string
requestType qbtypes.RequestType
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]
expected qbtypes.Statement
}{
{
name: "AttributeField_GroupByMaterialized_LinearAxis_RequiresField",
requestType: qbtypes.RequestTypeHeatmap,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
Source: telemetrytypes.SourceAudit,
StepInterval: qbtypes.Step{Duration: 60 * time.Second},
Aggregations: []qbtypes.LogAggregation{{Expression: "http.response.status_code"}},
Filter: &qbtypes.Filter{Expression: "signoz.resource.kind = 'dashboard'"},
GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "signoz.audit.verb"}}},
BucketOptions: &qbtypes.BucketOptions{Kind: qbtypes.BucketsKindLinear, Spec: qbtypes.LinearBucketsSpec{MaxValue: 500, NumBuckets: 5}},
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_audit.distributed_logs_resource WHERE ((simpleJSONExtractString(labels, 'signoz.resource.kind') = ? AND labels LIKE ? AND labels LIKE ?)) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), ifNull(multiIf(mapContains(attributes_number, 'http.response.status_code'), toFloat64(attributes_number['http.response.status_code']), NULL), 0) AS __bucket_value SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 60 SECOND) AS ts, toString(multiIf(`attribute_string_signoz$$audit$$verb_exists` = true, `attribute_string_signoz$$audit$$verb`, NULL)) AS `signoz.audit.verb`, multiIf(__bucket_value <= 0, toFloat64('-Inf'), __bucket_value > 500, toFloat64(500), (least(greatest(ceil(__bucket_value * 5 / 500), 1), 5) - 1) * 500 / 5) AS __bucket_min, multiIf(__bucket_value <= 0, toFloat64(0), __bucket_value > 500, toFloat64('+Inf'), least(greatest(ceil(__bucket_value * 5 / 500), 1), 5) * 500 / 5) AS __bucket_max, count() AS __result_0 FROM signoz_audit.distributed_logs WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND mapContains(attributes_number, 'http.response.status_code') AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY ts, `signoz.audit.verb`, __bucket_min, __bucket_max",
Args: []any{"dashboard", "%signoz.resource.kind%", "%signoz.resource.kind\":\"dashboard%", uint64(1747945619), uint64(1747983448), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448)},
},
},
{
name: "Distribution_AttributeField_SameStatementAsHeatmap",
requestType: qbtypes.RequestTypeDistribution,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
Source: telemetrytypes.SourceAudit,
StepInterval: qbtypes.Step{Duration: 60 * time.Second},
Aggregations: []qbtypes.LogAggregation{{Expression: "http.response.status_code"}},
Filter: &qbtypes.Filter{Expression: "signoz.resource.kind = 'dashboard'"},
GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "signoz.audit.verb"}}},
BucketOptions: &qbtypes.BucketOptions{Kind: qbtypes.BucketsKindLinear, Spec: qbtypes.LinearBucketsSpec{MaxValue: 500, NumBuckets: 5}},
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_audit.distributed_logs_resource WHERE ((simpleJSONExtractString(labels, 'signoz.resource.kind') = ? AND labels LIKE ? AND labels LIKE ?)) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), ifNull(multiIf(mapContains(attributes_number, 'http.response.status_code'), toFloat64(attributes_number['http.response.status_code']), NULL), 0) AS __bucket_value SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 60 SECOND) AS ts, toString(multiIf(`attribute_string_signoz$$audit$$verb_exists` = true, `attribute_string_signoz$$audit$$verb`, NULL)) AS `signoz.audit.verb`, multiIf(__bucket_value <= 0, toFloat64('-Inf'), __bucket_value > 500, toFloat64(500), (least(greatest(ceil(__bucket_value * 5 / 500), 1), 5) - 1) * 500 / 5) AS __bucket_min, multiIf(__bucket_value <= 0, toFloat64(0), __bucket_value > 500, toFloat64('+Inf'), least(greatest(ceil(__bucket_value * 5 / 500), 1), 5) * 500 / 5) AS __bucket_max, count() AS __result_0 FROM signoz_audit.distributed_logs WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND mapContains(attributes_number, 'http.response.status_code') AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY ts, `signoz.audit.verb`, __bucket_min, __bucket_max",
Args: []any{"dashboard", "%signoz.resource.kind%", "%signoz.resource.kind\":\"dashboard%", uint64(1747945619), uint64(1747983448), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448)},
},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
statement, err := statementBuilder.Build(context.Background(), valuer.UUID{}, 1747947419000, 1747983448000, testCase.requestType, testCase.query, nil)
require.NoError(t, err)
assert.Equal(t, testCase.expected.Query, statement.Query)
assert.Equal(t, testCase.expected.Args, statement.Args)
assert.Equal(t, testCase.expected.Warnings, statement.Warnings)
})
}
}

View File

@@ -142,6 +142,8 @@ func (b *logQueryStatementBuilder) Build(
stmt, err = b.buildListQuery(ctx, orgID, q, query, start, end, keys, variables)
case qbtypes.RequestTypeTimeSeries:
stmt, err = b.buildTimeSeriesQuery(ctx, orgID, q, query, start, end, keys, variables)
case qbtypes.RequestTypeHeatmap, qbtypes.RequestTypeDistribution:
stmt, err = b.buildHeatmapTimeSeriesQuery(ctx, orgID, q, query, start, end, keys, variables)
case qbtypes.RequestTypeScalar:
stmt, err = b.buildScalarQuery(ctx, orgID, q, query, start, end, keys, false, variables)
default:
@@ -442,6 +444,48 @@ func (b *logQueryStatementBuilder) buildTimeSeriesQuery(
keys map[string][]*telemetrytypes.TelemetryFieldKey,
variables map[string]qbtypes.VariableItem,
) (*qbtypes.Statement, error) {
return b.buildTimeSeriesOrHeatmapQuery(ctx, orgID, sb, query, start, end, keys, variables, nil)
}
// buildHeatmapTimeSeriesQuery counts rows per (time bucket, group, value
// bucket) of the field its aggregation names.
func (b *logQueryStatementBuilder) buildHeatmapTimeSeriesQuery(
ctx context.Context,
orgID valuer.UUID,
sb *sqlbuilder.SelectBuilder,
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation],
start, end uint64,
keys map[string][]*telemetrytypes.TelemetryFieldKey,
variables map[string]qbtypes.VariableItem,
) (*qbtypes.Statement, error) {
heatmapField, countQuery, err := qbtypes.HeatmapCountQuery(query)
if err != nil {
return nil, err
}
info := querybuilder.NewQueryInfo(ctx, orgID, b.fl, telemetrytypes.SignalLogs, nil, start, end)
mayBeAbsent, err := querybuilder.HeatmapFieldMayBeAbsent(ctx, info, b.storage, columnKey(heatmapField), keys)
if err != nil {
return nil, err
}
if mayBeAbsent {
countQuery = qbtypes.RequireHeatmapField(countQuery, *heatmapField)
}
return b.buildTimeSeriesOrHeatmapQuery(ctx, orgID, sb, countQuery, start, end, keys, variables, heatmapField)
}
// buildTimeSeriesOrHeatmapQuery builds one row per (time bucket, group). A
// non-nil heatmapField makes it a heatmap instead: the bucket a row's
// heatmapField falls in joins the group keys, so a row is one cell.
func (b *logQueryStatementBuilder) buildTimeSeriesOrHeatmapQuery(
ctx context.Context,
orgID valuer.UUID,
sb *sqlbuilder.SelectBuilder,
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation],
start, end uint64,
keys map[string][]*telemetrytypes.TelemetryFieldKey,
variables map[string]qbtypes.VariableItem,
heatmapField *telemetrytypes.TelemetryFieldKey,
) (*qbtypes.Statement, error) {
var (
cteFragments []string
@@ -481,6 +525,19 @@ func (b *logQueryStatementBuilder) buildTimeSeriesQuery(
fieldNames = append(fieldNames, sqlbuilder.Escape(fieldAlias))
}
var bucketAliases []string
if heatmapField != nil {
valueFragment, aliases, warnings, err := querybuilder.HeatmapBucketColumns(
ctx, info, b.storage, sb, columnKey(heatmapField), query.BucketOptions, keys,
)
if err != nil {
return nil, err
}
cteFragments = append(cteFragments, valueFragment)
bucketAliases = aliases
columnWarnings = append(columnWarnings, warnings...)
}
// Aggregations
allAggChArgs := make([]any, 0)
for i, agg := range query.Aggregations {
@@ -527,6 +584,7 @@ func (b *logQueryStatementBuilder) buildTimeSeriesQuery(
// Group by all dimensions
sb.GroupBy("ts")
sb.GroupBy(fieldNames...)
sb.GroupBy(bucketAliases...)
if query.Having != nil && query.Having.Expression != "" {
// Rewrite having expression to use SQL column names
rewriter := querybuilder.NewHavingExpressionRewriter()
@@ -561,6 +619,7 @@ func (b *logQueryStatementBuilder) buildTimeSeriesQuery(
} else {
sb.GroupBy("ts")
sb.GroupBy(fieldNames...)
sb.GroupBy(bucketAliases...)
if query.Having != nil && query.Having.Expression != "" {
rewriter := querybuilder.NewHavingExpressionRewriter()
rewrittenExpr, err := rewriter.RewriteForLogs(query.Having.Expression, query.Aggregations)

View File

@@ -19,6 +19,7 @@ import (
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes/telemetrytypestest"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -1271,3 +1272,119 @@ func newSkipResourceFingerprintLogsBuilder(
statementbuilder.Config{SkipResourceFingerprint: statementbuilder.SkipResourceFingerprint{Enabled: skipEnable, Threshold: threshold}},
)
}
func TestStatementBuilderHeatmap(t *testing.T) {
releaseTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)
startTs := uint64(releaseTime.UnixNano()) + uint64(24*time.Hour.Nanoseconds())
endTs := uint64(releaseTime.UnixNano()) + uint64(48*time.Hour.Nanoseconds())
testCases := []struct {
name string
requestType qbtypes.RequestType
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]
expected qbtypes.Statement
}{
{
name: "AttributeField_LogAxis_RequiresField",
requestType: qbtypes.RequestTypeHeatmap,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Aggregations: []qbtypes.LogAggregation{{Expression: "duration"}},
Filter: &qbtypes.Filter{Expression: "service.name = 'cartservice'"},
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE ((simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?)) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), ifNull(multiIf(mapContains(attributes_number, 'duration'), toFloat64(attributes_number['duration']), NULL), 0) AS __bucket_value SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 30 SECOND) AS ts, multiIf(__bucket_value <= 0, toFloat64('-Inf'), __bucket_value <= 2.3283064365386963e-10, toFloat64(0), __bucket_value > 1.8446744073709552e+19, toFloat64(1.8446744073709552e+19), pow(2, (ceil(log2(__bucket_value) * 16) - 1) / 16)) AS __bucket_min, multiIf(__bucket_value <= 0, toFloat64(0), __bucket_value <= 2.3283064365386963e-10, 2.3283064365386963e-10, __bucket_value > 1.8446744073709552e+19, toFloat64('+Inf'), pow(2, ceil(log2(__bucket_value) * 16) / 16)) AS __bucket_max, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND mapContains(attributes_number, 'duration') AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY ts, __bucket_min, __bucket_max",
Args: []any{"cartservice", "%service.name%", "%service.name\":\"cartservice%", uint64(1705397400), uint64(1705485600), "1705399200000000000", uint64(1705397400), "1705485600000000000", uint64(1705485600)},
},
},
{
name: "AttributeField_GroupByWithLimit_LinearAxis_RequiresFieldInLimit",
requestType: qbtypes.RequestTypeHeatmap,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
StepInterval: qbtypes.Step{Duration: 60 * time.Second},
Aggregations: []qbtypes.LogAggregation{{Expression: "http.status_code"}},
GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "service.name"}}},
Limit: 5,
BucketOptions: &qbtypes.BucketOptions{Kind: qbtypes.BucketsKindLinear, Spec: qbtypes.LinearBucketsSpec{MaxValue: 600, NumBuckets: 6}},
},
expected: qbtypes.Statement{
Query: "WITH ifNull(multiIf(mapContains(attributes_number, 'http.status_code'), toFloat64(attributes_number['http.status_code']), NULL), 0) AS __bucket_value, __limit_cte AS (SELECT toString(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, NULL)) AS `__GROUP_BY_KEY_0_service.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE mapContains(attributes_number, 'http.status_code') AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY `__GROUP_BY_KEY_0_service.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 60 SECOND) AS ts, toString(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, NULL)) AS `__GROUP_BY_KEY_0_service.name`, multiIf(__bucket_value <= 0, toFloat64('-Inf'), __bucket_value > 600, toFloat64(600), (least(greatest(ceil(__bucket_value * 6 / 600), 1), 6) - 1) * 600 / 6) AS __bucket_min, multiIf(__bucket_value <= 0, toFloat64(0), __bucket_value > 600, toFloat64('+Inf'), least(greatest(ceil(__bucket_value * 6 / 600), 1), 6) * 600 / 6) AS __bucket_max, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE mapContains(attributes_number, 'http.status_code') AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? AND (`__GROUP_BY_KEY_0_service.name`) GLOBAL IN (SELECT `__GROUP_BY_KEY_0_service.name` FROM __limit_cte) GROUP BY ts, `__GROUP_BY_KEY_0_service.name`, __bucket_min, __bucket_max",
Args: []any{"1705399200000000000", uint64(1705397400), "1705485600000000000", uint64(1705485600), 5, "1705399200000000000", uint64(1705397400), "1705485600000000000", uint64(1705485600)},
},
},
{
name: "QualifiedField_OrderedByAggregation_OrdersByCount",
requestType: qbtypes.RequestTypeHeatmap,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Aggregations: []qbtypes.LogAggregation{{Expression: "attribute.amount"}},
GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "service.name"}}},
Limit: 3,
Order: []qbtypes.OrderBy{{
Key: qbtypes.OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "amount", FieldContext: telemetrytypes.FieldContextAttribute}},
Direction: qbtypes.OrderDirectionAsc,
}},
},
expected: qbtypes.Statement{
Query: "WITH ifNull(multiIf(mapContains(attributes_number, 'amount'), toFloat64(attributes_number['amount']), NULL), 0) AS __bucket_value, __limit_cte AS (SELECT toString(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, NULL)) AS `__GROUP_BY_KEY_0_service.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE mapContains(attributes_number, 'amount') AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY `__GROUP_BY_KEY_0_service.name` ORDER BY __result_0 asc LIMIT ?) SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 30 SECOND) AS ts, toString(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, NULL)) AS `__GROUP_BY_KEY_0_service.name`, multiIf(__bucket_value <= 0, toFloat64('-Inf'), __bucket_value <= 2.3283064365386963e-10, toFloat64(0), __bucket_value > 1.8446744073709552e+19, toFloat64(1.8446744073709552e+19), pow(2, (ceil(log2(__bucket_value) * 16) - 1) / 16)) AS __bucket_min, multiIf(__bucket_value <= 0, toFloat64(0), __bucket_value <= 2.3283064365386963e-10, 2.3283064365386963e-10, __bucket_value > 1.8446744073709552e+19, toFloat64('+Inf'), pow(2, ceil(log2(__bucket_value) * 16) / 16)) AS __bucket_max, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE mapContains(attributes_number, 'amount') AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? AND (`__GROUP_BY_KEY_0_service.name`) GLOBAL IN (SELECT `__GROUP_BY_KEY_0_service.name` FROM __limit_cte) GROUP BY ts, `__GROUP_BY_KEY_0_service.name`, __bucket_min, __bucket_max ORDER BY ts desc",
Args: []any{"1705399200000000000", uint64(1705397400), "1705485600000000000", uint64(1705485600), 3, "1705399200000000000", uint64(1705397400), "1705485600000000000", uint64(1705485600)},
},
},
{
name: "IntrinsicColumn_NoExists",
requestType: qbtypes.RequestTypeHeatmap,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Aggregations: []qbtypes.LogAggregation{{Expression: "log.severity_number"}},
},
expected: qbtypes.Statement{
Query: "WITH ifNull(toFloat64(severity_number), 0) AS __bucket_value SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 30 SECOND) AS ts, multiIf(__bucket_value <= 0, toFloat64('-Inf'), __bucket_value <= 2.3283064365386963e-10, toFloat64(0), __bucket_value > 1.8446744073709552e+19, toFloat64(1.8446744073709552e+19), pow(2, (ceil(log2(__bucket_value) * 16) - 1) / 16)) AS __bucket_min, multiIf(__bucket_value <= 0, toFloat64(0), __bucket_value <= 2.3283064365386963e-10, 2.3283064365386963e-10, __bucket_value > 1.8446744073709552e+19, toFloat64('+Inf'), pow(2, ceil(log2(__bucket_value) * 16) / 16)) AS __bucket_max, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY ts, __bucket_min, __bucket_max",
Args: []any{"1705399200000000000", uint64(1705397400), "1705485600000000000", uint64(1705485600)},
},
},
{
name: "Distribution_AttributeField_SameStatementAsHeatmap",
requestType: qbtypes.RequestTypeDistribution,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Aggregations: []qbtypes.LogAggregation{{Expression: "duration"}},
Filter: &qbtypes.Filter{Expression: "service.name = 'cartservice'"},
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE ((simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?)) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), ifNull(multiIf(mapContains(attributes_number, 'duration'), toFloat64(attributes_number['duration']), NULL), 0) AS __bucket_value SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 30 SECOND) AS ts, multiIf(__bucket_value <= 0, toFloat64('-Inf'), __bucket_value <= 2.3283064365386963e-10, toFloat64(0), __bucket_value > 1.8446744073709552e+19, toFloat64(1.8446744073709552e+19), pow(2, (ceil(log2(__bucket_value) * 16) - 1) / 16)) AS __bucket_min, multiIf(__bucket_value <= 0, toFloat64(0), __bucket_value <= 2.3283064365386963e-10, 2.3283064365386963e-10, __bucket_value > 1.8446744073709552e+19, toFloat64('+Inf'), pow(2, ceil(log2(__bucket_value) * 16) / 16)) AS __bucket_max, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND mapContains(attributes_number, 'duration') AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY ts, __bucket_min, __bucket_max",
Args: []any{"cartservice", "%service.name%", "%service.name\":\"cartservice%", uint64(1705397400), uint64(1705485600), "1705399200000000000", uint64(1705397400), "1705485600000000000", uint64(1705485600)},
},
},
}
fl := flaggertest.New(t)
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
mockMetadataStore.KeysMap = logstelemetryschema.BuildCompleteFieldKeyMap(releaseTime)
storage := logstelemetryschema.NewStorage()
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalLogs)
statementBuilder := NewLogQueryStatementBuilder(
instrumentationtest.New().ToProviderSettings(),
mockMetadataStore,
storage,
aggExprRewriter,
logstelemetryschema.DefaultFullTextColumn,
fl,
nil,
statementbuilder.Config{SkipResourceFingerprint: statementbuilder.SkipResourceFingerprint{Enabled: false, Threshold: 100000}},
)
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
statement, err := statementBuilder.Build(context.Background(), valuer.UUID{}, startTs, endTs, testCase.requestType, testCase.query, nil)
require.NoError(t, err)
assert.Equal(t, testCase.expected.Query, statement.Query)
assert.Equal(t, testCase.expected.Args, statement.Args)
assert.Equal(t, testCase.expected.Warnings, statement.Warnings)
})
}
}

View File

@@ -4,14 +4,11 @@ import (
"context"
"fmt"
"log/slog"
"math"
"slices"
"strconv"
"strings"
"time"
"github.com/SigNoz/signoz/pkg/clickhousesql"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/querybuilder"
@@ -917,6 +914,10 @@ const (
heatmapValueAlias = "__result_0"
heatmapWindow = "__heatmap_window"
// The column __spatial_aggregation_cte carries the sample in, which is what
// the bucket axis is cut from.
heatmapValueColumn = "value"
)
func isHistogramBucket(k qbtypes.GroupByKey) bool { return k.Name == histogramBucketKey }
@@ -982,10 +983,7 @@ func buildValueHeatmapFinalSelect(
args []any,
query qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation],
) (*qbtypes.Statement, error) {
bucketMin, bucketMax, err := renderHeatmapBucketExprs(*query.Aggregations[0].HeatmapBucketing)
if err != nil {
return nil, err
}
bucketMin, bucketMax := querybuilder.RenderHeatmapBucketExprs(*query.Aggregations[0].HeatmapBucketing, heatmapValueColumn)
groupAliases := GroupByAliases(query.GroupBy)
@@ -1003,61 +1001,6 @@ func buildValueHeatmapFinalSelect(
return &qbtypes.Statement{Query: combined + q, Args: append(args, a...)}, nil
}
// renderHeatmapBucketExprs renders the bucket (min, max] that `value` falls in.
// Only the bucket under everything the axis covers is open below, and only the
// one over it is open above.
func renderHeatmapBucketExprs(bucketing qbtypes.HeatmapBucketing) (minExpr, maxExpr string, err error) {
switch bucketing.Kind {
case qbtypes.BucketsKindLinear:
return renderLinearBucketExprs(bucketing)
case qbtypes.BucketsKindLog:
return renderLogBucketExprs()
default:
return "", "", errors.NewInvalidInputf(errors.CodeInvalidInput,
"unsupported bucketsScaling %q for heatmap requests", bucketing.Kind.StringValue())
}
}
func renderLinearBucketExprs(bucketing qbtypes.HeatmapBucketing) (string, string, error) {
maxValue := formatFloat(bucketing.MaxValue)
numBuckets := strconv.Itoa(bucketing.NumBuckets)
index := fmt.Sprintf("least(greatest(ceil(value * %s / %s), 1), %s)", numBuckets, maxValue, numBuckets)
minExpr := fmt.Sprintf(
"multiIf(value <= 0, toFloat64('-Inf'), value > %s, toFloat64(%s), (%s - 1) * %s / %s)",
maxValue, maxValue, index, maxValue, numBuckets,
)
maxExpr := fmt.Sprintf(
"multiIf(value <= 0, toFloat64(0), value > %s, toFloat64('+Inf'), %s * %s / %s)",
maxValue, index, maxValue, numBuckets,
)
return minExpr, maxExpr, nil
}
// ClickHouse buckets at MaxLogScale whatever HeatmapBucketing.LogScale asks for;
// postprocessing folds the axis down afterwards.
func renderLogBucketExprs() (string, string, error) {
bucketsPerDoubling := formatFloat(math.Exp2(qbtypes.MaxLogScale))
lowest := formatFloat(qbtypes.MinLogUpperBound)
highest := formatFloat(qbtypes.MaxLogUpperBound)
minExpr := fmt.Sprintf(
"multiIf(value <= 0, toFloat64('-Inf'), value <= %s, toFloat64(0), value > %s, toFloat64(%s), pow(2, (ceil(log2(value) * %s) - 1) / %s))",
lowest, highest, highest, bucketsPerDoubling, bucketsPerDoubling,
)
maxExpr := fmt.Sprintf(
"multiIf(value <= 0, toFloat64(0), value <= %s, %s, value > %s, toFloat64('+Inf'), pow(2, ceil(log2(value) * %s) / %s))",
lowest, lowest, highest, bucketsPerDoubling, bucketsPerDoubling,
)
return minExpr, maxExpr, nil
}
// formatFloat renders a float64 as the shortest literal that reads back as the
// same value, so an upper bound computed from it is identical on every row.
func formatFloat(v float64) string {
return strconv.FormatFloat(v, 'g', -1, 64)
}
func GroupByColumnAlias(i int, name string) string {
if name == histogramBucketKey {
return clickhousesql.Identifier(histogramBucketKey)

View File

@@ -178,6 +178,8 @@ func (b *traceQueryStatementBuilder) Build(
return b.buildListQuery(ctx, orgID, q, query, start, end, keys, variables, isSelectFieldsEmpty)
case qbtypes.RequestTypeTimeSeries:
return b.buildTimeSeriesQuery(ctx, orgID, q, query, start, end, keys, variables)
case qbtypes.RequestTypeHeatmap, qbtypes.RequestTypeDistribution:
return b.buildHeatmapTimeSeriesQuery(ctx, orgID, q, query, start, end, keys, variables)
case qbtypes.RequestTypeScalar:
return b.buildScalarQuery(ctx, orgID, q, query, start, end, keys, variables, false, false)
case qbtypes.RequestTypeTrace:
@@ -570,6 +572,48 @@ func (b *traceQueryStatementBuilder) buildTimeSeriesQuery(
keys map[string][]*telemetrytypes.TelemetryFieldKey,
variables map[string]qbtypes.VariableItem,
) (*qbtypes.Statement, error) {
return b.buildTimeSeriesOrHeatmapQuery(ctx, orgID, sb, query, start, end, keys, variables, nil)
}
// buildHeatmapTimeSeriesQuery counts rows per (time bucket, group, value
// bucket) of the field its aggregation names.
func (b *traceQueryStatementBuilder) buildHeatmapTimeSeriesQuery(
ctx context.Context,
orgID valuer.UUID,
sb *sqlbuilder.SelectBuilder,
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
start, end uint64,
keys map[string][]*telemetrytypes.TelemetryFieldKey,
variables map[string]qbtypes.VariableItem,
) (*qbtypes.Statement, error) {
heatmapField, countQuery, err := qbtypes.HeatmapCountQuery(query)
if err != nil {
return nil, err
}
info := querybuilder.NewQueryInfo(ctx, orgID, b.fl, telemetrytypes.SignalTraces, nil, start, end)
mayBeAbsent, err := querybuilder.HeatmapFieldMayBeAbsent(ctx, info, b.storage, heatmapField, keys)
if err != nil {
return nil, err
}
if mayBeAbsent {
countQuery = qbtypes.RequireHeatmapField(countQuery, *heatmapField)
}
return b.buildTimeSeriesOrHeatmapQuery(ctx, orgID, sb, countQuery, start, end, keys, variables, heatmapField)
}
// buildTimeSeriesOrHeatmapQuery builds one row per (time bucket, group). A
// non-nil heatmapField makes it a heatmap instead: the bucket a row's
// heatmapField falls in joins the group keys, so a row is one cell.
func (b *traceQueryStatementBuilder) buildTimeSeriesOrHeatmapQuery(
ctx context.Context,
orgID valuer.UUID,
sb *sqlbuilder.SelectBuilder,
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
start, end uint64,
keys map[string][]*telemetrytypes.TelemetryFieldKey,
variables map[string]qbtypes.VariableItem,
heatmapField *telemetrytypes.TelemetryFieldKey,
) (*qbtypes.Statement, error) {
var (
cteFragments []string
@@ -610,6 +654,19 @@ func (b *traceQueryStatementBuilder) buildTimeSeriesQuery(
fieldNames = append(fieldNames, sqlbuilder.Escape(fieldAlias))
}
var bucketAliases []string
if heatmapField != nil {
valueFragment, aliases, warnings, err := querybuilder.HeatmapBucketColumns(
ctx, info, b.storage, sb, heatmapField, query.BucketOptions, keys,
)
if err != nil {
return nil, err
}
cteFragments = append(cteFragments, valueFragment)
bucketAliases = aliases
columnWarnings = append(columnWarnings, warnings...)
}
// Aggregations
allAggChArgs := make([]any, 0)
for i, agg := range query.Aggregations {
@@ -653,6 +710,7 @@ func (b *traceQueryStatementBuilder) buildTimeSeriesQuery(
// Group by all dimensions
sb.GroupBy("ts")
sb.GroupBy(fieldNames...)
sb.GroupBy(bucketAliases...)
if query.Having != nil && query.Having.Expression != "" {
rewriter := querybuilder.NewHavingExpressionRewriter()
rewrittenExpr, err := rewriter.RewriteForTraces(query.Having.Expression, query.Aggregations)
@@ -686,6 +744,7 @@ func (b *traceQueryStatementBuilder) buildTimeSeriesQuery(
} else {
sb.GroupBy("ts")
sb.GroupBy(fieldNames...)
sb.GroupBy(bucketAliases...)
if query.Having != nil && query.Having.Expression != "" {
rewriter := querybuilder.NewHavingExpressionRewriter()
rewrittenExpr, err := rewriter.RewriteForTraces(query.Having.Expression, query.Aggregations)

View File

@@ -2113,3 +2113,103 @@ func TestStatementBuilderSemconvSingleSpelling(t *testing.T) {
require.NoError(t, err)
require.Equal(t, "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'deployment.environment') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT toString(multiIf(resource.`deployment.environment` IS NOT NULL, resource.`deployment.environment`::String, mapContains(resources_string, 'deployment.environment'), resources_string['deployment.environment'], NULL)) AS `__GROUP_BY_KEY_0_deployment.environment.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY `__GROUP_BY_KEY_0_deployment.environment.name` ORDER BY __result_0 DESC", q.Query)
}
func TestStatementBuilderHeatmap(t *testing.T) {
testCases := []struct {
name string
requestType qbtypes.RequestType
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]
expected qbtypes.Statement
}{
{
name: "DurationNano_CollidingAttribute_NoExists",
requestType: qbtypes.RequestTypeHeatmap,
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Aggregations: []qbtypes.TraceAggregation{{Expression: "duration_nano"}},
Filter: &qbtypes.Filter{Expression: "service.name = 'redis-manual'"},
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), ifNull(toFloat64(duration_nano), 0) AS __bucket_value SELECT toStartOfInterval(timestamp, INTERVAL 30 SECOND) AS ts, multiIf(__bucket_value <= 0, toFloat64('-Inf'), __bucket_value <= 2.3283064365386963e-10, toFloat64(0), __bucket_value > 1.8446744073709552e+19, toFloat64(1.8446744073709552e+19), pow(2, (ceil(log2(__bucket_value) * 16) - 1) / 16)) AS __bucket_min, multiIf(__bucket_value <= 0, toFloat64(0), __bucket_value <= 2.3283064365386963e-10, 2.3283064365386963e-10, __bucket_value > 1.8446744073709552e+19, toFloat64('+Inf'), pow(2, ceil(log2(__bucket_value) * 16) / 16)) AS __bucket_max, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY ts, __bucket_min, __bucket_max",
Args: []any{"redis-manual", "%service.name%", "%service.name\":\"redis-manual%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448)},
},
},
{
name: "AttributeField_GroupByWithLimit_LinearAxis_RequiresFieldInLimit",
requestType: qbtypes.RequestTypeHeatmap,
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
StepInterval: qbtypes.Step{Duration: 60 * time.Second},
Aggregations: []qbtypes.TraceAggregation{{Expression: "cart.items_count"}},
GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "service.name"}}},
Limit: 5,
BucketOptions: &qbtypes.BucketOptions{Kind: qbtypes.BucketsKindLinear, Spec: qbtypes.LinearBucketsSpec{MaxValue: 50, NumBuckets: 5}},
},
expected: qbtypes.Statement{
Query: "WITH ifNull(multiIf(`attribute_number_cart$$items_count_exists` = true, toFloat64(`attribute_number_cart$$items_count`), NULL), 0) AS __bucket_value, __limit_cte AS (SELECT toString(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, NULL)) AS `__GROUP_BY_KEY_0_service.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE `attribute_number_cart$$items_count_exists` = true AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY `__GROUP_BY_KEY_0_service.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(timestamp, INTERVAL 60 SECOND) AS ts, toString(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, NULL)) AS `__GROUP_BY_KEY_0_service.name`, multiIf(__bucket_value <= 0, toFloat64('-Inf'), __bucket_value > 50, toFloat64(50), (least(greatest(ceil(__bucket_value * 5 / 50), 1), 5) - 1) * 50 / 5) AS __bucket_min, multiIf(__bucket_value <= 0, toFloat64(0), __bucket_value > 50, toFloat64('+Inf'), least(greatest(ceil(__bucket_value * 5 / 50), 1), 5) * 50 / 5) AS __bucket_max, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE `attribute_number_cart$$items_count_exists` = true AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND (`__GROUP_BY_KEY_0_service.name`) GLOBAL IN (SELECT `__GROUP_BY_KEY_0_service.name` FROM __limit_cte) GROUP BY ts, `__GROUP_BY_KEY_0_service.name`, __bucket_min, __bucket_max",
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 5, "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448)},
},
},
{
name: "QualifiedField_OrderedByAggregation_OrdersByCount",
requestType: qbtypes.RequestTypeHeatmap,
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Aggregations: []qbtypes.TraceAggregation{{Expression: "attribute.metric.max_count"}},
GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "service.name"}}},
Limit: 3,
Order: []qbtypes.OrderBy{{
Key: qbtypes.OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "metric.max_count", FieldContext: telemetrytypes.FieldContextAttribute}},
Direction: qbtypes.OrderDirectionDesc,
}},
},
expected: qbtypes.Statement{
Query: "WITH ifNull(multiIf(mapContains(attributes_number, 'metric.max_count'), toFloat64(attributes_number['metric.max_count']), NULL), 0) AS __bucket_value, __limit_cte AS (SELECT toString(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, NULL)) AS `__GROUP_BY_KEY_0_service.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE mapContains(attributes_number, 'metric.max_count') AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY `__GROUP_BY_KEY_0_service.name` ORDER BY __result_0 desc LIMIT ?) SELECT toStartOfInterval(timestamp, INTERVAL 30 SECOND) AS ts, toString(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, NULL)) AS `__GROUP_BY_KEY_0_service.name`, multiIf(__bucket_value <= 0, toFloat64('-Inf'), __bucket_value <= 2.3283064365386963e-10, toFloat64(0), __bucket_value > 1.8446744073709552e+19, toFloat64(1.8446744073709552e+19), pow(2, (ceil(log2(__bucket_value) * 16) - 1) / 16)) AS __bucket_min, multiIf(__bucket_value <= 0, toFloat64(0), __bucket_value <= 2.3283064365386963e-10, 2.3283064365386963e-10, __bucket_value > 1.8446744073709552e+19, toFloat64('+Inf'), pow(2, ceil(log2(__bucket_value) * 16) / 16)) AS __bucket_max, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE mapContains(attributes_number, 'metric.max_count') AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND (`__GROUP_BY_KEY_0_service.name`) GLOBAL IN (SELECT `__GROUP_BY_KEY_0_service.name` FROM __limit_cte) GROUP BY ts, `__GROUP_BY_KEY_0_service.name`, __bucket_min, __bucket_max ORDER BY ts desc",
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 3, "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448)},
},
},
{
name: "Distribution_DurationNano_SameStatementAsHeatmap",
requestType: qbtypes.RequestTypeDistribution,
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Aggregations: []qbtypes.TraceAggregation{{Expression: "duration_nano"}},
Filter: &qbtypes.Filter{Expression: "service.name = 'redis-manual'"},
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), ifNull(toFloat64(duration_nano), 0) AS __bucket_value SELECT toStartOfInterval(timestamp, INTERVAL 30 SECOND) AS ts, multiIf(__bucket_value <= 0, toFloat64('-Inf'), __bucket_value <= 2.3283064365386963e-10, toFloat64(0), __bucket_value > 1.8446744073709552e+19, toFloat64(1.8446744073709552e+19), pow(2, (ceil(log2(__bucket_value) * 16) - 1) / 16)) AS __bucket_min, multiIf(__bucket_value <= 0, toFloat64(0), __bucket_value <= 2.3283064365386963e-10, 2.3283064365386963e-10, __bucket_value > 1.8446744073709552e+19, toFloat64('+Inf'), pow(2, ceil(log2(__bucket_value) * 16) / 16)) AS __bucket_max, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY ts, __bucket_min, __bucket_max",
Args: []any{"redis-manual", "%service.name%", "%service.name\":\"redis-manual%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448)},
},
},
}
releaseTime := time.Date(2025, 5, 22, 0, 0, 0, 0, time.UTC)
fl := flaggertest.New(t)
storage := tracestelemetryschema.NewStorage(nil)
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
mockMetadataStore.KeysMap = tracestelemetryschema.BuildCompleteFieldKeyMap(releaseTime)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalTraces)
statementBuilder := NewTraceQueryStatementBuilder(
instrumentationtest.New().ToProviderSettings(),
mockMetadataStore,
storage,
aggExprRewriter,
nil,
fl,
false,
100000,
)
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
statement, err := statementBuilder.Build(context.Background(), valuer.UUID{}, 1747947419000, 1747983448000, testCase.requestType, testCase.query, nil)
require.NoError(t, err)
assert.Equal(t, testCase.expected.Query, statement.Query)
assert.Equal(t, testCase.expected.Args, statement.Args)
assert.Equal(t, testCase.expected.Warnings, statement.Warnings)
})
}
}

View File

@@ -1,9 +1,12 @@
package audittypes
import (
"net"
"net/http"
"strconv"
"strings"
"github.com/SigNoz/signoz-semantic-conventions/gen/go/sigsemconv"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/valuer"
@@ -16,10 +19,9 @@ type AuditAttributes struct {
Action coretypes.Verb // guaranteed to be present
ActionCategory coretypes.ActionCategory // guaranteed to be present
Outcome Outcome // guaranteed to be present
IdentNProvider authtypes.IdentNProvider
}
func NewAuditAttributesFromHTTP(statusCode int, action coretypes.Verb, category coretypes.ActionCategory, claims authtypes.Claims) AuditAttributes {
func NewAuditAttributesFromHTTP(statusCode int, action coretypes.Verb, category coretypes.ActionCategory) AuditAttributes {
outcome := OutcomeFailure
if statusCode >= 200 && statusCode < 400 {
outcome = OutcomeSuccess
@@ -29,15 +31,13 @@ func NewAuditAttributesFromHTTP(statusCode int, action coretypes.Verb, category
Action: action,
ActionCategory: category,
Outcome: outcome,
IdentNProvider: claims.IdentNProvider,
}
}
func (attributes AuditAttributes) Put(dest pcommon.Map) {
dest.PutStr("signoz.audit.action", attributes.Action.StringValue())
dest.PutStr("signoz.audit.action_category", attributes.ActionCategory.StringValue())
dest.PutStr("signoz.audit.outcome", attributes.Outcome.StringValue())
putStrIfNotEmpty(dest, "signoz.audit.identn_provider", attributes.IdentNProvider.StringValue())
dest.PutStr(string(sigsemconv.SigNozAuditVerbKey), attributes.Action.StringValue())
dest.PutStr(string(sigsemconv.SigNozAuditActionCategoryKey), attributes.ActionCategory.StringValue())
dest.PutStr(string(sigsemconv.SigNozAuditOutcomeKey), attributes.Outcome.StringValue())
}
// Audit attributes — Principal (Who).
@@ -50,6 +50,9 @@ type PrincipalAttributes struct {
func NewPrincipalAttributesFromClaims(claims authtypes.Claims) PrincipalAttributes {
principalID, _ := valuer.NewUUID(claims.UserID)
if claims.Principal == authtypes.PrincipalServiceAccount {
principalID, _ = valuer.NewUUID(claims.ServiceAccountID)
}
principalEmail, _ := valuer.NewEmail(claims.Email)
principalOrgID, _ := valuer.NewUUID(claims.OrgID)
@@ -62,10 +65,12 @@ func NewPrincipalAttributesFromClaims(claims authtypes.Claims) PrincipalAttribut
}
func (attributes PrincipalAttributes) Put(dest pcommon.Map) {
dest.PutStr("signoz.audit.principal.id", attributes.PrincipalID.StringValue())
dest.PutStr("signoz.audit.principal.email", attributes.PrincipalEmail.String())
dest.PutStr("signoz.audit.principal.type", attributes.PrincipalType.StringValue())
dest.PutStr("signoz.audit.principal.org_id", attributes.PrincipalOrgID.StringValue())
if !attributes.PrincipalID.IsZero() {
dest.PutStr(string(sigsemconv.SigNozPrincipalIDKey), attributes.PrincipalID.StringValue())
putStrIfNotEmpty(dest, string(sigsemconv.SigNozPrincipalTypeKey), attributes.PrincipalType.StringValue())
}
putStrIfNotEmpty(dest, string(sigsemconv.SigNozPrincipalEmailKey), attributes.PrincipalEmail.String())
dest.PutStr(string(sigsemconv.SigNozOrganizationIDKey), attributes.PrincipalOrgID.StringValue())
}
// Audit attributes — Resource (On What).
@@ -73,11 +78,6 @@ func (attributes PrincipalAttributes) Put(dest pcommon.Map) {
type ResourceAttributes struct {
Resource coretypes.Resource // guaranteed to be present
ResourceID string
// TargetResource names the counterpart of an attach/detach event (audit
// context only). nil when there is no relationship.
TargetResource coretypes.Resource
TargetResourceID string
}
func NewResourceAttributes(resource coretypes.Resource, resourceID string) ResourceAttributes {
@@ -87,55 +87,75 @@ func NewResourceAttributes(resource coretypes.Resource, resourceID string) Resou
}
}
// NewAttachResourceAttributes builds resource attributes that additionally name
// the target counterpart (used for attach/detach audit events).
func NewRelatedResourceAttributes(resource coretypes.Resource, resourceID string, targetResource coretypes.Resource, targetResourceID string) ResourceAttributes {
return ResourceAttributes{
Resource: resource,
ResourceID: resourceID,
// Put writes the resource attributes to an OTel Resource's attribute map.
// These are resource-level attributes (stored in the resource JSON column),
// not event-level attributes (stored in attributes_string).
func (attributes ResourceAttributes) Put(orgID valuer.UUID, dest pcommon.Map) {
putStrIfNotEmpty(dest, string(sigsemconv.SigNozResourceKindKey), attributes.Resource.Kind().String())
putStrIfNotEmpty(dest, string(sigsemconv.SigNozResourceIDKey), attributes.ResourceID)
if attributes.ResourceID != "" {
putStrIfNotEmpty(dest, string(sigsemconv.SigNozResourceObjectKey), attributes.Resource.Object(orgID, attributes.ResourceID))
}
}
// Audit attributes — Target (To/From What).
// The counterpart of an attach/detach event, placed on the log record.
type TargetAttributes struct {
// nil when there is no relationship.
TargetResource coretypes.Resource
TargetResourceID string
}
func NewTargetAttributes(targetResource coretypes.Resource, targetResourceID string) TargetAttributes {
return TargetAttributes{
TargetResource: targetResource,
TargetResourceID: targetResourceID,
}
}
// PutResource writes the resource attributes to an OTel Resource's attribute map.
// These are resource-level attributes (stored in the resource JSON column),
// not event-level attributes (stored in attributes_string).
func (attributes ResourceAttributes) PutResource(orgID valuer.UUID, dest pcommon.Map) {
putStrIfNotEmpty(dest, "signoz.audit.resource.kind", attributes.Resource.Kind().String())
putStrIfNotEmpty(dest, "signoz.audit.resource.id", attributes.ResourceID)
if attributes.ResourceID != "" {
putStrIfNotEmpty(dest, "signoz.audit.resource.object", attributes.Resource.Object(orgID, attributes.ResourceID))
func (attributes TargetAttributes) Put(orgID valuer.UUID, dest pcommon.Map) {
if attributes.TargetResource == nil {
return
}
if attributes.TargetResource != nil {
putStrIfNotEmpty(dest, "signoz.audit.resource.target.kind", attributes.TargetResource.Kind().String())
putStrIfNotEmpty(dest, "signoz.audit.resource.target.id", attributes.TargetResourceID)
if attributes.TargetResourceID != "" {
putStrIfNotEmpty(dest, "signoz.audit.resource.target.object", attributes.TargetResource.Object(orgID, attributes.TargetResourceID))
}
putStrIfNotEmpty(dest, string(sigsemconv.SigNozResourceTargetKindKey), attributes.TargetResource.Kind().String())
putStrIfNotEmpty(dest, string(sigsemconv.SigNozResourceTargetIDKey), attributes.TargetResourceID)
if attributes.TargetResourceID != "" {
putStrIfNotEmpty(dest, string(sigsemconv.SigNozResourceTargetObjectKey), attributes.TargetResource.Object(orgID, attributes.TargetResourceID))
}
}
// Audit attributes: Relation (attach/detach).
// One side of a relation: the resource the event is recorded from, placed on
// the Resource, and its counterpart, placed on the log record.
type RelatedResourceAttributes struct {
Resource ResourceAttributes
Target TargetAttributes
}
func NewRelatedResourceAttributes(resource coretypes.Resource, resourceID string, targetResource coretypes.Resource, targetResourceID string) RelatedResourceAttributes {
return RelatedResourceAttributes{
Resource: NewResourceAttributes(resource, resourceID),
Target: NewTargetAttributes(targetResource, targetResourceID),
}
}
// Audit attributes — Error (When outcome is failure)
// Error messages are intentionally excluded to avoid leaking sensitive or
// PII data into audit logs. The error type and code are sufficient for
// filtering and alerting; investigators can correlate via trace ID.
// PII data into audit logs. The error type is sufficient for filtering and
// alerting; investigators can correlate via trace ID.
type ErrorAttributes struct {
ErrorType string
ErrorCode string
}
func NewErrorAttributes(errorType, errorCode string) ErrorAttributes {
func NewErrorAttributes(errorType string) ErrorAttributes {
return ErrorAttributes{
ErrorType: errorType,
ErrorCode: errorCode,
}
}
func (attributes ErrorAttributes) Put(dest pcommon.Map) {
putStrIfNotEmpty(dest, "signoz.audit.error.type", attributes.ErrorType)
putStrIfNotEmpty(dest, "signoz.audit.error.code", attributes.ErrorCode)
putStrIfNotEmpty(dest, string(semconv.ExceptionTypeKey), attributes.ErrorType)
}
// Audit attributes — Transport Context (Where/How).
@@ -148,6 +168,7 @@ type TransportAttributes struct {
HTTPStatusCode int
URLPath string
ClientAddress string
ClientPort int
UserAgent string
}
@@ -157,15 +178,18 @@ func NewTransportAttributesFromHTTP(req *http.Request, route string, statusCode
scheme = "https"
}
clientAddress, clientPort := splitHostPort(req.RemoteAddr)
return TransportAttributes{
NetworkProtocolName: "http",
NetworkProtocolVersion: req.Proto,
NetworkProtocolVersion: networkProtocolVersion(req),
URLScheme: scheme,
HTTPMethod: req.Method,
HTTPRoute: route,
HTTPStatusCode: statusCode,
URLPath: req.URL.Path,
ClientAddress: req.RemoteAddr,
ClientAddress: clientAddress,
ClientPort: clientPort,
UserAgent: req.UserAgent(),
}
}
@@ -181,16 +205,43 @@ func (attributes TransportAttributes) Put(dest pcommon.Map) {
}
putStrIfNotEmpty(dest, string(semconv.URLPathKey), attributes.URLPath)
putStrIfNotEmpty(dest, string(semconv.ClientAddressKey), attributes.ClientAddress)
if attributes.ClientPort != 0 {
dest.PutInt(string(semconv.ClientPortKey), int64(attributes.ClientPort))
}
putStrIfNotEmpty(dest, string(semconv.UserAgentOriginalKey), attributes.UserAgent)
}
// networkProtocolVersion drops the HTTP/ prefix, and the .0 of HTTP/2 and later.
func networkProtocolVersion(req *http.Request) string {
if req.ProtoMajor >= 2 && req.ProtoMinor == 0 {
return strconv.Itoa(req.ProtoMajor)
}
return strconv.Itoa(req.ProtoMajor) + "." + strconv.Itoa(req.ProtoMinor)
}
// splitHostPort returns addr unchanged with port 0 when it has no valid port.
func splitHostPort(addr string) (string, int) {
host, portStr, err := net.SplitHostPort(addr)
if err != nil {
return addr, 0
}
port, err := strconv.Atoi(portStr)
if err != nil {
return addr, 0
}
return host, port
}
func putStrIfNotEmpty(attrs pcommon.Map, key, value string) {
if value != "" {
attrs.PutStr(key, value)
}
}
func newBody(auditAttributes AuditAttributes, principalAttributes PrincipalAttributes, resourceAttributes ResourceAttributes, errorAttributes ErrorAttributes) string {
func newBody(auditAttributes AuditAttributes, principalAttributes PrincipalAttributes, resourceAttributes ResourceAttributes, targetAttributes TargetAttributes, errorAttributes ErrorAttributes) string {
var b strings.Builder
// Principal: "email (id)" or "id" or "email" or omitted.
@@ -227,36 +278,25 @@ func newBody(auditAttributes AuditAttributes, principalAttributes PrincipalAttri
b.WriteString(")")
}
// Target (attach/detach context): " · target kind (id)" or " · target kind".
if resourceAttributes.TargetResource != nil {
b.WriteString(" to ")
b.WriteString(resourceAttributes.TargetResource.Kind().String())
if resourceAttributes.TargetResourceID != "" {
// Target (attach/detach context): " to target kind (id)" or " from target kind" for detach.
if targetAttributes.TargetResource != nil {
if auditAttributes.Action == coretypes.VerbDetach {
b.WriteString(" from ")
} else {
b.WriteString(" to ")
}
b.WriteString(targetAttributes.TargetResource.Kind().String())
if targetAttributes.TargetResourceID != "" {
b.WriteString(" (")
b.WriteString(resourceAttributes.TargetResourceID)
b.WriteString(targetAttributes.TargetResourceID)
b.WriteString(")")
}
}
// Error suffix (failure only): ": type (code)" or ": type" or ": (code)" or omitted.
if auditAttributes.Outcome == OutcomeFailure {
errorType := errorAttributes.ErrorType
errorCode := errorAttributes.ErrorCode
if errorType != "" || errorCode != "" {
b.WriteString(": ")
if errorType != "" && errorCode != "" {
b.WriteString(errorType)
b.WriteString(" (")
b.WriteString(errorCode)
b.WriteString(")")
} else if errorType != "" {
b.WriteString(errorType)
} else {
b.WriteString("(")
b.WriteString(errorCode)
b.WriteString(")")
}
}
// Error suffix (failure only): ": type" or omitted.
if auditAttributes.Outcome == OutcomeFailure && errorAttributes.ErrorType != "" {
b.WriteString(": ")
b.WriteString(errorAttributes.ErrorType)
}
return b.String()

View File

@@ -3,15 +3,12 @@ package audittypes
import (
"testing"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
)
func TestNewAuditAttributesFromHTTP_OutcomeBoundary(t *testing.T) {
claims := authtypes.Claims{IdentNProvider: authtypes.IdentNProviderTokenizer}
testCases := []struct {
name string
statusCode int
@@ -36,7 +33,7 @@ func TestNewAuditAttributesFromHTTP_OutcomeBoundary(t *testing.T) {
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
attrs := NewAuditAttributesFromHTTP(testCase.statusCode, coretypes.VerbUpdate, coretypes.ActionCategoryConfigurationChange, claims)
attrs := NewAuditAttributesFromHTTP(testCase.statusCode, coretypes.VerbUpdate, coretypes.ActionCategoryConfigurationChange)
assert.Equal(t, testCase.expectedOutcome, attrs.Outcome)
})
}
@@ -48,6 +45,7 @@ func TestNewBody(t *testing.T) {
auditAttributes AuditAttributes
principalAttributes PrincipalAttributes
resourceAttributes ResourceAttributes
targetAttributes TargetAttributes
errorAttributes ErrorAttributes
expectedBody string
}{
@@ -154,9 +152,8 @@ func TestNewBody(t *testing.T) {
},
errorAttributes: ErrorAttributes{
ErrorType: "forbidden",
ErrorCode: "authz_forbidden",
},
expectedBody: "viewer@acme.com (019aaaaa-bbbb-7000-8000-cccc00000002) failed to update dashboard (019b-5678): forbidden (authz_forbidden)",
expectedBody: "viewer@acme.com (019aaaaa-bbbb-7000-8000-cccc00000002) failed to update dashboard (019b-5678): forbidden",
},
{
name: "Failure_ErrorTypeOnly",
@@ -193,11 +190,47 @@ func TestNewBody(t *testing.T) {
errorAttributes: ErrorAttributes{},
expectedBody: "test@acme.com (019a1234-abcd-7000-8000-567800000001) failed to create dashboard (019b-5678)",
},
{
name: "Success_Attach",
auditAttributes: AuditAttributes{
Action: coretypes.VerbAttach,
Outcome: OutcomeSuccess,
},
principalAttributes: PrincipalAttributes{},
resourceAttributes: NewResourceAttributes(coretypes.ResourceRole, "role-1"),
targetAttributes: NewTargetAttributes(coretypes.ResourceServiceAccount, "sa-1"),
errorAttributes: ErrorAttributes{},
expectedBody: "attached role (role-1) to serviceaccount (sa-1)",
},
{
name: "Success_Detach",
auditAttributes: AuditAttributes{
Action: coretypes.VerbDetach,
Outcome: OutcomeSuccess,
},
principalAttributes: PrincipalAttributes{},
resourceAttributes: NewResourceAttributes(coretypes.ResourceRole, "role-1"),
targetAttributes: NewTargetAttributes(coretypes.ResourceServiceAccount, "sa-1"),
errorAttributes: ErrorAttributes{},
expectedBody: "detached role (role-1) from serviceaccount (sa-1)",
},
{
name: "Failure_Detach",
auditAttributes: AuditAttributes{
Action: coretypes.VerbDetach,
Outcome: OutcomeFailure,
},
principalAttributes: PrincipalAttributes{},
resourceAttributes: NewResourceAttributes(coretypes.ResourceRole, "role-1"),
targetAttributes: NewTargetAttributes(coretypes.ResourceServiceAccount, "sa-1"),
errorAttributes: ErrorAttributes{},
expectedBody: "failed to detach role (role-1) from serviceaccount (sa-1)",
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
body := newBody(testCase.auditAttributes, testCase.principalAttributes, testCase.resourceAttributes, testCase.errorAttributes)
body := newBody(testCase.auditAttributes, testCase.principalAttributes, testCase.resourceAttributes, testCase.targetAttributes, testCase.errorAttributes)
assert.Equal(t, testCase.expectedBody, body)
})
}

View File

@@ -37,6 +37,9 @@ type AuditEvent struct {
// Custom Audit Attributes - Resource
ResourceAttributes ResourceAttributes
// Custom Audit Attributes - Target
TargetAttributes TargetAttributes
// Custom Audit Attributes - Error
ErrorAttributes ErrorAttributes
@@ -56,23 +59,24 @@ func NewAuditEventFromHTTPRequest(
actionCategory coretypes.ActionCategory,
claims authtypes.Claims,
resourceAttributes ResourceAttributes,
targetAttributes TargetAttributes,
errorType string,
errorCode string,
) AuditEvent {
auditAttributes := NewAuditAttributesFromHTTP(statusCode, action, actionCategory, claims)
auditAttributes := NewAuditAttributesFromHTTP(statusCode, action, actionCategory)
principalAttributes := NewPrincipalAttributesFromClaims(claims)
errorAttributes := NewErrorAttributes(errorType, errorCode)
errorAttributes := NewErrorAttributes(errorType)
transportAttributes := NewTransportAttributesFromHTTP(req, route, statusCode)
return AuditEvent{
Timestamp: time.Now(),
TraceID: traceID,
SpanID: spanID,
Body: newBody(auditAttributes, principalAttributes, resourceAttributes, errorAttributes),
EventName: NewEventName(resourceAttributes.Resource.Kind(), auditAttributes.Action),
Body: newBody(auditAttributes, principalAttributes, resourceAttributes, targetAttributes, errorAttributes),
EventName: NewEventName(auditAttributes.Action),
AuditAttributes: auditAttributes,
PrincipalAttributes: principalAttributes,
ResourceAttributes: resourceAttributes,
TargetAttributes: targetAttributes,
ErrorAttributes: errorAttributes,
TransportAttributes: transportAttributes,
}
@@ -102,7 +106,7 @@ func NewPLogsFromAuditEvents(events []AuditEvent, name string, version string, s
resourceAttrs.PutStr(string(semconv.ServiceNameKey), name)
resourceAttrs.PutStr(string(semconv.ServiceVersionKey), version)
head := events[groups[key][0]]
head.ResourceAttributes.PutResource(head.PrincipalAttributes.PrincipalOrgID, resourceAttrs)
head.ResourceAttributes.Put(head.PrincipalAttributes.PrincipalOrgID, resourceAttrs)
scopeLogs := resourceLogs.ScopeLogs().AppendEmpty()
scopeLogs.Scope().SetName(scope)
@@ -148,6 +152,9 @@ func (event AuditEvent) ToLogRecord(dest plog.LogRecord) {
// Resource attributes are set at the ResourceLogs level, not per-LogRecord.
// See NewPLogsFromAuditEvents.
// Target attributes
event.TargetAttributes.Put(event.PrincipalAttributes.PrincipalOrgID, attrs)
// Error attributes
event.ErrorAttributes.Put(attrs)

View File

@@ -1,6 +1,17 @@
package audittypes
import "github.com/SigNoz/signoz/pkg/types/coretypes"
import (
"github.com/SigNoz/signoz-semantic-conventions/gen/go/sigsemconv"
"github.com/SigNoz/signoz/pkg/types/coretypes"
)
var eventNames = map[coretypes.Verb]EventName{
coretypes.VerbCreate: {s: sigsemconv.SigNozAuditCreateEventName},
coretypes.VerbUpdate: {s: sigsemconv.SigNozAuditUpdateEventName},
coretypes.VerbDelete: {s: sigsemconv.SigNozAuditDeleteEventName},
coretypes.VerbAttach: {s: sigsemconv.SigNozAuditAttachEventName},
coretypes.VerbDetach: {s: sigsemconv.SigNozAuditDetachEventName},
}
// EventName is a typed wrapper for audit event names, ensuring not every
// string qualifies as an event name.
@@ -8,15 +19,16 @@ type EventName struct {
s string
}
// NewEventName derives the audit event name from a resource kind and action.
// Format: {resource_kind}.{pastTense(action)}
//
// Examples:
//
// NewEventName("dashboard", coretypes.VerbCreate) → "dashboard.created"
// NewEventName("dashboard", coretypes.VerbUpdate) → "dashboard.updated"
func NewEventName(resourceKind coretypes.Kind, action coretypes.Verb) EventName {
return EventName{s: resourceKind.String() + "." + action.PastTense()}
// NewEventName returns the audit event name of an action, signoz.audit.{action}.
// It is zero for an action that is not audited; see IsAudited.
func NewEventName(action coretypes.Verb) EventName {
return eventNames[action]
}
// IsAudited reports whether an action records an audit event.
func IsAudited(action coretypes.Verb) bool {
_, ok := eventNames[action]
return ok
}
// String returns the string representation of the event name.

View File

@@ -7,7 +7,10 @@ import (
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/collector/pdata/plog"
oteltrace "go.opentelemetry.io/otel/trace"
)
@@ -31,7 +34,6 @@ func TestNewAuditEvent(t *testing.T) {
resource coretypes.Resource
resourceID string
errorType string
errorCode string
expectedOutcome Outcome
expectedBody string
}{
@@ -61,9 +63,8 @@ func TestNewAuditEvent(t *testing.T) {
resource: testDashboardResource,
resourceID: "019b-5678-efgh-9012",
errorType: "forbidden",
errorCode: "authz_forbidden",
expectedOutcome: OutcomeFailure,
expectedBody: "viewer@acme.com (019aaaaa-bbbb-7000-8000-cccc00000002) failed to update dashboard (019b-5678-efgh-9012): forbidden (authz_forbidden)",
expectedBody: "viewer@acme.com (019aaaaa-bbbb-7000-8000-cccc00000002) failed to update dashboard (019b-5678-efgh-9012): forbidden",
},
}
@@ -81,8 +82,8 @@ func TestNewAuditEvent(t *testing.T) {
testCase.category,
testCase.claims,
NewResourceAttributes(testCase.resource, testCase.resourceID),
TargetAttributes{},
testCase.errorType,
testCase.errorCode,
)
assert.Equal(t, testCase.expectedOutcome, event.AuditAttributes.Outcome)
@@ -97,7 +98,6 @@ func TestNewAuditEvent(t *testing.T) {
assert.Equal(t, traceID, event.TraceID)
assert.Equal(t, spanID, event.SpanID)
assert.Equal(t, testCase.errorType, event.ErrorAttributes.ErrorType)
assert.Equal(t, testCase.errorCode, event.ErrorAttributes.ErrorCode)
})
}
}
@@ -105,7 +105,7 @@ func TestNewAuditEvent(t *testing.T) {
func newTestEvent(resource coretypes.Resource, resourceID string, action coretypes.Verb) AuditEvent {
return AuditEvent{
Body: resource.Kind().String() + "." + action.PastTense(),
EventName: NewEventName(resource.Kind(), action),
EventName: NewEventName(action),
AuditAttributes: AuditAttributes{
Action: action,
ActionCategory: coretypes.ActionCategoryConfigurationChange,
@@ -210,11 +210,11 @@ func TestNewPLogsFromAuditEvents(t *testing.T) {
assert.True(t, exists)
assert.Equal(t, "0.90.0", serviceVersion.Str())
kind, exists := resourceAttrs.Get("signoz.audit.resource.kind")
kind, exists := resourceAttrs.Get("signoz.resource.kind")
assert.True(t, exists)
assert.Equal(t, testCase.expectedResourceKinds[i], kind.Str())
id, exists := resourceAttrs.Get("signoz.audit.resource.id")
id, exists := resourceAttrs.Get("signoz.resource.id")
assert.True(t, exists)
assert.Equal(t, testCase.expectedResourceIDs[i], id.Str())
@@ -225,8 +225,8 @@ func TestNewPLogsFromAuditEvents(t *testing.T) {
for j := 0; j < resourceLogs.ScopeLogs().At(0).LogRecords().Len(); j++ {
recordAttrs := resourceLogs.ScopeLogs().At(0).LogRecords().At(j).Attributes()
_, hasKind := recordAttrs.Get("signoz.audit.resource.kind")
_, hasID := recordAttrs.Get("signoz.audit.resource.id")
_, hasKind := recordAttrs.Get("signoz.resource.kind")
_, hasID := recordAttrs.Get("signoz.resource.id")
assert.False(t, hasKind, "resource.kind must not be in log record attributes")
assert.False(t, hasID, "resource.id must not be in log record attributes")
}
@@ -234,3 +234,276 @@ func TestNewPLogsFromAuditEvents(t *testing.T) {
})
}
}
func TestNewEventName(t *testing.T) {
testCases := []struct {
name string
action coretypes.Verb
expected string
expectedAudited bool
}{
{name: "Create", action: coretypes.VerbCreate, expected: "signoz.audit.create", expectedAudited: true},
{name: "Detach", action: coretypes.VerbDetach, expected: "signoz.audit.detach", expectedAudited: true},
{name: "Read_NotAudited", action: coretypes.VerbRead, expected: "", expectedAudited: false},
{name: "List_NotAudited", action: coretypes.VerbList, expected: "", expectedAudited: false},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.expected, NewEventName(testCase.action).String())
assert.Equal(t, testCase.expectedAudited, IsAudited(testCase.action))
})
}
}
func TestAuditEventToLogRecord(t *testing.T) {
testCases := []struct {
name string
claims authtypes.Claims
resourceAttributes ResourceAttributes
targetAttributes TargetAttributes
statusCode int
errorType string
remoteAddr string
proto string
protoMajor int
protoMinor int
expectedAttributes map[string]any
absentAttributes []string
}{
{
name: "Success_User_HTTP11",
claims: authtypes.Claims{UserID: "019a1234-abcd-7000-8000-567800000001", Principal: authtypes.PrincipalUser, Email: "alice@acme.com", OrgID: "019a1234-abcd-7000-8000-567800000002"},
resourceAttributes: NewResourceAttributes(testDashboardResource, "d-001"),
statusCode: http.StatusOK,
remoteAddr: "10.0.0.1:52341",
proto: "HTTP/1.1",
protoMajor: 1,
protoMinor: 1,
expectedAttributes: map[string]any{
"signoz.audit.verb": "create",
"signoz.audit.action_category": "configuration_changes",
"signoz.audit.outcome": "success",
"signoz.principal.id": "019a1234-abcd-7000-8000-567800000001",
"signoz.principal.type": "user",
"signoz.principal.email": "alice@acme.com",
"signoz.organization.id": "019a1234-abcd-7000-8000-567800000002",
"network.protocol.version": "1.1",
"client.address": "10.0.0.1",
"client.port": int64(52341),
"url.path": "/api/v1/resource",
},
absentAttributes: []string{"exception.type", "signoz.workspace.key.id", "signoz.resource.target.kind", "signoz.audit.identn_provider"},
},
{
name: "ServiceAccount_HTTP2",
claims: authtypes.Claims{UserID: "00000000-0000-0000-0000-000000000000", ServiceAccountID: "019a1234-abcd-7000-8000-567800000003", Principal: authtypes.PrincipalServiceAccount, OrgID: "019a1234-abcd-7000-8000-567800000002"},
resourceAttributes: NewResourceAttributes(testDashboardResource, "d-002"),
statusCode: http.StatusOK,
remoteAddr: "10.0.0.3:443",
proto: "HTTP/2.0",
protoMajor: 2,
protoMinor: 0,
expectedAttributes: map[string]any{
"signoz.principal.id": "019a1234-abcd-7000-8000-567800000003",
"signoz.principal.type": "service_account",
"network.protocol.version": "2",
},
absentAttributes: []string{"signoz.principal.email"},
},
{
name: "Failure_Anonymous_IngestionKey_AddressWithoutPort",
claims: authtypes.Claims{OrgID: "019a1234-abcd-7000-8000-567800000002"},
resourceAttributes: NewResourceAttributes(coretypes.ResourceMetaResourceIngestionKey, "key-001"),
statusCode: http.StatusForbidden,
errorType: "forbidden",
remoteAddr: "10.0.0.2",
proto: "HTTP/1.1",
protoMajor: 1,
protoMinor: 1,
expectedAttributes: map[string]any{
"signoz.audit.outcome": "failure",
"exception.type": "forbidden",
"client.address": "10.0.0.2",
},
absentAttributes: []string{"client.port", "signoz.principal.id", "signoz.principal.type", "signoz.principal.email", "signoz.workspace.key.id"},
},
{
name: "Attach_IngestionLimitToKey",
claims: authtypes.Claims{UserID: "019a1234-abcd-7000-8000-567800000001", Principal: authtypes.PrincipalUser, OrgID: "019a1234-abcd-7000-8000-567800000002"},
resourceAttributes: NewResourceAttributes(coretypes.ResourceMetaResourceIngestionLimit, "limit-001"),
targetAttributes: NewTargetAttributes(coretypes.ResourceMetaResourceIngestionKey, "key-002"),
statusCode: http.StatusOK,
remoteAddr: "10.0.0.4:1234",
proto: "HTTP/1.1",
protoMajor: 1,
protoMinor: 1,
expectedAttributes: map[string]any{
"signoz.resource.target.kind": "ingestion-key",
"signoz.resource.target.id": "key-002",
},
absentAttributes: []string{"signoz.resource.kind", "signoz.resource.id", "signoz.workspace.key.id"},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/resource", nil)
req.RemoteAddr = testCase.remoteAddr
req.Proto, req.ProtoMajor, req.ProtoMinor = testCase.proto, testCase.protoMajor, testCase.protoMinor
event := NewAuditEventFromHTTPRequest(req, "/api/v1/resource", testCase.statusCode, oteltrace.TraceID{}, oteltrace.SpanID{}, coretypes.VerbCreate, coretypes.ActionCategoryConfigurationChange, testCase.claims, testCase.resourceAttributes, testCase.targetAttributes, testCase.errorType)
record := plog.NewLogRecord()
event.ToLogRecord(record)
attrs := record.Attributes().AsRaw()
for key, value := range testCase.expectedAttributes {
assert.Equal(t, value, attrs[key], key)
}
for _, key := range testCase.absentAttributes {
assert.NotContains(t, attrs, key)
}
})
}
}
func TestNewPLogsFromAuditEvents_TargetPerRecord(t *testing.T) {
first := NewAuditEventFromHTTPRequest(httptest.NewRequest(http.MethodPost, "/", nil), "/", http.StatusOK, oteltrace.TraceID{}, oteltrace.SpanID{}, coretypes.VerbAttach, coretypes.ActionCategoryConfigurationChange, authtypes.Claims{}, NewResourceAttributes(testDashboardResource, "d-001"), NewTargetAttributes(coretypes.ResourceMetaResourceRule, "r-001"), "")
second := NewAuditEventFromHTTPRequest(httptest.NewRequest(http.MethodPost, "/", nil), "/", http.StatusOK, oteltrace.TraceID{}, oteltrace.SpanID{}, coretypes.VerbAttach, coretypes.ActionCategoryConfigurationChange, authtypes.Claims{}, NewResourceAttributes(testDashboardResource, "d-001"), NewTargetAttributes(coretypes.ResourceMetaResourceRule, "r-002"), "")
logs := NewPLogsFromAuditEvents([]AuditEvent{first, second}, "signoz", "0.90.0", "signoz.audit")
require.Equal(t, 1, logs.ResourceLogs().Len())
resourceLogs := logs.ResourceLogs().At(0)
assert.NotContains(t, resourceLogs.Resource().Attributes().AsRaw(), "signoz.resource.target.id")
records := resourceLogs.ScopeLogs().At(0).LogRecords()
require.Equal(t, 2, records.Len())
targetIDs := []any{}
for i := 0; i < records.Len(); i++ {
targetIDs = append(targetIDs, records.At(i).Attributes().AsRaw()["signoz.resource.target.id"])
}
assert.Equal(t, []any{"r-001", "r-002"}, targetIDs)
}
func TestNewPLogsFromAuditEvents_Attributes(t *testing.T) {
testCases := []struct {
name string
method string
path string
route string
statusCode int
action coretypes.Verb
category coretypes.ActionCategory
claims authtypes.Claims
resourceAttributes ResourceAttributes
targetAttributes TargetAttributes
errorType string
expectedEventName string
expectedResourceAttributes map[string]any
expectedRecordAttributes map[string]any
}{
{
name: "Success_DashboardCreated",
method: http.MethodPost,
path: "/api/v1/dashboards",
route: "/api/v1/dashboards",
statusCode: http.StatusCreated,
action: coretypes.VerbCreate,
category: coretypes.ActionCategoryConfigurationChange,
claims: authtypes.Claims{UserID: "019a1234-abcd-7000-8000-567800000001", Email: "alice@acme.com", OrgID: "019a0000-0000-7000-8000-000000000001", Principal: authtypes.PrincipalUser},
resourceAttributes: NewResourceAttributes(testDashboardResource, "019b5678-0000-7000-8000-000000009012"),
expectedEventName: "signoz.audit.create",
expectedResourceAttributes: map[string]any{
"service.name": "signoz",
"service.version": "0.90.0",
"signoz.resource.kind": "dashboard",
"signoz.resource.id": "019b5678-0000-7000-8000-000000009012",
"signoz.resource.object": testDashboardResource.Object(valuer.MustNewUUID("019a0000-0000-7000-8000-000000000001"), "019b5678-0000-7000-8000-000000009012"),
},
expectedRecordAttributes: map[string]any{
"signoz.audit.verb": "create",
"signoz.audit.action_category": "configuration_changes",
"signoz.audit.outcome": "success",
"signoz.principal.id": "019a1234-abcd-7000-8000-567800000001",
"signoz.principal.email": "alice@acme.com",
"signoz.principal.type": "user",
"signoz.organization.id": "019a0000-0000-7000-8000-000000000001",
"network.protocol.name": "http",
"network.protocol.version": "1.1",
"url.scheme": "http",
"url.path": "/api/v1/dashboards",
"http.request.method": "POST",
"http.route": "/api/v1/dashboards",
"http.response.status_code": int64(201),
"client.address": "192.0.2.1",
"client.port": int64(1234),
},
},
{
name: "Failure_ForbiddenRoleAttachedToUser",
method: http.MethodPost,
path: "/api/v1/users/019c1111-0000-7000-8000-000000000020/roles",
route: "/api/v1/users/{id}/roles",
statusCode: http.StatusForbidden,
action: coretypes.VerbAttach,
category: coretypes.ActionCategoryAccessControl,
claims: authtypes.Claims{ServiceAccountID: "019aaaaa-bbbb-7000-8000-cccc00000002", OrgID: "019a0000-0000-7000-8000-000000000002", Principal: authtypes.PrincipalServiceAccount},
resourceAttributes: NewResourceAttributes(coretypes.ResourceUser, "019c1111-0000-7000-8000-000000000020"),
targetAttributes: NewTargetAttributes(coretypes.ResourceRole, "019d2222-0000-7000-8000-000000000030"),
errorType: "forbidden",
expectedEventName: "signoz.audit.attach",
expectedResourceAttributes: map[string]any{
"service.name": "signoz",
"service.version": "0.90.0",
"signoz.resource.kind": "user",
"signoz.resource.id": "019c1111-0000-7000-8000-000000000020",
"signoz.resource.object": coretypes.ResourceUser.Object(valuer.MustNewUUID("019a0000-0000-7000-8000-000000000002"), "019c1111-0000-7000-8000-000000000020"),
},
expectedRecordAttributes: map[string]any{
"signoz.audit.verb": "attach",
"signoz.audit.action_category": "access_control",
"signoz.audit.outcome": "failure",
"signoz.principal.id": "019aaaaa-bbbb-7000-8000-cccc00000002",
"signoz.principal.type": "service_account",
"signoz.organization.id": "019a0000-0000-7000-8000-000000000002",
"signoz.resource.target.kind": "role",
"signoz.resource.target.id": "019d2222-0000-7000-8000-000000000030",
"signoz.resource.target.object": coretypes.ResourceRole.Object(valuer.MustNewUUID("019a0000-0000-7000-8000-000000000002"), "019d2222-0000-7000-8000-000000000030"),
"exception.type": "forbidden",
"network.protocol.name": "http",
"network.protocol.version": "1.1",
"url.scheme": "http",
"url.path": "/api/v1/users/019c1111-0000-7000-8000-000000000020/roles",
"http.request.method": "POST",
"http.route": "/api/v1/users/{id}/roles",
"http.response.status_code": int64(403),
"client.address": "192.0.2.1",
"client.port": int64(1234),
},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
event := NewAuditEventFromHTTPRequest(httptest.NewRequest(testCase.method, testCase.path, nil), testCase.route, testCase.statusCode, oteltrace.TraceID{}, oteltrace.SpanID{}, testCase.action, testCase.category, testCase.claims, testCase.resourceAttributes, testCase.targetAttributes, testCase.errorType)
logs := NewPLogsFromAuditEvents([]AuditEvent{event}, "signoz", "0.90.0", "signoz.audit")
require.Equal(t, 1, logs.ResourceLogs().Len())
resourceLogs := logs.ResourceLogs().At(0)
assert.Equal(t, testCase.expectedResourceAttributes, resourceLogs.Resource().Attributes().AsRaw())
require.Equal(t, 1, resourceLogs.ScopeLogs().Len())
require.Equal(t, 1, resourceLogs.ScopeLogs().At(0).LogRecords().Len())
record := resourceLogs.ScopeLogs().At(0).LogRecords().At(0)
assert.Equal(t, testCase.expectedEventName, record.EventName())
assert.Equal(t, testCase.expectedRecordAttributes, record.Attributes().AsRaw())
})
}
}

View File

@@ -1,6 +1,7 @@
package audittypes
import (
"github.com/SigNoz/signoz-semantic-conventions/gen/go/sigsemconv"
"github.com/SigNoz/signoz/pkg/valuer"
"go.opentelemetry.io/collector/pdata/plog"
)
@@ -13,8 +14,8 @@ type Outcome struct {
}
var (
OutcomeSuccess = Outcome{valuer.NewString("success"), plog.SeverityNumberInfo, "INFO"}
OutcomeFailure = Outcome{valuer.NewString("failure"), plog.SeverityNumberError, "ERROR"}
OutcomeSuccess = Outcome{valuer.NewString(sigsemconv.SigNozAuditOutcomeSuccess.Value.AsString()), plog.SeverityNumberInfo, "INFO"}
OutcomeFailure = Outcome{valuer.NewString(sigsemconv.SigNozAuditOutcomeFailure.Value.AsString()), plog.SeverityNumberError, "ERROR"}
)
func (Outcome) Enum() []any {

View File

@@ -1,10 +1,13 @@
package coretypes
import "github.com/SigNoz/signoz/pkg/valuer"
import (
"github.com/SigNoz/signoz-semantic-conventions/gen/go/sigsemconv"
"github.com/SigNoz/signoz/pkg/valuer"
)
var (
ActionCategoryAccessControl = ActionCategory{valuer.NewString("access_control")}
ActionCategoryConfigurationChange = ActionCategory{valuer.NewString("configuration_change")}
ActionCategoryAccessControl = ActionCategory{valuer.NewString(sigsemconv.SigNozAuditActionCategoryAccessControl.Value.AsString())}
ActionCategoryConfigurationChange = ActionCategory{valuer.NewString(sigsemconv.SigNozAuditActionCategoryConfigurationChanges.Value.AsString())}
ActionCategoryDataAccess = ActionCategory{valuer.NewString("data_access")}
ActionCategorySystemEvent = ActionCategory{valuer.NewString("system_event")}
)

View File

@@ -3,9 +3,11 @@ package querybuildertypesv5
import (
"math"
"slices"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/metrictypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
const (
@@ -16,6 +18,9 @@ const (
HeatmapBucketMinColumn = "__bucket_min"
HeatmapBucketMaxColumn = "__bucket_max"
// A logs or traces heatmap counts events, so its field renders as count().
heatmapCountExpression = "count()"
DefaultNumBuckets = 60
// MaxLogScale is the resolution ClickHouse buckets every log heatmap at:
@@ -81,6 +86,68 @@ func (b *BucketOptions) ToHeatmapBucketing() HeatmapBucketing {
return resolved
}
// HeatmapAggregationField returns the field a logs or traces heatmap buckets,
// written as the bare field. ok is false for any other aggregation.
func HeatmapAggregationField(expression string) (telemetrytypes.TelemetryFieldKey, bool) {
keyText := strings.TrimSpace(expression)
if keyText == "" || strings.ContainsAny(keyText, "() \t\n") {
return telemetrytypes.TelemetryFieldKey{}, false
}
return telemetrytypes.GetFieldKeyFromKeyText(keyText), true
}
// HeatmapCountQuery splits a logs or traces heatmap query into the field its
// aggregation buckets and a copy of query that counts events in its place,
// which is what every cell holds.
func HeatmapCountQuery[T LogAggregation | TraceAggregation](query QueryBuilderQuery[T]) (*telemetrytypes.TelemetryFieldKey, QueryBuilderQuery[T], error) {
if len(query.Aggregations) != 1 {
return nil, query, errors.NewInvalidInputf(errors.CodeInvalidInput,
"a heatmap renders one bucket axis, but the query has %d aggregations", len(query.Aggregations))
}
query.Aggregations = slices.Clone(query.Aggregations)
var expression *string
switch aggregation := any(&query.Aggregations[0]).(type) {
case *LogAggregation:
expression = &aggregation.Expression
case *TraceAggregation:
expression = &aggregation.Expression
}
field, ok := HeatmapAggregationField(*expression)
if !ok {
return nil, query, errors.NewInvalidInputf(errors.CodeInvalidInput,
"a logs or traces heatmap counts events by the field named as its aggregation, got %q", *expression)
}
// An order on the aggregation names it by its expression, which decoding
// splits into a context, a name and a data type, and which metadata may
// complete, as it does any field.
query.Order = slices.Clone(query.Order)
for i := range query.Order {
key := query.Order[i].Key.TelemetryFieldKey
sameField := key.Name == field.Name &&
(field.FieldContext == telemetrytypes.FieldContextUnspecified || key.FieldContext == field.FieldContext) &&
(field.FieldDataType == telemetrytypes.FieldDataTypeUnspecified || key.FieldDataType == field.FieldDataType)
if key.Name == *expression || sameField {
query.Order[i].Key = OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: heatmapCountExpression}}
}
}
*expression = heatmapCountExpression
return &field, query, nil
}
// RequireHeatmapField keeps only the rows of query that carry field.
func RequireHeatmapField[T LogAggregation | TraceAggregation](query QueryBuilderQuery[T], field telemetrytypes.TelemetryFieldKey) QueryBuilderQuery[T] {
exists := telemetrytypes.TelemetryFieldKeyToText(&field) + " EXISTS"
if query.Filter != nil && strings.TrimSpace(query.Filter.Expression) != "" {
exists = "(" + query.Filter.Expression + ") AND " + exists
}
query.Filter = &Filter{Expression: exists}
return query
}
// This cannot be called in validateBucketCounting cuz metric type is resolved in querier.go.
func (a *MetricAggregation) VerifyAndApplyBucketOptions(bucketOptions *BucketOptions) error {
switch a.Type {

View File

@@ -934,15 +934,26 @@ func (r *QueryRangeRequest) validateBucketCounting() error {
continue
}
enabled++
// An AI query decodes to the traces spec, so it lands here too. Admitting
// either signal means capping Aggregations at one: each carries its own
// Meta.Buckets, and a heatmap renders against a single bucket axis.
case QueryBuilderQuery[LogAggregation], QueryBuilderQuery[TraceAggregation]:
return errors.New(errors.TypeUnsupported, errors.CodeUnsupported,
"heatmaps are not supported for the logs and traces signals yet")
// An AI query decodes to the traces spec, so it lands here too.
case QueryBuilderQuery[LogAggregation]:
if err := validateEventHeatmapQuery(r.RequestType, spec.Functions, spec.Having, aggregationExpressions(spec.Aggregations), spec.Disabled); err != nil {
return err
}
if spec.Disabled {
continue
}
enabled++
case QueryBuilderQuery[TraceAggregation]:
if err := validateEventHeatmapQuery(r.RequestType, spec.Functions, spec.Having, aggregationExpressions(spec.Aggregations), spec.Disabled); err != nil {
return err
}
if spec.Disabled {
continue
}
enabled++
default:
return errors.NewInvalidInputf(errors.CodeInvalidInput,
"%s requests support one metrics builder query, one formula over them, one clickhouse query, or one promql query, got %q", r.RequestType.StringValue(), envelope.Type.StringValue())
"%s requests support one builder query, one formula over them, one clickhouse query, or one promql query, got %q", r.RequestType.StringValue(), envelope.Type.StringValue())
}
}
@@ -1046,6 +1057,48 @@ func validateBucketCountingQuery(requestType RequestType, functions []Function,
return nil
}
// validateEventHeatmapQuery holds a logs or traces query to what a per-event
// heatmap can draw. A disabled query feeds a formula rather than the chart, so
// it aggregates as a time series.
func validateEventHeatmapQuery(requestType RequestType, functions []Function, having *Having, expressions []string, disabled bool) error {
if err := validateBucketCountingQuery(requestType, functions, having); err != nil {
return err
}
if disabled {
return nil
}
// Every aggregation carries its own Meta.Buckets, and a heatmap renders
// against a single bucket axis.
if len(expressions) != 1 {
return errors.NewInvalidInputf(errors.CodeInvalidInput,
"a heatmap renders one bucket axis, but the query has %d aggregations", len(expressions)).
WithAdditional("Keep the one aggregation whose distribution you want to plot")
}
if _, ok := HeatmapAggregationField(expressions[0]); !ok {
return errors.NewInvalidInputf(errors.CodeInvalidInput,
"a logs or traces heatmap counts events by the field named as its aggregation, got %q", expressions[0]).
WithAdditional("Use an aggregation such as duration_nano")
}
return nil
}
func aggregationExpressions[T LogAggregation | TraceAggregation](aggregations []T) []string {
expressions := make([]string, len(aggregations))
for i, aggregation := range aggregations {
switch a := any(aggregation).(type) {
case LogAggregation:
expressions[i] = a.Expression
case TraceAggregation:
expressions[i] = a.Expression
}
}
return expressions
}
func GetValidationOptions(requestType RequestType) []ValidationOption {
switch requestType {
case RequestTypeTimeSeries, RequestTypeHeatmap, RequestTypeDistribution:

View File

@@ -730,6 +730,44 @@ def build_scalar_query(
return {"type": "builder_query", "spec": spec}
def build_heatmap_query(
name: str,
signal: str,
expression: str,
*,
source: str | None = None,
group_by: list[dict] | None = None,
order: list[dict] | None = None,
filter_expression: str | None = None,
bucket_options: dict | None = None,
step_interval: int = DEFAULT_STEP_INTERVAL,
) -> dict:
"""Omitting bucket_options leaves the server's default log axis."""
spec: dict[str, Any] = {
"name": name,
"signal": signal,
"stepInterval": step_interval,
"aggregations": [{"expression": expression}],
}
if source:
spec["source"] = source
if group_by:
spec["groupBy"] = group_by
if order:
spec["order"] = order
if filter_expression:
spec["filter"] = {"expression": filter_expression}
if bucket_options is not None:
spec["bucketOptions"] = bucket_options
return {"type": "builder_query", "spec": spec}
def build_traces_scalar_query(
aggregations: list[dict],
*,

View File

@@ -30,55 +30,55 @@ def test_audit_list_all(
timestamp=now - timedelta(seconds=3),
resources={
"service.name": "signoz",
"signoz.audit.resource.kind": "alert-rule",
"signoz.audit.resource.id": "alert-001",
"signoz.resource.kind": "alert-rule",
"signoz.resource.id": "alert-001",
},
attributes={
"signoz.audit.principal.id": "user-010",
"signoz.audit.principal.email": "ops@acme.com",
"signoz.audit.principal.type": "user",
"signoz.audit.action": "create",
"signoz.principal.id": "user-010",
"signoz.principal.email": "ops@acme.com",
"signoz.principal.type": "user",
"signoz.audit.verb": "create",
"signoz.audit.outcome": "success",
},
body="ops@acme.com (user-010) created alert-rule (alert-001)",
event_name="alert-rule.created",
event_name="signoz.audit.create",
severity_text="INFO",
),
AuditLog(
timestamp=now - timedelta(seconds=2),
resources={
"service.name": "signoz",
"signoz.audit.resource.kind": "saved-view",
"signoz.audit.resource.id": "view-001",
"signoz.resource.kind": "saved-view",
"signoz.resource.id": "view-001",
},
attributes={
"signoz.audit.principal.id": "user-010",
"signoz.audit.principal.email": "ops@acme.com",
"signoz.audit.principal.type": "user",
"signoz.audit.action": "update",
"signoz.principal.id": "user-010",
"signoz.principal.email": "ops@acme.com",
"signoz.principal.type": "user",
"signoz.audit.verb": "update",
"signoz.audit.outcome": "success",
},
body="ops@acme.com (user-010) updated saved-view (view-001)",
event_name="saved-view.updated",
event_name="signoz.audit.update",
severity_text="INFO",
),
AuditLog(
timestamp=now - timedelta(seconds=1),
resources={
"service.name": "signoz",
"signoz.audit.resource.kind": "user",
"signoz.audit.resource.id": "user-020",
"signoz.resource.kind": "user",
"signoz.resource.id": "user-020",
},
attributes={
"signoz.audit.principal.id": "user-010",
"signoz.audit.principal.email": "ops@acme.com",
"signoz.audit.principal.type": "user",
"signoz.audit.action": "update",
"signoz.principal.id": "user-010",
"signoz.principal.email": "ops@acme.com",
"signoz.principal.type": "user",
"signoz.audit.verb": "update",
"signoz.audit.action_category": "access_control",
"signoz.audit.outcome": "success",
},
body="ops@acme.com (user-010) updated user (user-020)",
event_name="user.role.changed",
event_name="signoz.audit.update",
severity_text="INFO",
),
]
@@ -109,9 +109,12 @@ def test_audit_list_all(
assert len(rows) == 3
# Most recent first
assert rows[0]["data"]["event_name"] == "user.role.changed"
assert rows[1]["data"]["event_name"] == "saved-view.updated"
assert rows[2]["data"]["event_name"] == "alert-rule.created"
assert [row["data"]["event_name"] for row in rows] == ["signoz.audit.update", "signoz.audit.update", "signoz.audit.create"]
assert [row["data"]["body"] for row in rows] == [
"ops@acme.com (user-010) updated user (user-020)",
"ops@acme.com (user-010) updated saved-view (view-001)",
"ops@acme.com (user-010) created alert-rule (alert-001)",
]
# Verify event_name and body are present
assert rows[0]["data"]["body"] == "ops@acme.com (user-010) updated user (user-020)"
@@ -122,33 +125,33 @@ def test_audit_list_all(
"filter_expression,expected_count,expected_event_names",
[
pytest.param(
"signoz.audit.principal.id = 'user-001'",
"signoz.principal.id = 'user-001'",
3,
{"session.login", "dashboard.updated", "dashboard.created"},
{"signoz.audit.update", "signoz.audit.create"},
id="filter_by_principal_id",
),
pytest.param(
"signoz.audit.outcome = 'failure'",
1,
{"dashboard.deleted"},
{"signoz.audit.delete"},
id="filter_by_outcome_failure",
),
pytest.param(
"signoz.audit.resource.kind = 'dashboard' AND signoz.audit.resource.id = 'dash-001'",
"signoz.resource.kind = 'dashboard' AND signoz.resource.id = 'dash-001'",
3,
{"dashboard.deleted", "dashboard.updated", "dashboard.created"},
{"signoz.audit.delete", "signoz.audit.update", "signoz.audit.create"},
id="filter_by_resource_kind_and_id",
),
pytest.param(
"signoz.audit.principal.type = 'service_account'",
"signoz.principal.type = 'service_account'",
1,
{"serviceaccount.apikey.created"},
{"signoz.audit.create"},
id="filter_by_principal_type",
),
pytest.param(
"signoz.audit.resource.kind = 'dashboard' AND signoz.audit.action = 'delete'",
"signoz.resource.kind = 'dashboard' AND signoz.audit.verb = 'delete'",
1,
{"dashboard.deleted"},
{"signoz.audit.delete"},
id="filter_by_resource_kind_and_action",
),
],
@@ -170,94 +173,92 @@ def test_audit_filter(
timestamp=now - timedelta(seconds=5),
resources={
"service.name": "signoz",
"signoz.audit.resource.kind": "dashboard",
"signoz.audit.resource.id": "dash-001",
"signoz.resource.kind": "dashboard",
"signoz.resource.id": "dash-001",
},
attributes={
"signoz.audit.principal.id": "user-001",
"signoz.audit.principal.email": "alice@acme.com",
"signoz.audit.principal.type": "user",
"signoz.audit.action": "create",
"signoz.audit.action_category": "configuration_change",
"signoz.principal.id": "user-001",
"signoz.principal.email": "alice@acme.com",
"signoz.principal.type": "user",
"signoz.audit.verb": "create",
"signoz.audit.action_category": "configuration_changes",
"signoz.audit.outcome": "success",
},
body="alice@acme.com created dashboard",
event_name="dashboard.created",
event_name="signoz.audit.create",
),
AuditLog(
timestamp=now - timedelta(seconds=4),
resources={
"service.name": "signoz",
"signoz.audit.resource.kind": "dashboard",
"signoz.audit.resource.id": "dash-001",
"signoz.resource.kind": "dashboard",
"signoz.resource.id": "dash-001",
},
attributes={
"signoz.audit.principal.id": "user-001",
"signoz.audit.principal.email": "alice@acme.com",
"signoz.audit.principal.type": "user",
"signoz.audit.action": "update",
"signoz.audit.action_category": "configuration_change",
"signoz.principal.id": "user-001",
"signoz.principal.email": "alice@acme.com",
"signoz.principal.type": "user",
"signoz.audit.verb": "update",
"signoz.audit.action_category": "configuration_changes",
"signoz.audit.outcome": "success",
},
body="alice@acme.com updated dashboard",
event_name="dashboard.updated",
event_name="signoz.audit.update",
),
AuditLog(
timestamp=now - timedelta(seconds=3),
resources={
"service.name": "signoz",
"signoz.audit.resource.kind": "dashboard",
"signoz.audit.resource.id": "dash-001",
"signoz.resource.kind": "dashboard",
"signoz.resource.id": "dash-001",
},
attributes={
"signoz.audit.principal.id": "user-002",
"signoz.audit.principal.email": "viewer@acme.com",
"signoz.audit.principal.type": "user",
"signoz.audit.action": "delete",
"signoz.audit.action_category": "configuration_change",
"signoz.principal.id": "user-002",
"signoz.principal.email": "viewer@acme.com",
"signoz.principal.type": "user",
"signoz.audit.verb": "delete",
"signoz.audit.action_category": "configuration_changes",
"signoz.audit.outcome": "failure",
"signoz.audit.error.type": "forbidden",
"signoz.audit.error.code": "authz_forbidden",
"exception.type": "forbidden",
},
body="viewer@acme.com failed to delete dashboard",
event_name="dashboard.deleted",
event_name="signoz.audit.delete",
severity_text="ERROR",
),
AuditLog(
timestamp=now - timedelta(seconds=2),
resources={
"service.name": "signoz",
"signoz.audit.resource.kind": "serviceaccount",
"signoz.audit.resource.id": "sa-001",
"signoz.resource.kind": "serviceaccount",
"signoz.resource.id": "sa-001",
},
attributes={
"signoz.audit.principal.id": "sa-001",
"signoz.audit.principal.email": "",
"signoz.audit.principal.type": "service_account",
"signoz.audit.action": "create",
"signoz.principal.id": "sa-001",
"signoz.principal.type": "service_account",
"signoz.audit.verb": "create",
"signoz.audit.action_category": "access_control",
"signoz.audit.outcome": "success",
},
body="sa-001 created serviceaccount",
event_name="serviceaccount.apikey.created",
event_name="signoz.audit.create",
),
AuditLog(
timestamp=now - timedelta(seconds=1),
resources={
"service.name": "signoz",
"signoz.audit.resource.kind": "session",
"signoz.audit.resource.id": "*",
"signoz.resource.kind": "session",
"signoz.resource.id": "*",
},
attributes={
"signoz.audit.principal.id": "user-001",
"signoz.audit.principal.email": "alice@acme.com",
"signoz.audit.principal.type": "user",
"signoz.audit.action": "login",
"signoz.principal.id": "user-001",
"signoz.principal.email": "alice@acme.com",
"signoz.principal.type": "user",
"signoz.audit.verb": "create",
"signoz.audit.action_category": "access_control",
"signoz.audit.outcome": "success",
},
body="alice@acme.com login session",
event_name="session.login",
body="alice@acme.com created session",
event_name="signoz.audit.create",
),
]
)
@@ -304,51 +305,51 @@ def test_audit_scalar_count_failures(
timestamp=now - timedelta(seconds=3),
resources={
"service.name": "signoz",
"signoz.audit.resource.kind": "dashboard",
"signoz.audit.resource.id": "dash-100",
"signoz.resource.kind": "dashboard",
"signoz.resource.id": "dash-100",
},
attributes={
"signoz.audit.principal.id": "user-050",
"signoz.audit.principal.type": "user",
"signoz.audit.action": "delete",
"signoz.principal.id": "user-050",
"signoz.principal.type": "user",
"signoz.audit.verb": "delete",
"signoz.audit.outcome": "failure",
},
body="user-050 failed to delete dashboard",
event_name="dashboard.deleted",
event_name="signoz.audit.delete",
severity_text="ERROR",
),
AuditLog(
timestamp=now - timedelta(seconds=2),
resources={
"service.name": "signoz",
"signoz.audit.resource.kind": "alert-rule",
"signoz.audit.resource.id": "alert-200",
"signoz.resource.kind": "alert-rule",
"signoz.resource.id": "alert-200",
},
attributes={
"signoz.audit.principal.id": "user-060",
"signoz.audit.principal.type": "user",
"signoz.audit.action": "update",
"signoz.principal.id": "user-060",
"signoz.principal.type": "user",
"signoz.audit.verb": "update",
"signoz.audit.outcome": "failure",
},
body="user-060 failed to update alert-rule",
event_name="alert-rule.updated",
event_name="signoz.audit.update",
severity_text="ERROR",
),
AuditLog(
timestamp=now - timedelta(seconds=1),
resources={
"service.name": "signoz",
"signoz.audit.resource.kind": "dashboard",
"signoz.audit.resource.id": "dash-100",
"signoz.resource.kind": "dashboard",
"signoz.resource.id": "dash-100",
},
attributes={
"signoz.audit.principal.id": "user-050",
"signoz.audit.principal.type": "user",
"signoz.audit.action": "update",
"signoz.principal.id": "user-050",
"signoz.principal.type": "user",
"signoz.audit.verb": "update",
"signoz.audit.outcome": "success",
},
body="user-050 updated dashboard",
event_name="dashboard.updated",
event_name="signoz.audit.update",
),
]
)
@@ -394,17 +395,17 @@ def test_audit_does_not_leak_into_logs(
timestamp=now - timedelta(seconds=1),
resources={
"service.name": "signoz",
"signoz.audit.resource.kind": "organization",
"signoz.audit.resource.id": "org-999",
"signoz.resource.kind": "organization",
"signoz.resource.id": "org-999",
},
attributes={
"signoz.audit.principal.id": "user-admin",
"signoz.audit.principal.type": "user",
"signoz.audit.action": "update",
"signoz.principal.id": "user-admin",
"signoz.principal.type": "user",
"signoz.audit.verb": "update",
"signoz.audit.outcome": "success",
},
body="user-admin updated organization (org-999)",
event_name="organization.updated",
event_name="signoz.audit.update",
),
]
)
@@ -430,5 +431,5 @@ def test_audit_does_not_leak_into_logs(
rows = response.json()["data"]["data"]["results"][0].get("rows") or []
audit_bodies = [row["data"]["body"] for row in rows if "signoz.audit" in row["data"].get("attributes_string", {}).get("signoz.audit.action", "")]
audit_bodies = [row["data"]["body"] for row in rows if "signoz.audit" in row["data"].get("attributes_string", {}).get("signoz.audit.verb", "")]
assert len(audit_bodies) == 0

View File

@@ -0,0 +1,83 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
import pytest
from fixtures import types
from fixtures.audit import AuditLog
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.querier import (
RequestType,
assert_distribution_sums_heatmap,
build_group_by_field,
build_heatmap_query,
build_linear_bucket_options,
get_all_series,
get_buckets,
index_series_by_label,
make_query_request,
)
def test_audit_heatmap(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_audit_logs: Callable[[list[AuditLog]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
changed_by_action = {"update": [3, 8], "delete": [25]}
insert_audit_logs(
[
AuditLog(
timestamp=now - timedelta(seconds=index + 1),
resources={"service.name": "signoz", "signoz.resource.kind": "dashboard"},
attributes={"signoz.audit.verb": action, "signoz.audit.fields_changed": fields_changed},
event_name=f"signoz.audit.{action}",
)
for action, values in changed_by_action.items()
for index, fields_changed in enumerate(values)
]
# an event without the field is not counted
+ [
AuditLog(
timestamp=now - timedelta(seconds=5),
resources={"service.name": "signoz", "signoz.resource.kind": "dashboard"},
attributes={"signoz.audit.verb": "update"},
event_name="signoz.audit.update",
)
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
end_ms = int((now + timedelta(seconds=5)).timestamp() * 1000)
queries = [
build_heatmap_query(
"A",
"logs",
"signoz.audit.fields_changed",
source="audit",
group_by=[build_group_by_field("signoz.audit.verb", "string", "attribute")],
filter_expression="signoz.resource.kind = 'dashboard'",
bucket_options=build_linear_bucket_options(20, 4),
)
]
response = make_query_request(signoz, token, start_ms, end_ms, queries, request_type=RequestType.HEATMAP)
assert response.status_code == HTTPStatus.OK, response.text
assert_distribution_sums_heatmap(signoz, token, start_ms, end_ms, queries, response)
data = response.json()
# 20 split into 4 gives (0, 5], (5, 10], (10, 15], (15, 20]; the axis stops
# at the highest bucket reached, and 25 is past 20 so it counts in the
# trailing overflow
buckets = get_buckets(data, "A")
assert buckets == pytest.approx([0.0, 5.0, 10.0])
series_by_action = index_series_by_label(get_all_series(data, "A"), "signoz.audit.verb")
totals_by_action = {action: [sum(column["values"][slot] for column in series["values"]) for slot in range(len(buckets) + 1)] for action, series in series_by_action.items()}
assert totals_by_action == {
"update": [0, 1, 1, 0],
"delete": [0, 0, 0, 1],
}

View File

@@ -0,0 +1,207 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
import pytest
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.logs import Logs
from fixtures.querier import (
RequestType,
assert_distribution_sums_heatmap,
build_aggregation,
build_group_by_field,
build_heatmap_query,
build_linear_bucket_options,
build_log_bucket_options,
build_order_by,
build_scalar_query,
get_all_series,
get_buckets,
get_error_message,
get_heatmap_columns,
index_series_by_label,
make_query_request,
)
def test_linear_heatmap_grouped(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
bytes_by_service = {"heatmap-svc-a": [50, 250], "heatmap-svc-b": [950, 5000]}
insert_logs(
[
Logs(
timestamp=now - timedelta(seconds=index + 1),
resources={"service.name": service},
attributes={"response_bytes": response_bytes},
body=f"{service} served {response_bytes} bytes",
)
for service, values in bytes_by_service.items()
for index, response_bytes in enumerate(values)
]
# a log without the field is not counted
+ [
Logs(
timestamp=now - timedelta(seconds=index + 1),
resources={"service.name": "heatmap-svc-a"},
body="no response size",
)
for index in range(2)
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
end_ms = int((now + timedelta(seconds=5)).timestamp() * 1000)
queries = [
build_heatmap_query(
"A",
"logs",
"response_bytes",
group_by=[build_group_by_field("service.name", "string", "resource")],
filter_expression="service.name IN ('heatmap-svc-a', 'heatmap-svc-b')",
bucket_options=build_linear_bucket_options(1000, 10),
)
]
response = make_query_request(signoz, token, start_ms, end_ms, queries, request_type=RequestType.HEATMAP)
assert response.status_code == HTTPStatus.OK, response.text
assert_distribution_sums_heatmap(signoz, token, start_ms, end_ms, queries, response)
data = response.json()
# 1000 split into 10 gives (0, 100], (100, 200], ... (900, 1000], and 5000
# is past 1000 so it counts in the trailing overflow
buckets = get_buckets(data, "A")
assert buckets == pytest.approx([0.0, 100.0, 200.0, 300.0, 400.0, 500.0, 600.0, 700.0, 800.0, 900.0, 1000.0])
series_by_service = index_series_by_label(get_all_series(data, "A"), "service.name")
assert set(series_by_service) == set(bytes_by_service)
totals_by_service = {service: [sum(column["values"][slot] for column in series["values"]) for slot in range(len(buckets) + 1)] for service, series in series_by_service.items()}
assert totals_by_service == {
"heatmap-svc-a": [0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0],
"heatmap-svc-b": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1],
}
def test_log_heatmap(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_logs(
[
Logs(
timestamp=now - timedelta(seconds=index + 1),
resources={"service.name": "heatmap-log-axis"},
attributes={"queue_depth": queue_depth},
body=f"queue depth {queue_depth}",
)
for index, queue_depth in enumerate([100, 300, 600])
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
end_ms = int((now + timedelta(seconds=5)).timestamp() * 1000)
queries = [
build_heatmap_query(
"A",
"logs",
"attribute.queue_depth",
order=[build_order_by("attribute.queue_depth")],
filter_expression="service.name = 'heatmap-log-axis'",
bucket_options=build_log_bucket_options(0),
)
]
response = make_query_request(signoz, token, start_ms, end_ms, queries, request_type=RequestType.HEATMAP)
assert response.status_code == HTTPStatus.OK, response.text
assert_distribution_sums_heatmap(signoz, token, start_ms, end_ms, queries, response)
data = response.json()
# a doubling per bucket, with one rung under the lowest bucket reached so
# its lower bound is on the axis; (128, 256] holds nothing but stays
buckets = get_buckets(data, "A")
assert buckets == pytest.approx([64.0, 128.0, 256.0, 512.0, 1024.0])
assert [sum(column["values"][slot] for column in get_heatmap_columns(data, "A")) for slot in range(len(buckets) + 1)] == [0, 1, 0, 1, 1, 0]
def test_default_axis(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_logs(
[
Logs(
timestamp=now - timedelta(seconds=index + 1),
resources={"service.name": "heatmap-default-axis"},
attributes={"batch_size": 1000},
body="batch of 1000",
)
for index in range(2)
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
end_ms = int((now + timedelta(seconds=5)).timestamp() * 1000)
queries = [build_heatmap_query("A", "logs", "batch_size", filter_expression="service.name = 'heatmap-default-axis'")]
response = make_query_request(signoz, token, start_ms, end_ms, queries, request_type=RequestType.HEATMAP)
assert response.status_code == HTTPStatus.OK, response.text
assert_distribution_sums_heatmap(signoz, token, start_ms, end_ms, queries, response)
data = response.json()
# no bucketOptions is the finest log axis, 16 buckets per doubling: 1000
# sits in (2^(159/16), 2^10]
buckets = get_buckets(data, "A")
totals = [sum(column["values"][slot] for column in get_heatmap_columns(data, "A")) for slot in range(len(buckets) + 1)]
holding = [slot for slot, total in enumerate(totals) if total]
assert holding == [len(buckets) - 1]
assert totals[holding[0]] == 2
assert buckets[-2:] == pytest.approx([2 ** (159 / 16), 1024.0])
@pytest.mark.parametrize(
"aggregations, expected_message",
[
pytest.param(
[build_aggregation("count()")],
'a logs or traces heatmap counts events by the field named as its aggregation, got "count()"',
id="count_aggregation",
),
pytest.param(
[build_aggregation("response_bytes"), build_aggregation("queue_depth")],
"a heatmap renders one bucket axis, but the query has 2 aggregations",
id="two_aggregations",
),
],
)
def test_heatmap_rejections(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
aggregations: list[dict],
expected_message: str,
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_query_request(
signoz,
token,
int((now - timedelta(minutes=5)).timestamp() * 1000),
int(now.timestamp() * 1000),
[build_scalar_query("A", "logs", aggregations)],
request_type=RequestType.HEATMAP,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
assert expected_message in get_error_message(response.json())

View File

@@ -0,0 +1,113 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
import pytest
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.querier import (
RequestType,
assert_distribution_sums_heatmap,
build_heatmap_query,
build_linear_bucket_options,
build_log_bucket_options,
get_buckets,
get_heatmap_columns,
make_query_request,
)
from fixtures.traces import TraceIdGenerator, Traces, TracesKind
def test_duration_heatmap(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
durations = [timedelta(0)] + [timedelta(milliseconds=5)] * 6 + [timedelta(seconds=1)] * 3 + [timedelta(seconds=30)]
insert_traces(
[
Traces(
timestamp=now - timedelta(minutes=2, seconds=index),
duration=duration,
trace_id=TraceIdGenerator.trace_id(),
span_id=TraceIdGenerator.span_id(),
name="GET /cart",
kind=TracesKind.SPAN_KIND_SERVER,
resources={"service.name": "heatmap-cart"},
)
for index, duration in enumerate(durations)
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
end_ms = int(now.timestamp() * 1000)
queries = [build_heatmap_query("A", "traces", "duration_nano", filter_expression="service.name = 'heatmap-cart'", bucket_options=build_log_bucket_options(0))]
response = make_query_request(signoz, token, start_ms, end_ms, queries, request_type=RequestType.HEATMAP)
assert response.status_code == HTTPStatus.OK, response.text
assert_distribution_sums_heatmap(signoz, token, start_ms, end_ms, queries, response)
data = response.json()
# a doubling per bucket in nanoseconds: 5ms is in (2^22, 2^23], 1s in
# (2^29, 2^30] and 30s in (2^34, 2^35], and 2^22 is the rung under 5ms; a
# 0ns span is counted at 0
buckets = get_buckets(data, "A")
assert buckets == pytest.approx([0.0] + [float(2**power) for power in range(22, 36)])
totals = [sum(column["values"][slot] for column in get_heatmap_columns(data, "A")) for slot in range(len(buckets) + 1)]
assert {buckets[slot]: total for slot, total in enumerate(totals) if total} == {0.0: 1, 2.0**23: 6, 2.0**30: 3, 2.0**35: 1}
assert sum(totals) == len(durations)
def test_span_attribute_heatmap(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
rows_returned = [5, 5, 5, 45, 45, 250]
insert_traces(
[
Traces(
timestamp=now - timedelta(minutes=2, seconds=index),
trace_id=TraceIdGenerator.trace_id(),
span_id=TraceIdGenerator.span_id(),
name="SELECT orders",
kind=TracesKind.SPAN_KIND_CLIENT,
resources={"service.name": "heatmap-orders-db"},
attributes={"db.rows_returned": rows},
)
for index, rows in enumerate(rows_returned)
]
# a span without the attribute is not counted
+ [
Traces(
timestamp=now - timedelta(minutes=2, seconds=index),
trace_id=TraceIdGenerator.trace_id(),
span_id=TraceIdGenerator.span_id(),
name="BEGIN",
kind=TracesKind.SPAN_KIND_CLIENT,
resources={"service.name": "heatmap-orders-db"},
)
for index in range(2)
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
end_ms = int(now.timestamp() * 1000)
queries = [build_heatmap_query("A", "traces", "db.rows_returned", filter_expression="service.name = 'heatmap-orders-db'", bucket_options=build_linear_bucket_options(100, 10))]
response = make_query_request(signoz, token, start_ms, end_ms, queries, request_type=RequestType.HEATMAP)
assert response.status_code == HTTPStatus.OK, response.text
assert_distribution_sums_heatmap(signoz, token, start_ms, end_ms, queries, response)
data = response.json()
# 100 split into 10 gives (0, 10], (10, 20], ... (90, 100]: 5 is in the
# first, 45 in (40, 50], and 250 is past 100 so it counts in the overflow
buckets = get_buckets(data, "A")
assert buckets == pytest.approx([0.0, 10.0, 20.0, 30.0, 40.0, 50.0])
assert [sum(column["values"][slot] for column in get_heatmap_columns(data, "A")) for slot in range(len(buckets) + 1)] == [0, 3, 0, 0, 0, 2, 1]