Skip to content

Commit 9a63668

Browse files
committed
fix(insights): bound usage settling by the stream cap, discard unreadable segments, neutral zero delta
1 parent 0078d27 commit 9a63668

6 files changed

Lines changed: 66 additions & 17 deletions

File tree

‎apps/sim/ee/organization-usage/components/usage-credits.test.tsx‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,18 @@ describe('UsageCredits', () => {
3434
expect(failed).toContain('load credits.')
3535
})
3636

37+
it('shows an unchanged period as neutral, not as a decrease', () => {
38+
const markup = renderToStaticMarkup(
39+
<UsageCredits
40+
overview={{ ...overview, totals: { credits: 100 }, previousTotals: { credits: 100 } }}
41+
isLoading={false}
42+
isError={false}
43+
/>
44+
)
45+
expect(markup).toContain('No change compared with the previous period')
46+
expect(markup).not.toContain('↓')
47+
})
48+
3749
it('omits the allowance outside the organization period', () => {
3850
const markup = renderToStaticMarkup(
3951
<UsageCredits

‎apps/sim/ee/organization-usage/components/usage-credits.tsx‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,8 @@ export function UsageCredits({
104104
const { layers, legend } = useMemo(() => toCreditLayers(current?.series ?? []), [current])
105105
const used = current?.totals.credits ?? 0
106106
const previous = current?.previousTotals?.credits ?? 0
107-
const delta = current && previous > 0 ? ((used - previous) / previous) * 100 : null
107+
/** Rounded once, so the arrow and the figure never disagree about a sub-percent change. */
108+
const delta = current && previous > 0 ? Math.round(((used - previous) / previous) * 100) : null
108109
const limit = current?.limitCredits ?? null
109110
const highlight = useLegendHighlight(legend.map((item) => item.id))
110111

@@ -122,8 +123,12 @@ export function UsageCredits({
122123
<Badge
123124
variant={delta > 0 ? 'amber' : 'gray-secondary'}
124125
size='sm'
125-
aria-label={`${Math.abs(delta).toFixed(0)}% ${delta > 0 ? 'increase' : delta < 0 ? 'decrease' : 'change'} compared with the previous period`}
126-
>{`${delta > 0 ? '↑' : '↓'} ${Math.abs(delta).toFixed(0)}%`}</Badge>
126+
aria-label={
127+
delta === 0
128+
? 'No change compared with the previous period'
129+
: `${Math.abs(delta)}% ${delta > 0 ? 'increase' : 'decrease'} compared with the previous period`
130+
}
131+
>{`${delta > 0 ? '↑ ' : delta < 0 ? '↓ ' : ''}${Math.abs(delta)}%`}</Badge>
127132
)}
128133
{limit !== null && used > limit && (
129134
<Badge variant='red' size='sm'>

‎apps/sim/ee/organization-usage/hooks/use-legend-highlight.ts‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,16 @@ import { useState } from 'react'
55
/**
66
* Hover and click highlighting shared by a chart and its legend.
77
*
8-
* A selection whose series the current window no longer draws is ignored: otherwise
9-
* every layer would stay dimmed after a period change, with no legend entry left to
10-
* clear it.
8+
* A hover or selection whose series the current window no longer draws is ignored:
9+
* otherwise every layer would stay dimmed after a period change, with no legend entry
10+
* left to clear it.
1111
*/
1212
export function useLegendHighlight(seriesIds: readonly string[]) {
1313
const [hoveredId, setHoveredId] = useState<string | null>(null)
1414
const [selectedId, setSelectedId] = useState<string | null>(null)
15-
const activeSelection = selectedId !== null && seriesIds.includes(selectedId) ? selectedId : null
16-
const highlightedId = hoveredId ?? activeSelection
15+
const isDrawn = (id: string | null): id is string => id !== null && seriesIds.includes(id)
16+
const activeSelection = isDrawn(selectedId) ? selectedId : null
17+
const highlightedId = isDrawn(hoveredId) ? hoveredId : activeSelection
1718
return {
1819
highlightedId,
1920
legendProps: {

‎apps/sim/lib/billing/core/usage-analytics.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
resolveEnterpriseReportingPeriod,
77
} from '@/lib/billing/core/reporting-period'
88
import type { BillingEntity } from '@/lib/billing/core/usage-log'
9+
import { STREAM_TIMEOUT_MS } from '@/lib/copilot/constants'
910
import { zonedWallClockToUtc } from '@/lib/core/utils/timezone'
1011

1112
/**
@@ -487,10 +488,11 @@ export function usageBucketTimestamps(
487488
* How long after a stretch of time ends before its ledger rows are final.
488489
*
489490
* Rows are stamped when inserted, but a cumulative model charge tops up its row's
490-
* cost in place for as long as the stream runs. Past this lag a day or hour is
491-
* treated as settled.
491+
* cost in place for as long as its stream runs — which {@link STREAM_TIMEOUT_MS}
492+
* caps — plus the retry flushes that follow it. Past the cap and this margin a day or
493+
* hour can no longer change and is treated as settled.
492494
*/
493-
export const USAGE_SETTLE_MS = 3 * 60 * 60 * 1000
495+
export const USAGE_SETTLE_MS = STREAM_TIMEOUT_MS + 2 * 60 * 60 * 1000
494496

495497
const HOUR_MS = 60 * 60 * 1000
496498

‎apps/sim/lib/billing/core/usage-segment-cache.test.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
import { generateShortId } from '@sim/utils/id'
55
import { beforeEach, describe, expect, it, vi } from 'vitest'
66

7-
vi.mock('@/lib/core/config/redis', () => ({ getRedisClient: () => null }))
7+
const redisMock = vi.hoisted(() => ({ client: null as unknown }))
8+
vi.mock('@/lib/core/config/redis', () => ({ getRedisClient: () => redisMock.client }))
89

910
import { type UsageSegment, usageHourKey } from '@/lib/billing/core/usage-analytics'
1011
import { readThroughSegments } from '@/lib/billing/core/usage-segment-cache'
@@ -58,9 +59,20 @@ describe('readThroughSegments', () => {
5859

5960
beforeEach(() => {
6061
vi.clearAllMocks()
62+
redisMock.client = null
6163
namespace = `test-${generateShortId()}`
6264
})
6365

66+
it('reads the ledger when a stored entry does not parse, rather than failing', async () => {
67+
redisMock.client = {
68+
mget: async (...keys: string[]) => keys.map(() => '{not json'),
69+
pipeline: () => ({ set: () => undefined, exec: async () => [] }),
70+
}
71+
const entries = await read([day('2026-01-05')])
72+
expect(fetchRange).toHaveBeenCalledTimes(1)
73+
expect(totalByDay(entries)).toEqual({ '2026-01-05': 24 })
74+
})
75+
6476
it('reads every missing segment in one merged range and counts each hour once', async () => {
6577
const segments = [day('2026-01-01'), ...hours('2026-01-02', 10)]
6678
const entries = await read(segments)

‎apps/sim/lib/billing/core/usage-segment-cache.ts‎

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,21 @@ async function readStored(keys: string[]): Promise<(string | null)[]> {
6060
}
6161
}
6262

63+
/**
64+
* A stored value, or `undefined` for a miss. An entry that does not parse — written by
65+
* an older shape, or corrupted — is a miss too: the ledger answers instead of a failed
66+
* page, and the fresh value overwrites it.
67+
*/
68+
function parseStored<T>(key: string, value: string | null | undefined): T | undefined {
69+
if (value == null) return undefined
70+
try {
71+
return JSON.parse(value) as T
72+
} catch (error) {
73+
logger.warn('Discarding unreadable usage segment', { key, error: getErrorMessage(error) })
74+
return undefined
75+
}
76+
}
77+
6378
/** Fire-and-forget: a page never waits on, or fails because of, a cache write. */
6479
function writeStored(entries: { key: string; value: string; ttlMs: number }[]): void {
6580
if (entries.length === 0) return
@@ -141,14 +156,16 @@ export async function readThroughSegments<T>({
141156
const writes: { key: string; value: string; ttlMs: number }[] = []
142157
const cached = new Map<UsageSegment, T>()
143158
settled.forEach((segment, index) => {
144-
const value = stored[index]
145-
if (value != null) cached.set(segment, JSON.parse(value) as T)
159+
const value = parseStored<T>(keyOf(segment.key), stored[index])
160+
if (value !== undefined) cached.set(segment, value)
146161
})
147162
assemblable.forEach((segment, index) => {
148163
if (cached.has(segment)) return
149-
const hours = storedHours.slice(index * 24, index * 24 + 24)
150-
if (hours.some((hour) => hour == null)) return
151-
const value = combine(hours.map((hour) => JSON.parse(hour as string) as T))
164+
const hours = storedHours
165+
.slice(index * 24, index * 24 + 24)
166+
.map((hour, offset) => parseStored<T>(keyOf(usageHourKey(segment.day, offset)), hour))
167+
if (hours.some((hour) => hour === undefined)) return
168+
const value = combine(hours as T[])
152169
cached.set(segment, value)
153170
writes.push({ key: keyOf(segment.key), value: JSON.stringify(value), ttlMs })
154171
})

0 commit comments

Comments
 (0)