mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
* 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>
138 lines
4.4 KiB
TypeScript
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)
|
|
}
|