Files
windmill/frontend/src/lib/utils/featureUsage.test.ts
T
11fda89b52 feat(telemetry): generic feature-usage telemetry with AI session metrics (#10200)
* feat(telemetry): add generic feature_usage table and batched logging endpoint

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(telemetry): log AI session usage events and document them in telemetry settings

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(telemetry): use escape sequence instead of literal NUL bytes in buffer key

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(telemetry): validate dimensions, decouple retention, keepalive flush

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(telemetry): allowlist feature-usage dimensions and index retention scans

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(telemetry): pin tool-name allowlist and deploy session attribution

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(telemetry): route AI chat usage through feature_usage and drop ai_chat_usage

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(telemetry): slim dimension validation to registered kinds plus key shape

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(telemetry): backfill ai_chat_usage into feature_usage before dropping it

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(telemetry): disclose provider and model identifiers in telemetry settings text

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(telemetry): issue all flush chunks before awaiting so pagehide keeps them

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: update ee-repo-ref to 6306c072a50937ea9af44a5bcf42345543207486

This commit updates the EE repository reference after PR #672 was merged in windmill-ee-private.

Previous ee-repo-ref: 964f242a0eb44db7f7d26636cc8d76aeabea2b73

New ee-repo-ref: 6306c072a50937ea9af44a5bcf42345543207486

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2026-07-20 20:56:57 +02:00

81 lines
3.2 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
vi.mock('$lib/gen', () => ({ OpenAPI: { BASE: '/api' } }))
vi.mock('$lib/stores', () => ({ workspaceStore: { subscribe: () => () => {} } }))
import { createFeatureUsageBuffer, type FeatureUsageEventPayload } from './featureUsage'
describe('createFeatureUsageBuffer', () => {
it('sums repeated events per (feature, kind, key, entity) and flushes one batch', async () => {
const send = vi.fn().mockResolvedValue(undefined)
const buffer = createFeatureUsageBuffer(send, () => 'ws1')
buffer.log('ai_session', 'message', { key: 'global', entityId: 's1' })
buffer.log('ai_session', 'message', { key: 'global', entityId: 's1' })
buffer.log('ai_session', 'tokens', { entityId: 's1', value: 120 })
buffer.log('ai_session', 'message', { key: 'global', entityId: 's2' })
await buffer.flush()
expect(send).toHaveBeenCalledTimes(1)
const [workspace, events] = send.mock.calls[0]
expect(workspace).toBe('ws1')
expect(events).toEqual(
expect.arrayContaining([
{ feature: 'ai_session', kind: 'message', key: 'global', entity_id: 's1', value: 2 },
{ feature: 'ai_session', kind: 'tokens', key: '', entity_id: 's1', value: 120 },
{ feature: 'ai_session', kind: 'message', key: 'global', entity_id: 's2', value: 1 }
])
)
expect(events).toHaveLength(3)
// Flushed events must not be re-sent.
await buffer.flush()
expect(send).toHaveBeenCalledTimes(1)
})
it('splits batches per workspace and drops events without any workspace', async () => {
const send = vi.fn().mockResolvedValue(undefined)
const buffer = createFeatureUsageBuffer(send, () => undefined)
buffer.log('ai_session', 'created', { key: 'fork' }) // no workspace -> dropped
buffer.log('ai_session', 'created', { key: 'fork', workspace: 'ws1' })
buffer.log('ai_session', 'created', { key: 'root', workspace: 'ws2' })
await buffer.flush()
expect(send).toHaveBeenCalledTimes(2)
const workspaces = send.mock.calls.map((c) => c[0]).sort()
expect(workspaces).toEqual(['ws1', 'ws2'])
})
it('starts every chunk request before any send resolves (pagehide flush)', async () => {
const send = vi.fn().mockReturnValue(new Promise<void>(() => {}))
const buffer = createFeatureUsageBuffer(send, () => 'ws1')
for (let i = 0; i < 60; i++) {
buffer.log('ai_session', 'tool', { key: `tool_${i}` })
}
buffer.log('ai_session', 'message', { workspace: 'ws2' })
void buffer.flush()
await Promise.resolve()
// keepalive only protects requests that were issued; a sequential flush
// would have started just the first chunk here.
expect(send).toHaveBeenCalledTimes(3)
})
it('chunks flushes above the per-request cap and survives send failures', async () => {
const send = vi.fn().mockRejectedValueOnce(new Error('network')).mockResolvedValue(undefined)
const buffer = createFeatureUsageBuffer(send, () => 'ws1')
for (let i = 0; i < 60; i++) {
buffer.log('ai_session', 'tool', { key: `tool_${i}` })
}
await expect(buffer.flush()).resolves.toBeUndefined()
expect(send).toHaveBeenCalledTimes(2)
const sent = send.mock.calls.flatMap((c) => c[1] as FeatureUsageEventPayload[])
expect(send.mock.calls[0][1]).toHaveLength(50)
expect(sent).toHaveLength(60)
})
})