diff --git a/backend/.sqlx/query-1bf189625a4f14e12e0d0510eb534600b68125fb55f77ad3abf3333ebab22416.json b/backend/.sqlx/query-1bf189625a4f14e12e0d0510eb534600b68125fb55f77ad3abf3333ebab22416.json new file mode 100644 index 0000000000..e7cd2e52b7 --- /dev/null +++ b/backend/.sqlx/query-1bf189625a4f14e12e0d0510eb534600b68125fb55f77ad3abf3333ebab22416.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT git_sync FROM workspace_settings WHERE git_sync IS NOT NULL", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "git_sync", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + true + ] + }, + "hash": "1bf189625a4f14e12e0d0510eb534600b68125fb55f77ad3abf3333ebab22416" +} diff --git a/backend/.sqlx/query-3ec92c1682f3ce701028f66f7ce83030e7a7ce32971a5621aceb738a3673f943.json b/backend/.sqlx/query-3ec92c1682f3ce701028f66f7ce83030e7a7ce32971a5621aceb738a3673f943.json new file mode 100644 index 0000000000..3d91feace1 --- /dev/null +++ b/backend/.sqlx/query-3ec92c1682f3ce701028f66f7ce83030e7a7ce32971a5621aceb738a3673f943.json @@ -0,0 +1,44 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT provider, model, mode,\n COUNT(*)::BIGINT as \"session_count!\",\n COALESCE(SUM(message_count), 0)::BIGINT as \"message_count!\"\n FROM ai_chat_usage\n WHERE created_at > NOW() - INTERVAL '30 days'\n GROUP BY provider, model, mode\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "provider", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "model", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "mode", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "session_count!", + "type_info": "Int8" + }, + { + "ordinal": 4, + "name": "message_count!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false, + null, + null + ] + }, + "hash": "3ec92c1682f3ce701028f66f7ce83030e7a7ce32971a5621aceb738a3673f943" +} diff --git a/backend/.sqlx/query-98746829ab854dff922a04822bc86122e4ceb34cc8993937dff24cdd7ba3fe5f.json b/backend/.sqlx/query-98746829ab854dff922a04822bc86122e4ceb34cc8993937dff24cdd7ba3fe5f.json new file mode 100644 index 0000000000..58f8729225 --- /dev/null +++ b/backend/.sqlx/query-98746829ab854dff922a04822bc86122e4ceb34cc8993937dff24cdd7ba3fe5f.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM ai_chat_usage WHERE created_at < NOW() - INTERVAL '60 days'", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "98746829ab854dff922a04822bc86122e4ceb34cc8993937dff24cdd7ba3fe5f" +} diff --git a/backend/.sqlx/query-b64f0f337aed44840cd68f4c81bd523ec2219920a64783cd5d2972f1826114cc.json b/backend/.sqlx/query-b64f0f337aed44840cd68f4c81bd523ec2219920a64783cd5d2972f1826114cc.json new file mode 100644 index 0000000000..ed4288ea5c --- /dev/null +++ b/backend/.sqlx/query-b64f0f337aed44840cd68f4c81bd523ec2219920a64783cd5d2972f1826114cc.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO ai_chat_usage (session_id, provider, model, mode) VALUES ($1, $2, $3, $4)\n ON CONFLICT (session_id) DO UPDATE SET message_count = ai_chat_usage.message_count + 1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "b64f0f337aed44840cd68f4c81bd523ec2219920a64783cd5d2972f1826114cc" +} diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 648a079815..ebad6f5c76 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -278a3887f759f9d1146554baa0765518d5bc70f2 +878cc2044717e0177228529a50433fe2768e70b5 diff --git a/backend/migrations/20260317000000_ai_chat_usage.down.sql b/backend/migrations/20260317000000_ai_chat_usage.down.sql new file mode 100644 index 0000000000..5823ee37a1 --- /dev/null +++ b/backend/migrations/20260317000000_ai_chat_usage.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS ai_chat_usage; diff --git a/backend/migrations/20260317000000_ai_chat_usage.up.sql b/backend/migrations/20260317000000_ai_chat_usage.up.sql new file mode 100644 index 0000000000..015a7c4daa --- /dev/null +++ b/backend/migrations/20260317000000_ai_chat_usage.up.sql @@ -0,0 +1,12 @@ +-- Table to track AI chat sessions and message counts for telemetry +CREATE TABLE IF NOT EXISTS ai_chat_usage ( + id BIGSERIAL PRIMARY KEY, + session_id VARCHAR(36) NOT NULL UNIQUE, + provider VARCHAR(50) NOT NULL, + model VARCHAR(255) NOT NULL, + mode VARCHAR(50) NOT NULL, + message_count INT NOT NULL DEFAULT 1, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_ai_chat_usage_created_at ON ai_chat_usage (created_at); diff --git a/backend/windmill-api-settings/src/lib.rs b/backend/windmill-api-settings/src/lib.rs index e026386474..3166e31bbc 100644 --- a/backend/windmill-api-settings/src/lib.rs +++ b/backend/windmill-api-settings/src/lib.rs @@ -579,8 +579,17 @@ pub async fn send_stats(Extension(db): Extension, authed: ApiAuthed) -> Resu Ok("Sent stats".to_string()) } +#[derive(serde::Serialize)] +pub struct StatsDownload { + pub signature: String, + pub data: String, +} + #[cfg(feature = "enterprise")] -pub async fn get_stats(Extension(db): Extension, authed: ApiAuthed) -> Result { +pub async fn get_stats( + Extension(db): Extension, + authed: ApiAuthed, +) -> error::JsonResult { require_super_admin(&db, &authed.email).await?; let stats = windmill_common::stats_oss::get_stats_payload( &db, @@ -588,12 +597,14 @@ pub async fn get_stats(Extension(db): Extension, authed: ApiAuthed) -> Resul false, ) .await?; - let encrypted = windmill_common::stats_oss::encrypt_stats(&stats)?; - Ok(encrypted) + let json = + serde_json::to_string(&stats).map_err(|e| error::Error::InternalErr(e.to_string()))?; + let signature = windmill_common::stats_oss::sign_stats(&json); + Ok(axum::Json(StatsDownload { signature, data: json })) } #[cfg(not(feature = "enterprise"))] -pub async fn get_stats() -> Result { +pub async fn get_stats() -> error::JsonResult { Err(error::Error::BadRequest( "Downloading telemetry is only available on enterprise edition".to_string(), )) diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 92c604de33..cc2cb0c797 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -157,6 +157,7 @@ pub fn workspaced_service() -> Router { "/protection_rules/:rule_name", post(update_protection_rule).delete(delete_protection_rule), ) + .route("/log_chat", post(log_ai_chat)) } pub fn global_service() -> Router { Router::new() @@ -5372,3 +5373,28 @@ async fn compare_two_folders( exists_in_fork: target_folder.is_some(), }); } + +#[derive(Deserialize)] +struct LogAiChatPayload { + session_id: String, + provider: String, + model: String, + mode: String, +} + +async fn log_ai_chat( + Extension(db): Extension, + Json(payload): Json, +) -> Result { + sqlx::query!( + "INSERT INTO ai_chat_usage (session_id, provider, model, mode) VALUES ($1, $2, $3, $4) + ON CONFLICT (session_id) DO UPDATE SET message_count = ai_chat_usage.message_count + 1", + &payload.session_id, + &payload.provider, + &payload.model, + &payload.mode + ) + .execute(&db) + .await?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 5b88630d71..4213eb39e7 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1370,17 +1370,22 @@ paths: /settings/get_stats: get: - summary: get encrypted telemetry stats (EE only) + summary: get telemetry stats with HMAC signature (EE only) operationId: getStats tags: - setting responses: "200": - description: base64-encoded encrypted telemetry blob + description: telemetry stats JSON with signature content: - text/plain: + application/json: schema: - type: string + type: object + properties: + signature: + type: string + data: + type: string /settings/latest_key_renewal_attempt: get: @@ -4455,6 +4460,37 @@ paths: type: string "404": description: protection rule not found + /w/{workspace}/workspaces/log_chat: + post: + summary: log AI chat message + operationId: logAiChat + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - session_id + - provider + - model + - mode + properties: + session_id: + type: string + provider: + type: string + model: + type: string + mode: + type: string + responses: + "204": + description: logged /w/{workspace}/workspaces/public_app_rate_limit: post: summary: Set public app rate limit for this workspace diff --git a/backend/windmill-common/src/stats_oss.rs b/backend/windmill-common/src/stats_oss.rs index d3cc2e2b44..99a3c49bc0 100644 --- a/backend/windmill-common/src/stats_oss.rs +++ b/backend/windmill-common/src/stats_oss.rs @@ -67,7 +67,7 @@ pub async fn get_stats_payload( } #[cfg(not(feature = "private"))] -pub fn encrypt_stats(_stats: &Stats) -> Result { +pub fn sign_stats(_json: &str) -> String { // stats details are closed source - Ok(String::new()) + String::new() } diff --git a/frontend/src/lib/components/InstanceSettings.svelte b/frontend/src/lib/components/InstanceSettings.svelte index 9049b9b08a..18514b3836 100644 --- a/frontend/src/lib/components/InstanceSettings.svelte +++ b/frontend/src/lib/components/InstanceSettings.svelte @@ -268,12 +268,13 @@ async function downloadStats() { try { downloadingStats = true - const encryptedData = await SettingService.getStats() - const blob = new Blob([encryptedData], { type: 'application/octet-stream' }) + const result = await SettingService.getStats() + const blob = new Blob([result.data ?? ''], { type: 'application/json' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url - a.download = `windmill-telemetry-${new Date().toISOString().split('T')[0]}.enc` + const date = new Date().toISOString().split('T')[0] + a.download = `windmill-telemetry-${date}-${result.signature}.json` document.body.appendChild(a) a.click() document.body.removeChild(a) @@ -977,6 +978,10 @@
When minimal telemetry is disabled, the following is also collected:
  • job usage (language, total duration, count)
  • +
  • git sync repo count (sync vs promotion mode)
  • +
  • AI chat usage (provider, model, mode, session count, message count — last 30 days)

For air-gapped instances, you can download the telemetry data and send it manually. @@ -1012,6 +1017,9 @@
  • worker usage (worker, worker instance, vCPUs, memory)
  • user usage (author count, operator count)
  • development instance status
  • +
  • AI chat usage (provider, model, mode, session count, message count — last 30 days)
  • {/if} diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 9ba412fb4a..00a1a11fdf 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -1,4 +1,5 @@ import type { AIProviderModel, ScriptLang } from '$lib/gen/types.gen' +import { WorkspaceService } from '$lib/gen' import type { FlowOptions, ScriptOptions } from './ContextManager.svelte' import { flowTools, @@ -42,7 +43,8 @@ import { getStringError } from './utils' import type { FlowModuleState, FlowState } from '$lib/components/flows/flowState' import type { CurrentEditor, ExtendedOpenFlow } from '$lib/components/flows/types' import { untrack } from 'svelte' -import { type DBSchemas } from '$lib/stores' +import { get } from 'svelte/store' +import { workspaceStore, type DBSchemas } from '$lib/stores' import { askTools, prepareAskSystemMessage, prepareAskUserMessage } from './ask/core' import { chatState, DEFAULT_SIZE, triggerablesByAi } from './sharedChatState.svelte' import type { @@ -660,6 +662,19 @@ class AIChatManager { this.#automaticScroll = true this.abortController = new AbortController() + const model = tryGetCurrentModel() + if (model) { + WorkspaceService.logAiChat({ + workspace: get(workspaceStore) ?? '', + requestBody: { + session_id: this.historyManager.getCurrentChatId(), + provider: model.provider, + model: model.model, + mode: this.mode + } + }).catch(() => {}) + } + if (this.mode === AIMode.FLOW && !this.flowAiChatHelpers) { throw new Error('No flow helpers found') } diff --git a/frontend/src/lib/components/copilot/chat/HistoryManager.svelte.ts b/frontend/src/lib/components/copilot/chat/HistoryManager.svelte.ts index d4396e0ea6..87be590dcc 100644 --- a/frontend/src/lib/components/copilot/chat/HistoryManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/HistoryManager.svelte.ts @@ -65,6 +65,10 @@ export default class HistoryManager { this.indexDB?.close() } + getCurrentChatId() { + return this.currentChatId + } + getPastChats() { return this.pastChats }