Files
windmill/frontend/src/lib/utils/featureUsage.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

138 lines
4.4 KiB
TypeScript

import { get } from 'svelte/store'
import { OpenAPI } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
// Anonymous product-usage counters (e.g. AI session activity), batched into the
// backend `feature_usage` accumulator. Only aggregated counts ever leave the
// instance, and only when telemetry is enabled and not in minimal mode — never
// log paths, prompts, code, or user identifiers here (entity ids must be
// opaque random ids).
export interface FeatureUsageOpts {
key?: string
entityId?: string
value?: number
/** Workspace whose API route carries the batch; defaults to the active workspace. */
workspace?: string
}
type SendFn = (workspace: string, events: FeatureUsageEventPayload[]) => Promise<void>
export interface FeatureUsageEventPayload {
feature: string
kind: string
key?: string
entity_id?: string
value?: number
}
const FLUSH_INTERVAL_MS = 30_000
// Backend caps a batch at 50 events; chunk larger flushes.
const MAX_EVENTS_PER_REQUEST = 50
export function createFeatureUsageBuffer(
send: SendFn,
getDefaultWorkspace: () => string | undefined,
flushIntervalMs = FLUSH_INTERVAL_MS
) {
// One accumulator per (workspace, feature, kind, key, entityId): repeated
// events sum locally so a chatty UI still produces one upsert per flush.
const pending = new Map<string, { workspace: string; event: FeatureUsageEventPayload }>()
let timer: ReturnType<typeof setTimeout> | undefined
function log(feature: string, kind: string, opts: FeatureUsageOpts = {}): void {
const workspace = opts.workspace ?? getDefaultWorkspace()
if (!workspace) return
const key = opts.key ?? ''
const entityId = opts.entityId ?? ''
const value = Math.max(1, Math.round(opts.value ?? 1))
const mapKey = `${workspace}\u0000${feature}\u0000${kind}\u0000${key}\u0000${entityId}`
const existing = pending.get(mapKey)
if (existing) {
existing.event.value = (existing.event.value ?? 1) + value
} else {
pending.set(mapKey, {
workspace,
event: { feature, kind, key, entity_id: entityId, value }
})
}
if (timer === undefined) {
timer = setTimeout(() => {
timer = undefined
void flush()
}, flushIntervalMs)
}
}
async function flush(): Promise<void> {
if (timer !== undefined) {
clearTimeout(timer)
timer = undefined
}
if (pending.size === 0) return
const byWorkspace = new Map<string, FeatureUsageEventPayload[]>()
for (const { workspace, event } of pending.values()) {
let events = byWorkspace.get(workspace)
if (!events) {
events = []
byWorkspace.set(workspace, events)
}
events.push(event)
}
pending.clear()
// Start every chunk request synchronously before awaiting: the pagehide
// flush only protects requests that were already issued (keepalive can't
// help a fetch that never started).
const inflight: Promise<void>[] = []
for (const [workspace, events] of byWorkspace) {
for (let i = 0; i < events.length; i += MAX_EVENTS_PER_REQUEST) {
inflight.push(
send(workspace, events.slice(i, i + MAX_EVENTS_PER_REQUEST)).catch(() => {
// Telemetry is best-effort: drop the batch rather than retry.
})
)
}
}
await Promise.all(inflight)
}
return { log, flush }
}
const buffer = createFeatureUsageBuffer(
async (workspace, events) => {
// Raw fetch instead of the generated client: `keepalive` lets the request
// finish after tab close/navigation, which is when the final flush runs.
// Auth rides on the token cookie (WITH_CREDENTIALS app setup).
await fetch(`${OpenAPI.BASE}/w/${encodeURIComponent(workspace)}/workspaces/log_feature_usage`, {
method: 'POST',
credentials: 'include',
keepalive: true,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ events })
})
},
() => get(workspaceStore) ?? undefined
)
if (typeof document !== 'undefined') {
// Flush what's buffered before the tab goes away. pagehide covers
// close/navigation paths where visibilitychange is not delivered.
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
void buffer.flush()
}
})
window.addEventListener('pagehide', () => {
void buffer.flush()
})
}
/**
* Record an anonymous feature-usage event. Fire-and-forget: events are summed
* locally per (feature, kind, key, entityId) and flushed in batches.
*/
export function logFeatureUsage(feature: string, kind: string, opts: FeatureUsageOpts = {}): void {
buffer.log(feature, kind, opts)
}