diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 4767454f25..2fabf92530 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -b58ad414b098d3d7787001a352bfbb13e43a335f +c3b6f62ea579a3583d4b474e9885c77104cfc87e diff --git a/backend/windmill-api-debug/src/lib.rs b/backend/windmill-api-debug/src/lib.rs index 7b2f569821..ab89df988d 100644 --- a/backend/windmill-api-debug/src/lib.rs +++ b/backend/windmill-api-debug/src/lib.rs @@ -468,6 +468,9 @@ async fn sign_debug_request( // Parse the language let script_lang: ScriptLang = request.language.parse().unwrap_or(ScriptLang::Bun); + // Taken from the parsed language, not the request's string: the telemetry key vocabulary has + // to stay the closed set of languages rather than whatever a caller sent. + let lang_key = script_lang.as_str(); // Hash the code (we don't include full code in JWT to keep it small) let mut hasher = Sha256::new(); @@ -578,6 +581,8 @@ async fn sign_debug_request( tx.commit().await?; + windmill_common::feature_usage::log_feature_usage("debugger", "session", lang_key); + Ok(Json(SignedDebugPayload { token, code: request.code, diff --git a/backend/windmill-api/src/ai_evals/datasets.rs b/backend/windmill-api/src/ai_evals/datasets.rs index da901276b0..8e1acb3d2a 100644 --- a/backend/windmill-api/src/ai_evals/datasets.rs +++ b/backend/windmill-api/src/ai_evals/datasets.rs @@ -190,6 +190,8 @@ pub async fn create_dataset( } tx.commit().await?; + windmill_common::feature_usage::log_feature_usage("ai_agent_eval", "dataset_created", ""); + Ok(format!("Created eval dataset {}", payload.path)) } diff --git a/backend/windmill-api/src/ai_evals/run.rs b/backend/windmill-api/src/ai_evals/run.rs index e102abaf4c..5d282c7e5b 100644 --- a/backend/windmill-api/src/ai_evals/run.rs +++ b/backend/windmill-api/src/ai_evals/run.rs @@ -707,6 +707,17 @@ pub async fn run_experiment( .await?; return Err(e); } + // Which state of the agent was measured is the whole key vocabulary: it is what separates + // running what is deployed from measuring edits or an older version. + windmill_common::feature_usage::log_feature_usage( + "ai_agent_eval", + "run", + match subject.kind { + EvalSubjectKind::Agent => "agent", + EvalSubjectKind::AgentDraft => "agent_draft", + EvalSubjectKind::AgentVersion => "agent_version", + }, + ); Ok(experiment_id.to_string()) } diff --git a/docs/feature-telemetry.md b/docs/feature-telemetry.md index 57467e25ec..5d7f0b2c29 100644 --- a/docs/feature-telemetry.md +++ b/docs/feature-telemetry.md @@ -4,10 +4,10 @@ anonymous usage-stats payload. It answers "does anyone use this, and which variant do they pick" without any identifying data leaving the instance. -It currently carries 21 registered actions across nine features (`ai_session`, `ai_chat`, -`flow_editor`, `flow_run`, `flow_step`, `trigger`, `command_script`, `hub_script`, -`usage_meter`). Nearly all of the product is uninstrumented, so new user-facing work is the -opportunity to change that. +It currently carries 28 registered actions across fourteen features (`ai_session`, `ai_chat`, +`ai_fix`, `ai_agent`, `ai_agent_eval`, `flow_editor`, `flow_run`, `flow_step`, `run_form`, +`debugger`, `trigger`, `command_script`, `hub_script`, `usage_meter`). Nearly all of the +product is uninstrumented, so new user-facing work is the opportunity to change that. ## When to instrument diff --git a/frontend/src/lib/components/InstanceSettings.svelte b/frontend/src/lib/components/InstanceSettings.svelte index 62bbe1ef93..91e00d8c71 100644 --- a/frontend/src/lib/components/InstanceSettings.svelte +++ b/frontend/src/lib/components/InstanceSettings.svelte @@ -1069,8 +1069,9 @@
  • git sync repo count (sync vs promotion mode)
  • feature usage (counts of which product features are used, including AI provider and - model identifiers, the names of public hub scripts used, and the plan tier and quota - shown when the execution meter is opened, last 30 days)
  • feature adoption (counts of which flow, script, trigger and worker features your @@ -1121,8 +1122,9 @@
  • development instance status
  • feature usage (counts of which product features are used, including AI provider and - model identifiers, the names of public hub scripts used, and the plan tier and quota - shown when the execution meter is opened, last 30 days)
  • feature adoption (counts of which flow, script, trigger and worker features your diff --git a/frontend/src/lib/components/copilot/AIFormAssistant.svelte b/frontend/src/lib/components/copilot/AIFormAssistant.svelte index ba8ed67084..db6a341c58 100644 --- a/frontend/src/lib/components/copilot/AIFormAssistant.svelte +++ b/frontend/src/lib/components/copilot/AIFormAssistant.svelte @@ -5,6 +5,7 @@ import OpenInSessionButton from '$lib/components/sessions/OpenInSessionButton.svelte' import { AIBtnClasses } from './chat/AIButtonStyle' import { workspaceStore } from '$lib/stores' + import { logFeatureUsage } from '$lib/utils/featureUsage' interface Props { onEditInstructions: () => void @@ -15,7 +16,16 @@ const { onEditInstructions, instructions, runnableType, path }: Props = $props() + // Anonymous counter for this card being acted on, keyed by what it sits above. The two + // branches share one counter: they are the same intent, and which of them is on screen + // follows the user's session gate rather than a choice made here. `beforeOpen` is the + // hand-off's only per-click hook, and it runs before anything can turn the click away. + function logAsked() { + logFeatureUsage('run_form', 'ai_fill', { key: runnableType }) + } + async function fillFormWithAI() { + logAsked() aiChatManager.openChat() aiChatManager.askAi(`Analyze the ${runnableType} form on this page and fill the inputs for me`) } @@ -29,6 +39,7 @@ ? { target: { kind: runnableType, path } as const, workspaceId: $workspaceStore ?? undefined, + beforeOpen: logAsked, seedPrompt: `Run the deployed ${runnableType} \`${path}\` for me. Pick sensible inputs, ` + `tell me what you chose, then run it.` + diff --git a/frontend/src/lib/components/copilot/ScriptFix.svelte b/frontend/src/lib/components/copilot/ScriptFix.svelte index bbd9341700..d6ccb26ab8 100644 --- a/frontend/src/lib/components/copilot/ScriptFix.svelte +++ b/frontend/src/lib/components/copilot/ScriptFix.svelte @@ -13,6 +13,7 @@ import { getOpenInSessionHandoff } from '$lib/components/sessions/openInSessionContext' import { AIBtnClasses } from './chat/AIButtonStyle' import { getContext } from 'svelte' + import { logFeatureUsage } from '$lib/utils/featureUsage' let { lang, @@ -45,9 +46,28 @@ ? `Fix this error in ${what}:\n\n\`\`\`\n${error}\n\`\`\`` : `Fix the error from the last run of ${what}.` }) + // Anonymous counter for a failing run being handed to AI, keyed by where the run was. All + // three branches below report the same action: which one is on screen follows the session + // gate and whether a chat is already beside this panel, not a choice made here. + function logAiFix() { + logFeatureUsage('ai_fix', 'requested', { key: moduleId ? 'flow_step' : 'script' }) + } + const sessionSource = $derived.by(() => { const source = handoff?.source({ moduleId }) - return source ? { ...source, seedPrompt, autoSend: true } : undefined + if (!source) return undefined + // The counter wraps the editor's own hook rather than replacing it: that hook persists + // the draft the session opens on, so dropping it would fix an older copy of the code. + const editorBeforeOpen = source.beforeOpen + return { + ...source, + seedPrompt, + autoSend: true, + beforeOpen: async () => { + logAiFix() + await editorBeforeOpen?.() + } + } }) // Inside a session pane the chat is already beside this panel, so there is @@ -65,7 +85,10 @@ color="light" spacingSize="xs2" startIcon={{ icon: WandSparkles }} - on:click={() => sessionScopedManager.sendOrQueue(seedPrompt)} + on:click={() => { + logAiFix() + sessionScopedManager.sendOrQueue(seedPrompt) + }} btnClasses={AIBtnClasses('default')} > AI Fix @@ -99,6 +122,7 @@ startIcon={{ icon: WandSparkles }} on:click={() => { if ($copilotInfo.enabled) { + logAiFix() aiChatManager.fix() } }} diff --git a/frontend/src/lib/components/copilot/StepInputGen.svelte b/frontend/src/lib/components/copilot/StepInputGen.svelte index 839792c5f0..5b912d4390 100644 --- a/frontend/src/lib/components/copilot/StepInputGen.svelte +++ b/frontend/src/lib/components/copilot/StepInputGen.svelte @@ -10,6 +10,7 @@ import { dfs } from '../flows/dfs' import { yamlStringifyExceptKeys } from './utils' import type { FlowCopilotContext } from './flow' + import { logStepInputFill } from './stepInputFillTelemetry' import { stepInputCompletionEnabled } from '$lib/stores' import type { SchemaProperty } from '$lib/common' import FlowCopilotInputsModal from './FlowCopilotInputsModal.svelte' @@ -66,6 +67,7 @@ if (generatedContent.length > 0 || loading) { return } + logStepInputFill('single') abortController = new AbortController() loading = true const flow: Flow = JSON.parse(JSON.stringify(flowStore.val)) diff --git a/frontend/src/lib/components/copilot/StepInputsGen.svelte b/frontend/src/lib/components/copilot/StepInputsGen.svelte index 99199812b5..5b1ddb741b 100644 --- a/frontend/src/lib/components/copilot/StepInputsGen.svelte +++ b/frontend/src/lib/components/copilot/StepInputsGen.svelte @@ -11,6 +11,7 @@ import { sendUserToast } from '$lib/toast' import Button from '../common/button/Button.svelte' import type { FlowCopilotContext } from './flow' + import { logStepInputFill } from './stepInputFillTelemetry' import { Check, ExternalLink, Loader2, Wand2 } from 'lucide-svelte' import { stepInputCompletionEnabled } from '$lib/stores' import { copilotInfo } from '$lib/aiStore' @@ -44,6 +45,7 @@ if (Object.keys($generatedExprs || {}).length > 0 || loading) { return } + logStepInputFill('all') abortController = new AbortController() loading = true stepInputsLoading?.set(true) diff --git a/frontend/src/lib/components/copilot/stepInputFillTelemetry.ts b/frontend/src/lib/components/copilot/stepInputFillTelemetry.ts new file mode 100644 index 0000000000..19efc2b932 --- /dev/null +++ b/frontend/src/lib/components/copilot/stepInputFillTelemetry.ts @@ -0,0 +1,13 @@ +import { logFeatureUsage } from '$lib/utils/featureUsage' + +// Anonymous counters for the AI filling of a step's inputs. Same rules as every other +// `logFeatureUsage` caller: aggregated counts only, and the two keys below are the whole +// vocabulary — no argument name, expression or step id ever reaches here. + +/** Which filler the user reached for: the per-field one, or the one above the whole form. */ +export type StepInputFillScope = 'single' | 'all' + +/** Counted where the user asks for a suggestion, not where one is accepted. */ +export function logStepInputFill(scope: StepInputFillScope): void { + logFeatureUsage('flow_step', 'ai_fill', { key: scope }) +} diff --git a/frontend/src/lib/components/flows/agentTelemetry.ts b/frontend/src/lib/components/flows/agentTelemetry.ts new file mode 100644 index 0000000000..fcfad440ff --- /dev/null +++ b/frontend/src/lib/components/flows/agentTelemetry.ts @@ -0,0 +1,19 @@ +import { logFeatureUsage } from '$lib/utils/featureUsage' + +// Anonymous counters for the reusable-agent lifecycle (`docs/reusable-ai-agents.md`). Same rules +// as every other `logFeatureUsage` caller: aggregated counts only, and the four keys below are +// the whole vocabulary — no agent path, prompt, model or tool ever reaches here. + +export type ReusableAgentEvent = + /** A step was saved as a new reusable agent. */ + | 'saved' + /** Edits to a linked agent were written back, propagating to every flow using it. */ + | 'updated' + /** A saved agent was picked into a new step. */ + | 'linked' + /** A linked step was forked back into a standalone agent. */ + | 'unlinked' + +export function logReusableAgentUsage(event: ReusableAgentEvent): void { + logFeatureUsage('ai_agent', 'reusable', { key: event }) +} diff --git a/frontend/src/lib/components/flows/content/AgentResourceBar.svelte b/frontend/src/lib/components/flows/content/AgentResourceBar.svelte index 687dc9d98c..f5d5ec19a1 100644 --- a/frontend/src/lib/components/flows/content/AgentResourceBar.svelte +++ b/frontend/src/lib/components/flows/content/AgentResourceBar.svelte @@ -31,6 +31,7 @@ linkedToolsScope } from '../linkedAgentToolsStore.svelte' import { getAgentEdit, getAgentEditingPath, setAgentEditingPath } from '../agentEditStore.svelte' + import { logReusableAgentUsage } from '../agentTelemetry' import { claimLinkedToolsFetch } from '../flowState' import type { AgentTool as AgentToolStrict } from '../agentToolUtils' import { resource } from 'runed' @@ -338,6 +339,7 @@ const linked = await persist(newPath, description) saveDrawer?.closeDrawer() if (linked) { + logReusableAgentUsage(updating ? 'updated' : 'saved') sendUserToast(updating ? `Updated agent ${newPath}` : `Saved reusable agent ${newPath}`) } } catch (e) { @@ -356,6 +358,7 @@ const path = editingPath try { if (await persist(path)) { + logReusableAgentUsage('updated') sendUserToast(`Updated agent ${path}`) } } catch (e) { @@ -419,6 +422,7 @@ const fork = await forkFromResource(true) if (fork) { setAgentEditingPath(tools, undefined) + logReusableAgentUsage('unlinked') sendUserToast('Forked agent. Its configuration was copied into this step') } else { sendUserToast('The step changed while loading the agent, so nothing was unlinked', true) diff --git a/frontend/src/lib/components/flows/map/InsertModuleInner.svelte b/frontend/src/lib/components/flows/map/InsertModuleInner.svelte index 3316463bbb..d1e65de1ea 100644 --- a/frontend/src/lib/components/flows/map/InsertModuleInner.svelte +++ b/frontend/src/lib/components/flows/map/InsertModuleInner.svelte @@ -14,6 +14,7 @@ import { ResourceService } from '$lib/gen' import { workspaceStore } from '$lib/stores' import type { FlowEditorContext } from '../types' + import { logReusableAgentUsage } from '../agentTelemetry' import { BotIcon, Loader2, Plus } from 'lucide-svelte' const dispatch = createEventDispatcher() @@ -271,6 +272,7 @@