Compare commits

..

13 Commits

Author SHA1 Message Date
nityanandagohain
4d3b84b10e Merge remote-tracking branch 'origin/main' into issue_577 2026-11-03 11:56:30 +05:30
nityanandagohain
c0a8c2e09a Merge remote-tracking branch 'origin/issue_577' into issue_577 2026-11-03 11:56:22 +05:30
nityanandagohain
285edf0442 fix: update comments 2026-11-03 11:56:05 +05:30
Nityananda Gohain
43cee83b85 Merge branch 'main' into issue_577 2026-11-03 11:46:04 +05:30
Abhi kumar
65084ba064 fix(dashboards): label unstacked bar layout as overlap (#13369)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
cacheci / tests (push) Waiting to run
Release Drafter / update_release_draft (push) Waiting to run
#### Description

- Bar chart "Series layout" offered "Side by side", but unstacked bars
render overlapping at the same x. Renamed to "Overlap" with a matching
tile drawing and help text, consistent with the area chart's option.

Before
<img width="388" height="157" alt="image"
src="https://github.com/user-attachments/assets/0b9ecab4-ece8-472c-b924-66c053d2cdc9"
/>


After
<img width="420" height="184" alt="image"
src="https://github.com/user-attachments/assets/680c7bdc-0c49-4111-81f5-e0c633df6715"
/>


#### Issues closed by this PR

Closes SigNoz/pulse-pod#673
2026-11-03 06:09:19 +00:00
nityanandagohain
7fbf97429c fix: perf improvements 2026-11-03 11:18:28 +05:30
nityanandagohain
0c6ac740a6 fix: perf improvements 2026-11-03 11:05:01 +05:30
Naman Verma
026369948b perf: divide cache buckets for a query into cells in a grid (#13333)
<!--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


https://app.notion.com/p/signoz/Dividing-Cache-Buckets-into-Cells-3fcfcc6bcd19819db159d2f7322c2dd6?source=copy_link

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

Closes https://github.com/SigNoz/pulse-pod/issues/527
2026-11-03 05:31:12 +00:00
nityanandagohain
411717b72b fix: limit values to just 1 2026-11-02 21:27:16 +05:30
nityanandagohain
3deb3351bb Merge remote-tracking branch 'origin/issue_577' into issue_577 2026-11-02 21:16:33 +05:30
nityanandagohain
ddc8a5ecb7 fix: address comment 2026-11-02 21:15:36 +05:30
Nityananda Gohain
55d892c075 Merge branch 'main' into issue_577 2026-11-02 21:04:01 +05:30
nityanandagohain
89df02dac7 feat(stats): report AI observability usage from the stats endpoint 2026-11-02 20:55:36 +05:30
25 changed files with 992 additions and 143 deletions

View File

@@ -106,8 +106,8 @@ jobs:
- name: webdriver
if: matrix.suite == 'callbackauthn' || matrix.suite == 'basepath'
run: |
wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | sudo gpg --batch --yes --dearmor -o /usr/share/keyrings/google-chrome.gpg
echo "deb [signed-by=/usr/share/keyrings/google-chrome.gpg] http://dl.google.com/linux/chrome/deb/ stable main" | sudo tee -a /etc/apt/sources.list.d/google-chrome.list
wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | sudo apt-key add -
echo "deb http://dl.google.com/linux/chrome/deb/ stable main" | sudo tee -a /etc/apt/sources.list.d/google-chrome.list
sudo apt-get update -qqy
sudo apt-get -qqy install google-chrome-stable
CHROME_VERSION=$(google-chrome-stable --version)

View File

@@ -164,12 +164,12 @@ export const TILE_DRAWINGS = {
<path d="M4 20 C 20 20, 22 12, 32 12 S 44 4, 60 4" />
</TileSvg>
),
barsSideBySide: (
barsOverlap: (
<TileSvg>
<rect x={10} y={10} width={7} height={12} {...FILLED} opacity={0.5} />
<rect x={18} y={4} width={7} height={18} {...FILLED} />
<rect x={36} y={12} width={7} height={10} {...FILLED} opacity={0.5} />
<rect x={44} y={7} width={7} height={15} {...FILLED} />
<rect x={14} y={6} width={11} height={16} {...FILLED} opacity={0.5} />
<rect x={14} y={12} width={11} height={10} {...FILLED} />
<rect x={38} y={10} width={11} height={12} {...FILLED} opacity={0.5} />
<rect x={38} y={14} width={11} height={8} {...FILLED} />
</TileSvg>
),
barsStacked: (

View File

@@ -56,7 +56,7 @@ function VisualizationSection({
value?.timePreference ?? DashboardtypesTimePreferenceDTO.global_time;
const barLayout = value?.stackedBarChart
? BarLayout.STACKED
: BarLayout.SIDE_BY_SIDE;
: BarLayout.OVERLAP;
const stack = value?.stack ?? defaultValue?.stack;
return (

View File

@@ -4,15 +4,15 @@ import type { ConfigTileItem } from '../../controls/ConfigTiles/ConfigTiles';
import { TILE_DRAWINGS } from '../../controls/drawings/tileDrawings';
export enum BarLayout {
SIDE_BY_SIDE = 'side',
OVERLAP = 'overlap',
STACKED = 'stacked',
}
export const BAR_LAYOUT_OPTIONS: ConfigTileItem<BarLayout>[] = [
{
value: BarLayout.SIDE_BY_SIDE,
label: 'Side by side',
drawing: TILE_DRAWINGS.barsSideBySide,
value: BarLayout.OVERLAP,
label: 'Overlap',
drawing: TILE_DRAWINGS.barsOverlap,
},
{
value: BarLayout.STACKED,
@@ -22,8 +22,7 @@ export const BAR_LAYOUT_OPTIONS: ConfigTileItem<BarLayout>[] = [
];
export const BAR_LAYOUT_HELP: Record<BarLayout, string> = {
[BarLayout.SIDE_BY_SIDE]:
'Each series gets its own bar, side by side, for easy comparison.',
[BarLayout.OVERLAP]: 'Each series is drawn from zero and may hide others.',
[BarLayout.STACKED]:
'Bars sit on top of each other, so the height shows the total.',
};

5
pkg/cache/cache.go vendored
View File

@@ -16,6 +16,11 @@ type Cache interface {
// Get gets the cacheble entity in the dest entity passed.
Get(ctx context.Context, orgID valuer.UUID, cacheKey string, dest cachetypes.Cacheable) error
// GetMany gets the cacheable entities of cacheKeys into dests, one per key in
// the same order. A key that is not found or could not be read sets its dest
// to nil. The returned error joins the read failures; a miss alone is not one.
GetMany(ctx context.Context, orgID valuer.UUID, cacheKeys []string, dests []cachetypes.Cacheable) error
// Delete deletes the cacheable entity from cache
Delete(ctx context.Context, orgID valuer.UUID, cacheKey string)

View File

@@ -196,6 +196,28 @@ func (provider *provider) Get(ctx context.Context, orgID valuer.UUID, cacheKey s
return errors.NewInternalf(errors.CodeInternal, "unrecognized: (value: \"%s\")", reflect.TypeOf(cachedData).String())
}
func (provider *provider) GetMany(ctx context.Context, orgID valuer.UUID, cacheKeys []string, dests []cachetypes.Cacheable) error {
if len(dests) != len(cacheKeys) {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "got %d destinations for %d keys", len(dests), len(cacheKeys))
}
var errs []error
for i, cacheKey := range cacheKeys {
err := provider.Get(ctx, orgID, cacheKey, dests[i])
if err == nil {
continue
}
dests[i] = nil
if !errors.Ast(err, errors.TypeNotFound) {
errs = append(errs, err)
}
}
if len(errs) == 0 {
return nil
}
return errors.Join(errs...)
}
func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, cacheKey string) {
_, span := provider.settings.Tracer().Start(ctx, "memory.delete", trace.WithAttributes(
attribute.String(semconv.AttributeDBSystem, "memory"),

View File

@@ -260,3 +260,27 @@ func TestCloneableConcurrentSetGet(t *testing.T) {
assert.NotSame(t, cachedCloneable, cloneables[i])
}
}
func TestGetMany(t *testing.T) {
cache, err := New(context.Background(), factorytest.NewSettings(), cache.Config{Provider: "memory", Memory: cache.Memory{
NumCounters: 10 * 1000,
MaxCost: 1 << 26,
}})
require.NoError(t, err)
orgID := valuer.GenerateUUID()
require.NoError(t, cache.Set(context.Background(), orgID, "first", &CloneableA{Key: "first", Value: 1}, 10*time.Second))
require.NoError(t, cache.Set(context.Background(), orgID, "third", &CloneableA{Key: "third", Value: 3}, 10*time.Second))
// bytes that are not JSON cannot be read into a CloneableA
memory := cache.(*provider)
memory.cc.SetWithTTL(strings.Join([]string{orgID.StringValue(), "fourth"}, "::"), []byte("not json"), 1, 10*time.Second)
memory.cc.Wait()
dests := []cachetypes.Cacheable{new(CloneableA), new(CloneableA), new(CloneableA), new(CloneableA)}
err = cache.GetMany(context.Background(), orgID, []string{"first", "second", "third", "fourth"}, dests)
// the unreadable key is reported and nilled, the readable ones are still served
assert.Error(t, err)
assert.Equal(t, []cachetypes.Cacheable{&CloneableA{Key: "first", Value: 1}, nil, &CloneableA{Key: "third", Value: 3}, nil}, dests)
}

View File

@@ -66,6 +66,49 @@ func (c *provider) Get(ctx context.Context, orgID valuer.UUID, cacheKey string,
return nil
}
func (c *provider) GetMany(ctx context.Context, orgID valuer.UUID, cacheKeys []string, dests []cachetypes.Cacheable) error {
if len(dests) != len(cacheKeys) {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "got %d destinations for %d keys", len(dests), len(cacheKeys))
}
if len(cacheKeys) == 0 {
return nil
}
updatedCacheKeys := make([]string, len(cacheKeys))
for i, cacheKey := range cacheKeys {
updatedCacheKeys[i] = strings.Join([]string{orgID.StringValue(), cacheKey}, "::")
}
values, err := c.client.MGet(ctx, updatedCacheKeys...).Result()
if err != nil {
for i := range dests {
dests[i] = nil
}
return err
}
var errs []error
for i, value := range values {
// MGET answers a missing key with nil and a present one with its string
switch stored := value.(type) {
case nil:
dests[i] = nil
case string:
if err := dests[i].UnmarshalBinary([]byte(stored)); err != nil {
dests[i] = nil
errs = append(errs, err)
}
default:
dests[i] = nil
errs = append(errs, errors.NewInternalf(errors.CodeInternal, "unexpected value of type %T for key %s", value, cacheKeys[i]))
}
}
if len(errs) == 0 {
return nil
}
return errors.Join(errs...)
}
func (c *provider) Delete(ctx context.Context, orgID valuer.UUID, cacheKey string) {
c.DeleteMany(ctx, orgID, []string{cacheKey})
}

View File

@@ -13,6 +13,7 @@ import (
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/go-redis/redismock/v9"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type CacheableA struct {
@@ -58,3 +59,28 @@ func TestSet(t *testing.T) {
assert.NoError(t, cache.Set(context.Background(), orgID, "key", cacheable, 10*time.Second))
assert.NoError(t, mock.ExpectationsWereMet())
}
func TestGetMany(t *testing.T) {
db, mock := redismock.NewClientMock()
providerSettings := instrumentationtest.New().ToProviderSettings()
cache := &provider{client: db, settings: factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/cache/rediscache")}
orgID := valuer.GenerateUUID()
stored, err := json.Marshal(&CacheableA{Key: "first", Value: 1, Expiry: time.Microsecond})
require.NoError(t, err)
// one round trip for all keys, with the missing one answered as nil and
// the third holding something that is not a CacheableA
mock.ExpectMGet(
strings.Join([]string{orgID.StringValue(), "first"}, "::"),
strings.Join([]string{orgID.StringValue(), "second"}, "::"),
strings.Join([]string{orgID.StringValue(), "third"}, "::"),
).SetVal([]any{string(stored), nil, "not json"})
dests := []cachetypes.Cacheable{new(CacheableA), new(CacheableA), new(CacheableA)}
err = cache.GetMany(context.Background(), orgID, []string{"first", "second", "third"}, dests)
// the unreadable key is reported and nilled, the readable one is still served
assert.Error(t, err)
assert.Equal(t, []cachetypes.Cacheable{&CacheableA{Key: "first", Value: 1, Expiry: time.Microsecond}, nil, nil}, dests)
assert.NoError(t, mock.ExpectationsWereMet())
}

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
package querier
import (
"cmp"
"context"
"encoding/json"
"fmt"
@@ -11,11 +12,16 @@ import (
"github.com/SigNoz/signoz/pkg/cache"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/types/cachetypes"
"github.com/SigNoz/signoz/pkg/types/metrictypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/valuer"
)
// bucketCacheCellSteps is the grid cell width in steps; no cached bucket
// crosses a cell boundary.
const bucketCacheCellSteps = 60
// BucketCache is the interface for bucket-based caching.
type BucketCache interface {
// cached portion + list of gaps to fetch
@@ -58,44 +64,59 @@ func (bc *bucketCache) GetMissRanges(
bc.logger.DebugContext(ctx, "getting miss ranges", slog.String("fingerprint", q.Fingerprint()), slog.Uint64("start", startMs), slog.Uint64("end", endMs))
// Generate cache key
cacheKey := bc.generateCacheKey(q)
bc.logger.DebugContext(ctx, "cache key", slog.String("cache_key", cacheKey))
// Try to get cached data
var data qbtypes.CachedData
err := bc.cache.Get(ctx, orgID, cacheKey, &data)
if err != nil {
if !errors.Ast(err, errors.TypeNotFound) {
bc.logger.ErrorContext(ctx, "error getting cached data", errors.Attr(err))
}
// No cached data, need to fetch entire range
missing = []*qbtypes.TimeRange{{From: startMs, To: endMs}}
return nil, missing
}
// Extract step interval if this is a builder query
stepMs := uint64(step.Milliseconds())
// Find missing ranges with step alignment
missing = bc.findMissingRangesWithStep(data.Buckets, startMs, endMs, stepMs)
bc.logger.DebugContext(ctx, "missing ranges", slog.Any("missing", missing), slog.Uint64("step", stepMs))
// No cached data, need to fetch entire range
missing = []*qbtypes.TimeRange{{From: startMs, To: endMs}}
// If no cached data overlaps with requested range, return empty result
if len(data.Buckets) == 0 {
if endMs <= startMs {
return nil, missing
}
cellWidthMs := bucketCacheCellSteps * stepMs
firstCellIdx, lastCellIdx := deriveCellIdx(startMs, cellWidthMs), deriveCellIdx(endMs-1, cellWidthMs)
// Gather the buckets of every cell the window touches
cellKeys := make([]string, 0, lastCellIdx-firstCellIdx+1)
cellEntries := make([]cachetypes.Cacheable, 0, lastCellIdx-firstCellIdx+1)
for cellIdx := firstCellIdx; cellIdx <= lastCellIdx; cellIdx++ {
cellKeys = append(cellKeys, bc.generateCacheKey(q, cellIdx))
cellEntries = append(cellEntries, &qbtypes.CachedData{})
}
// A cell that could not be read comes back nil like a miss, so the rest
// are still worth serving
if err := bc.cache.GetMany(ctx, orgID, cellKeys, cellEntries); err != nil {
bc.logger.ErrorContext(ctx, "error getting cached data", errors.Attr(err))
}
cachedBuckets := []*qbtypes.CachedBucket{}
cachedWarnings := []string{}
for _, cellEntry := range cellEntries {
if cellEntry == nil {
continue
}
cellData := cellEntry.(*qbtypes.CachedData)
cachedBuckets = append(cachedBuckets, cellData.Buckets...)
cachedWarnings = append(cachedWarnings, cellData.Warnings...)
}
if len(cachedBuckets) == 0 {
return nil, missing
}
// Find missing ranges with step alignment
missing = bc.findMissingRangesWithStep(cachedBuckets, startMs, endMs, stepMs)
bc.logger.DebugContext(ctx, "missing ranges", slog.Any("missing", missing), slog.Uint64("step", stepMs))
// Extract relevant buckets and merge them
relevantBuckets := bc.filterRelevantBuckets(data.Buckets, startMs, endMs)
relevantBuckets := bc.filterRelevantBuckets(cachedBuckets, startMs, endMs)
if len(relevantBuckets) == 0 {
// nothing cached overlaps the window, so all of it has to be fetched.
return nil, []*qbtypes.TimeRange{{From: startMs, To: endMs}}
}
// Merge buckets into a single result
mergedResult := bc.mergeBuckets(ctx, relevantBuckets, data.Warnings)
mergedResult := bc.mergeBuckets(ctx, relevantBuckets, cachedWarnings)
// Filter the merged result to only include values within the requested time range
mergedResult = bc.filterResultToTimeRange(mergedResult, q, startMs, endMs, stepMs)
@@ -130,15 +151,6 @@ func (bc *bucketCache) Put(ctx context.Context, orgID valuer.UUID, q qbtypes.Que
slog.Uint64("cachable_end", cachableEndMs))
}
// Generate cache key
cacheKey := bc.generateCacheKey(q)
// Get existing cached data
var existingData qbtypes.CachedData
if err := bc.cache.Get(ctx, orgID, cacheKey, &existingData); err != nil {
existingData = qbtypes.CachedData{}
}
// Trim the result to exclude data within flux interval
trimmedResult := bc.trimResultToFluxBoundary(fresh, cachableEndMs)
if trimmedResult == nil {
@@ -174,38 +186,54 @@ func (bc *bucketCache) Put(ctx context.Context, orgID valuer.UUID, q qbtypes.Que
}
}
// Convert trimmed result to buckets with adjusted boundaries
freshBuckets := bc.resultToBuckets(ctx, trimmedResult, cachableStartMs, cachableEndMs)
// Convert trimmed result to buckets, one per grid cell it spans
cellWidthMs := bucketCacheCellSteps * stepMs
freshBuckets := bc.resultToBuckets(ctx, trimmedResult, cachableStartMs, cachableEndMs, cellWidthMs)
// If no fresh buckets and no existing data, don't cache
if len(freshBuckets) == 0 && len(existingData.Buckets) == 0 {
return
cellKeys := make([]string, len(freshBuckets))
cellEntries := make([]cachetypes.Cacheable, len(freshBuckets))
for i, freshBucket := range freshBuckets {
cellKeys[i] = bc.generateCacheKey(q, deriveCellIdx(freshBucket.StartMs, cellWidthMs))
cellEntries[i] = &qbtypes.CachedData{}
}
// A cell that could not be read comes back nil like a miss, so its entry is
// rebuilt from the fresh bucket alone
if err := bc.cache.GetMany(ctx, orgID, cellKeys, cellEntries); err != nil {
bc.logger.ErrorContext(ctx, "error getting cached data", errors.Attr(err))
}
// Merge with existing buckets
mergedBuckets := bc.mergeAndDeduplicateBuckets(existingData.Buckets, freshBuckets)
// Each cell is its own cache entry, rewritten only when the fresh bucket
// changes what it holds, so an entry's TTL runs from its last new data
for i, freshBucket := range freshBuckets {
cellEntry := &qbtypes.CachedData{}
if cellEntries[i] != nil {
cellEntry = cellEntries[i].(*qbtypes.CachedData)
}
// Update warnings
allWarnings := append(existingData.Warnings, trimmedResult.Warnings...)
uniqueWarnings := bc.deduplicateWarnings(allWarnings)
// Create updated cached data
updatedData := qbtypes.CachedData{
Buckets: mergedBuckets,
Warnings: uniqueWarnings,
}
// Marshal and store in cache
if err := bc.cache.Set(ctx, orgID, cacheKey, &updatedData, bc.cacheTTL); err != nil {
bc.logger.ErrorContext(ctx, "error setting cached data", errors.Attr(err))
cellBuckets, changed := bc.mergeFreshBucketIntoCell(ctx, cellEntry.Buckets, freshBucket)
if !changed {
continue
}
cellEntry.Buckets = cellBuckets
cellEntry.Warnings = bc.deduplicateWarnings(append(cellEntry.Warnings, trimmedResult.Warnings...))
if err := bc.cache.Set(ctx, orgID, cellKeys[i], cellEntry, bc.cacheTTL); err != nil {
bc.logger.ErrorContext(ctx, "error setting cached data", errors.Attr(err))
}
}
}
// generateCacheKey creates a unique cache key based on query fingerprint.
func (bc *bucketCache) generateCacheKey(q qbtypes.Query) string {
fingerprint := q.Fingerprint()
// generateCacheKey names the cache entry holding one grid cell of a query.
func (bc *bucketCache) generateCacheKey(q qbtypes.Query, cellIdx uint64) string {
return fmt.Sprintf("v6:query:%s:cellidx:%d", q.Fingerprint(), cellIdx)
}
return fmt.Sprintf("v5:query:%s", fingerprint)
// deriveCellIdx returns the index of the grid cell holding startMs; with no grid
// every bucket shares cell 0.
func deriveCellIdx(startMs, cellWidthMs uint64) uint64 {
if cellWidthMs == 0 {
return 0
}
return startMs / cellWidthMs
}
// findMissingRangesWithStep identifies time ranges not covered by cached buckets with step alignment.
@@ -646,7 +674,7 @@ func (bc *bucketCache) isEmptyResult(result *qbtypes.Result) (isEmpty bool, isFi
}
// resultToBuckets converts a query result into time-based buckets.
func (bc *bucketCache) resultToBuckets(ctx context.Context, result *qbtypes.Result, startMs, endMs uint64) []*qbtypes.CachedBucket {
func (bc *bucketCache) resultToBuckets(ctx context.Context, result *qbtypes.Result, startMs, endMs, cellWidthMs uint64) []*qbtypes.CachedBucket {
// Check if result is empty
isEmpty, isFiltered := bc.isEmptyResult(result)
@@ -657,62 +685,74 @@ func (bc *bucketCache) resultToBuckets(ctx context.Context, result *qbtypes.Resu
return nil
}
// For now, create a single bucket for the entire range
// In the future, we could split large ranges into smaller buckets
valueBytes, err := json.Marshal(result.Value)
// Empty filtered results still become buckets, so the range is not re-queried
perCellBuckets, err := result.SplitIntoCacheCellBuckets(startMs, endMs, cellWidthMs)
if err != nil {
bc.logger.ErrorContext(ctx, "failed to marshal result value", errors.Attr(err))
bc.logger.ErrorContext(ctx, "failed to convert result to buckets", errors.Attr(err))
return nil
}
// Always create a bucket, even for empty filtered results
// This ensures we don't re-query for data that doesn't exist
return []*qbtypes.CachedBucket{
{
StartMs: startMs,
EndMs: endMs,
Type: result.Type,
Value: valueBytes,
Stats: result.Stats,
},
}
return perCellBuckets
}
// mergeAndDeduplicateBuckets combines and deduplicates bucket lists.
func (bc *bucketCache) mergeAndDeduplicateBuckets(existing, fresh []*qbtypes.CachedBucket) []*qbtypes.CachedBucket {
// Create a map to deduplicate by time range
bucketMap := make(map[string]*qbtypes.CachedBucket)
// Add existing buckets
for _, bucket := range existing {
key := fmt.Sprintf("%d-%d", bucket.StartMs, bucket.EndMs)
bucketMap[key] = bucket
}
// Add/update with fresh buckets
for _, bucket := range fresh {
key := fmt.Sprintf("%d-%d", bucket.StartMs, bucket.EndMs)
bucketMap[key] = bucket
}
// Convert back to slice with pre-allocated capacity
result := make([]*qbtypes.CachedBucket, 0, len(bucketMap))
for _, bucket := range bucketMap {
result = append(result, bucket)
}
// Sort by start time
slices.SortFunc(result, func(a, b *qbtypes.CachedBucket) int {
if a.StartMs < b.StartMs {
return -1
// mergeFreshBucketIntoCell folds a fresh bucket into the buckets of its cell
// and reports whether the cell changed: a bucket already covering the fresh
// one leaves it as is, buckets inside the fresh one are replaced, buckets
// overlapping or edge to edge with it are unioned, and the rest are kept apart.
func (bc *bucketCache) mergeFreshBucketIntoCell(ctx context.Context, cellBuckets []*qbtypes.CachedBucket, freshBucket *qbtypes.CachedBucket) ([]*qbtypes.CachedBucket, bool) {
keptBuckets := make([]*qbtypes.CachedBucket, 0, len(cellBuckets)+1)
touchingBuckets := []*qbtypes.CachedBucket{}
for _, cellBucket := range cellBuckets {
switch {
case cellBucket.Contains(freshBucket):
return cellBuckets, false
case cellBucket.Touches(freshBucket):
touchingBuckets = append(touchingBuckets, cellBucket)
default:
keptBuckets = append(keptBuckets, cellBucket)
}
if a.StartMs > b.StartMs {
return 1
}
return 0
}
keptBuckets = append(keptBuckets, bc.unionBuckets(ctx, freshBucket, touchingBuckets))
slices.SortFunc(keptBuckets, func(a, b *qbtypes.CachedBucket) int {
return cmp.Compare(a.StartMs, b.StartMs)
})
return keptBuckets, true
}
return result
// unionBuckets widens the fresh bucket by the buckets it touches. Buckets
// inside it are replaced outright, so only a genuine partial overlap decodes
// anything.
func (bc *bucketCache) unionBuckets(ctx context.Context, freshBucket *qbtypes.CachedBucket, touchingBuckets []*qbtypes.CachedBucket) *qbtypes.CachedBucket {
bucketsToMerge := []*qbtypes.CachedBucket{freshBucket}
for _, touchingBucket := range touchingBuckets {
if !freshBucket.Contains(touchingBucket) {
bucketsToMerge = append(bucketsToMerge, touchingBucket)
}
}
if len(bucketsToMerge) == 1 {
return freshBucket
}
value, err := json.Marshal(bc.mergeTimeSeriesValues(ctx, bucketsToMerge))
if err != nil {
bc.logger.ErrorContext(ctx, "failed to marshal unioned bucket", errors.Attr(err))
return freshBucket
}
unionBucket := &qbtypes.CachedBucket{
StartMs: freshBucket.StartMs,
EndMs: freshBucket.EndMs,
Type: freshBucket.Type,
Value: value,
Stats: qbtypes.ExecStats{StepIntervals: freshBucket.Stats.StepIntervals},
}
for _, mergedBucket := range bucketsToMerge {
unionBucket.StartMs = min(unionBucket.StartMs, mergedBucket.StartMs)
unionBucket.EndMs = max(unionBucket.EndMs, mergedBucket.EndMs)
unionBucket.Stats.RowsScanned += mergedBucket.Stats.RowsScanned
unionBucket.Stats.BytesScanned += mergedBucket.Stats.BytesScanned
unionBucket.Stats.DurationMS += mergedBucket.Stats.DurationMS
}
return unionBucket
}
// deduplicateWarnings removes duplicate warnings.

View File

@@ -2,6 +2,7 @@ package querier
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"testing"
@@ -1465,3 +1466,161 @@ func TestBucketCache_NoCache(t *testing.T) {
// The actual NoCache logic is implemented in querier.run(), not in bucket cache
// This test verifies that the cache works normally and NoCache bypasses it at a higher level
}
func TestBucketCachePutSlicesAtCellBoundaries(t *testing.T) {
bc := createTestBucketCache(t)
ctx := context.Background()
orgID := valuer.UUID{}
step := qbtypes.Step{Duration: time.Second}
// a 1s step gives 60s cells, so 30s to 150s spans three of them
query := &mockQuery{fingerprint: "sliced-query", startMs: 30000, endMs: 150000}
bc.Put(ctx, orgID, query, step, &qbtypes.Result{
Type: qbtypes.RequestTypeTimeSeries,
Value: createTestTimeSeries("A", 30000, 150000, 1000),
Stats: qbtypes.ExecStats{RowsScanned: 120, BytesScanned: 1200, DurationMS: 12},
})
type bucketShape struct {
cell, startMs, endMs, rowsScanned uint64
valueCount int
}
shapes := []bucketShape{}
for cell := uint64(0); cell < 3; cell++ {
var cellEntry qbtypes.CachedData
require.NoError(t, bc.cache.Get(ctx, orgID, bc.generateCacheKey(query, cell), &cellEntry))
for _, bucket := range cellEntry.Buckets {
var tsData qbtypes.TimeSeriesData
require.NoError(t, json.Unmarshal(bucket.Value, &tsData))
require.Len(t, tsData.Aggregations, 1)
require.Len(t, tsData.Aggregations[0].Series, 1)
shapes = append(shapes, bucketShape{cell, bucket.StartMs, bucket.EndMs, bucket.Stats.RowsScanned, len(tsData.Aggregations[0].Series[0].Values)})
}
}
assert.Equal(t, []bucketShape{
{0, 30000, 60000, 30, 30},
{1, 60000, 120000, 60, 60},
{2, 120000, 150000, 30, 30},
}, shapes)
cached, missing := bc.GetMissRanges(ctx, orgID, query, step)
assert.Equal(t, []*qbtypes.TimeRange{}, missing)
assert.Len(t, cached.Value.(*qbtypes.TimeSeriesData).Aggregations[0].Series[0].Values, 120)
assert.Equal(t, uint64(120), cached.Stats.RowsScanned)
}
func TestBucketCachePutAbsorbsWritesWithinACell(t *testing.T) {
type bucketShape struct{ startMs, endMs, rowsScanned uint64 }
testCases := []struct {
name string
firstWindow qbtypes.TimeRange
secondWindow qbtypes.TimeRange
expectedBuckets []bucketShape
expectedMissingRanges []*qbtypes.TimeRange
expectedValueCount int
}{
{
name: "DisjointWrites_StayApart",
firstWindow: qbtypes.TimeRange{From: 1000, To: 3000},
secondWindow: qbtypes.TimeRange{From: 5000, To: 8000},
expectedBuckets: []bucketShape{{1000, 3000, 2}, {5000, 8000, 3}},
expectedMissingRanges: []*qbtypes.TimeRange{{From: 3000, To: 5000}},
expectedValueCount: 5,
},
{
name: "EdgeToEdgeWrites_BecomeOne",
firstWindow: qbtypes.TimeRange{From: 1000, To: 3000},
secondWindow: qbtypes.TimeRange{From: 3000, To: 5000},
expectedBuckets: []bucketShape{{1000, 5000, 4}},
expectedMissingRanges: []*qbtypes.TimeRange{},
expectedValueCount: 4,
},
{
name: "OverlappingWrites_BecomeOne",
firstWindow: qbtypes.TimeRange{From: 1000, To: 4000},
secondWindow: qbtypes.TimeRange{From: 3000, To: 6000},
expectedBuckets: []bucketShape{{1000, 6000, 6}},
expectedMissingRanges: []*qbtypes.TimeRange{},
expectedValueCount: 5,
},
{
name: "ContainedWrite_KeepsExistingBucket",
firstWindow: qbtypes.TimeRange{From: 1000, To: 10000},
secondWindow: qbtypes.TimeRange{From: 3000, To: 5000},
expectedBuckets: []bucketShape{{1000, 10000, 9}},
expectedMissingRanges: []*qbtypes.TimeRange{},
expectedValueCount: 9,
},
{
name: "ContainingWrite_ReplacesExistingBucket",
firstWindow: qbtypes.TimeRange{From: 3000, To: 5000},
secondWindow: qbtypes.TimeRange{From: 1000, To: 10000},
expectedBuckets: []bucketShape{{1000, 10000, 9}},
expectedMissingRanges: []*qbtypes.TimeRange{},
expectedValueCount: 9,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
bc := createTestBucketCache(t)
ctx := context.Background()
orgID := valuer.UUID{}
step := qbtypes.Step{Duration: time.Second}
for _, window := range []qbtypes.TimeRange{testCase.firstWindow, testCase.secondWindow} {
query := &mockQuery{fingerprint: "absorb-query", startMs: window.From, endMs: window.To}
bc.Put(ctx, orgID, query, step, &qbtypes.Result{
Type: qbtypes.RequestTypeTimeSeries,
Value: createTestTimeSeries("A", window.From, window.To, 1000),
// one row per second, so a bucket's stats read as the seconds it was scanned for
Stats: qbtypes.ExecStats{RowsScanned: (window.To - window.From) / 1000},
})
}
query := &mockQuery{
fingerprint: "absorb-query",
startMs: min(testCase.firstWindow.From, testCase.secondWindow.From),
endMs: max(testCase.firstWindow.To, testCase.secondWindow.To),
}
var cellEntry qbtypes.CachedData
require.NoError(t, bc.cache.Get(ctx, orgID, bc.generateCacheKey(query, 0), &cellEntry))
shapes := make([]bucketShape, 0, len(cellEntry.Buckets))
for _, bucket := range cellEntry.Buckets {
shapes = append(shapes, bucketShape{bucket.StartMs, bucket.EndMs, bucket.Stats.RowsScanned})
}
assert.Equal(t, testCase.expectedBuckets, shapes)
cached, missing := bc.GetMissRanges(ctx, orgID, query, step)
assert.Equal(t, testCase.expectedMissingRanges, missing)
assert.Len(t, cached.Value.(*qbtypes.TimeSeriesData).Aggregations[0].Series[0].Values, testCase.expectedValueCount)
})
}
}
func TestBucketCachePutLeavesAnUnchangedCellAlone(t *testing.T) {
bc := createTestBucketCache(t)
ctx := context.Background()
orgID := valuer.UUID{}
step := qbtypes.Step{Duration: time.Second}
wide := &mockQuery{fingerprint: "unchanged-query", startMs: 1000, endMs: 10000}
bc.Put(ctx, orgID, wide, step, &qbtypes.Result{
Type: qbtypes.RequestTypeTimeSeries,
Value: createTestTimeSeries("A", 1000, 10000, 1000),
Warnings: []string{"from the wide write"},
})
// a write already covered by the cell's bucket must not rewrite the entry,
// which shows as its warnings never arriving
inside := &mockQuery{fingerprint: "unchanged-query", startMs: 3000, endMs: 5000}
bc.Put(ctx, orgID, inside, step, &qbtypes.Result{
Type: qbtypes.RequestTypeTimeSeries,
Value: createTestTimeSeries("A", 3000, 5000, 1000),
Warnings: []string{"from the inside write"},
})
var cellEntry qbtypes.CachedData
require.NoError(t, bc.cache.Get(ctx, orgID, bc.generateCacheKey(wide, 0), &cellEntry))
assert.Equal(t, []string{"from the wide write"}, cellEntry.Warnings)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,65 @@
package llmpricingruletypes
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/SigNoz/signoz/pkg/valuer"
)
func TestNewStatsFromRules(t *testing.T) {
sourceID := valuer.GenerateUUID()
testCases := []struct {
name string
rules []*LLMPricingRule
want map[string]any
}{
{
name: "NoRules",
want: map[string]any{
"llmpricingrule.custom.count": int64(0),
"llmpricingrule.override.count": int64(0),
},
},
{
name: "CustomPinnedAndSyncedRules_CountedSeparately",
rules: []*LLMPricingRule{
{IsOverride: true},
{IsOverride: false},
{SourceID: &sourceID, IsOverride: true},
{SourceID: &sourceID, IsOverride: false},
},
want: map[string]any{
"llmpricingrule.custom.count": int64(2),
"llmpricingrule.override.count": int64(1),
},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.want, NewStatsFromRules(testCase.rules))
})
}
}
func TestNewStatsFromModels(t *testing.T) {
rules := []*LLMPricingRule{{ModelPattern: StringSlice{"gpt-*"}}}
testCases := []struct {
name string
models []string
want int64
}{
{name: "NoModels", want: 0},
{name: "PricedAndUnpricedModels_UnpricedCounted", models: []string{"gpt-5-nano", "claude-haiku-5-5", "gemini-3"}, want: 2},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, map[string]any{"llmpricingrule.unmapped_model.count": testCase.want}, NewStatsFromModels(testCase.models, rules))
})
}
}

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"maps"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/cachetypes"
)
@@ -33,6 +34,15 @@ func (c *CachedBucket) Clone() *CachedBucket {
}
}
func (c *CachedBucket) Contains(other *CachedBucket) bool {
return c.StartMs <= other.StartMs && other.EndMs <= c.EndMs
}
// true if c and other overlap or sit edge to edge, false otherwise.
func (c *CachedBucket) Touches(other *CachedBucket) bool {
return c.StartMs <= other.EndMs && other.StartMs <= c.EndMs
}
// CachedData represents the full cached data for a query.
type CachedData struct {
Buckets []*CachedBucket `json:"buckets"`
@@ -77,3 +87,53 @@ func (c *CachedData) Cost() int64 {
}
return size
}
func (r *Result) SplitIntoCacheCellBuckets(startMs, endMs, cellWidthMs uint64) ([]*CachedBucket, error) {
tsData, ok := r.Value.(*TimeSeriesData)
if !ok || tsData == nil {
return nil, errors.NewInternalf(errors.CodeInternal, "result of type %s cannot be bucketed", r.Type.StringValue())
}
cellBuckets := make([]*CachedBucket, 0, 1)
for from := startMs; from < endMs; {
to := endMs
if cellWidthMs > 0 {
to = min(endMs, (from/cellWidthMs+1)*cellWidthMs)
}
// A promql value stamped exactly at the window end is an instant inside
// the window, so the last slice keeps it. A builder value never sits
// there, since the end is step-aligned and partial steps are dropped.
sliceEndMs := to
if to == endMs {
sliceEndMs = endMs + 1
}
value, err := json.Marshal(tsData.FilterToTimeRange(from, sliceEndMs))
if err != nil {
return nil, err
}
cellBuckets = append(cellBuckets, &CachedBucket{
StartMs: from,
EndMs: to,
Type: r.Type,
Value: value,
Stats: r.Stats.prorate(to-from, endMs-startMs),
})
from = to
}
return cellBuckets, nil
}
// prorate returns the share of the stats that partMs out of wholeMs of the
// scanned window accounts for.
func (e ExecStats) prorate(partMs, wholeMs uint64) ExecStats {
ratio := 1.0
if partMs < wholeMs {
ratio = float64(partMs) / float64(wholeMs)
}
return ExecStats{
RowsScanned: uint64(float64(e.RowsScanned) * ratio),
BytesScanned: uint64(float64(e.BytesScanned) * ratio),
DurationMS: uint64(float64(e.DurationMS) * ratio),
StepIntervals: maps.Clone(e.StepIntervals),
}
}

View File

@@ -137,6 +137,28 @@ type TimeSeriesData struct {
Aggregations []*AggregationBucket `json:"aggregations" description:"One bucket per aggregation of the query. The order is not guaranteed; match buckets to aggregations by index."`
}
// FilterToTimeRange copies the aggregation and series structure keeping only
// the values with startMs <= timestamp < endMs.
func (t *TimeSeriesData) FilterToTimeRange(startMs, endMs uint64) *TimeSeriesData {
filtered := &TimeSeriesData{
QueryName: t.QueryName,
Aggregations: make([]*AggregationBucket, 0, len(t.Aggregations)),
}
for _, aggBucket := range t.Aggregations {
filteredBucket := &AggregationBucket{
Index: aggBucket.Index,
Alias: aggBucket.Alias,
Meta: aggBucket.Meta,
Series: make([]*TimeSeries, 0, len(aggBucket.Series)),
}
for _, series := range aggBucket.Series {
filteredBucket.Series = append(filteredBucket.Series, series.filterToTimeRange(startMs, endMs))
}
filtered.Aggregations = append(filtered.Aggregations, filteredBucket)
}
return filtered
}
type AggregationBucket struct {
Index int `json:"index" description:"The zero-based position of the aggregation in the query."`
Alias string `json:"alias" description:"The alias of the aggregation, or the internal name __result_N, with N its index, when no alias was given."`
@@ -259,6 +281,21 @@ func (ts *TimeSeries) EvaluableValues() []*TimeSeriesValue {
return result
}
// filterToTimeRange copies the series keeping only the values with
// startMs <= timestamp < endMs.
func (ts *TimeSeries) filterToTimeRange(startMs, endMs uint64) *TimeSeries {
filtered := &TimeSeries{
Labels: ts.Labels,
Values: make([]*TimeSeriesValue, 0, len(ts.Values)),
}
for _, value := range ts.Values {
if timestamp := uint64(value.Timestamp); timestamp >= startMs && timestamp < endMs {
filtered.Values = append(filtered.Values, value)
}
}
return filtered
}
type Label struct {
Key telemetrytypes.TelemetryFieldKey `json:"key" description:"The group-by field this label value belongs to."`
Value any `json:"value" description:"The label value as a string, number or boolean."`

View File

@@ -436,3 +436,97 @@ func TestRoundToNonZeroDecimals(t *testing.T) {
assert.Equal(t, math.Inf(1), roundToNonZeroDecimals(math.Inf(1), 3))
assert.Equal(t, math.Inf(-1), roundToNonZeroDecimals(math.Inf(-1), 3))
}
func TestTimeSeriesDataFilterToTimeRange(t *testing.T) {
testCases := []struct {
name string
timeSeriesData *TimeSeriesData
startMs uint64
endMs uint64
expectedFiltered *TimeSeriesData
}{
{
name: "HalfOpenRange_KeepsStartDropsEnd",
timeSeriesData: &TimeSeriesData{
QueryName: "A",
Aggregations: []*AggregationBucket{{
Index: 0,
Series: []*TimeSeries{{
Labels: []*Label{{Key: telemetrytypes.TelemetryFieldKey{Name: "host"}, Value: "node-1"}},
Values: []*TimeSeriesValue{
{Timestamp: 1000, Value: 1},
{Timestamp: 2000, Value: 2},
{Timestamp: 3000, Value: 3},
{Timestamp: 4000, Value: 4},
},
}},
}},
},
startMs: 2000,
endMs: 4000,
expectedFiltered: &TimeSeriesData{
QueryName: "A",
Aggregations: []*AggregationBucket{{
Index: 0,
Series: []*TimeSeries{{
Labels: []*Label{{Key: telemetrytypes.TelemetryFieldKey{Name: "host"}, Value: "node-1"}},
Values: []*TimeSeriesValue{
{Timestamp: 2000, Value: 2},
{Timestamp: 3000, Value: 3},
},
}},
}},
},
},
{
// heatmap counts are positional against Meta.Buckets, so the axis
// has to survive even when no value falls in the range
name: "NothingInRange_KeepsSeriesAndHeatmapAxis",
timeSeriesData: &TimeSeriesData{
QueryName: "A",
Aggregations: []*AggregationBucket{{
Index: 1,
Alias: "__result_1",
Meta: AggregationMeta{Unit: "By", Buckets: []float64{1, 2, 4}},
Series: []*TimeSeries{{
Labels: []*Label{{Key: telemetrytypes.TelemetryFieldKey{Name: "host"}, Value: "node-1"}},
Values: []*TimeSeriesValue{{Timestamp: 1000, Values: []float64{1, 2, 3, 4}}},
}},
}},
},
startMs: 5000,
endMs: 6000,
expectedFiltered: &TimeSeriesData{
QueryName: "A",
Aggregations: []*AggregationBucket{{
Index: 1,
Alias: "__result_1",
Meta: AggregationMeta{Unit: "By", Buckets: []float64{1, 2, 4}},
Series: []*TimeSeries{{
Labels: []*Label{{Key: telemetrytypes.TelemetryFieldKey{Name: "host"}, Value: "node-1"}},
Values: []*TimeSeriesValue{},
}},
}},
},
},
{
name: "NoSeries_KeepsAggregation",
timeSeriesData: &TimeSeriesData{
QueryName: "A",
Aggregations: []*AggregationBucket{{Index: 0}},
},
startMs: 1000,
endMs: 2000,
expectedFiltered: &TimeSeriesData{
QueryName: "A",
Aggregations: []*AggregationBucket{{Index: 0, Series: []*TimeSeries{}}},
},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.expectedFiltered, testCase.timeSeriesData.FilterToTimeRange(testCase.startMs, testCase.endMs))
})
}
}