improve analytics (#8418)

* [ee] improve analytics: add git sync & AI chat telemetry, HMAC-signed download

- Add ai_chat_usage table to track chat sessions (session_id, provider, model, mode, message_count)
- Add POST /w/{workspace}/workspaces/log_chat endpoint with upsert on session_id
- Frontend fires logAiChat on every sendRequest, using HistoryManager's existing chat ID
- EE stats: add git_sync_usage (sync vs promotion repo count) and ai_chat_usage (30-day aggregates)
- Replace RSA+AES-GCM encrypted telemetry download with plaintext JSON + HMAC-SHA256 signature
- Signature (12 hex chars) included in download filename for verification
- Update instance settings telemetry descriptions for both EE and CE

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: make StatsDownload struct pub to fix private-interfaces error

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to 878cc2044717e0177228529a50433fe2768e70b5

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

Previous ee-repo-ref: 33eb863b6b881bd54ed69a540e0c65d5fe125024

New ee-repo-ref: 878cc2044717e0177228529a50433fe2768e70b5

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
hugocasa
2026-03-17 21:14:02 +01:00
committed by GitHub
parent fe051aa22b
commit 8c769aebbf
14 changed files with 221 additions and 15 deletions
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
+1 -1
View File
@@ -1 +1 @@
278a3887f759f9d1146554baa0765518d5bc70f2
878cc2044717e0177228529a50433fe2768e70b5
@@ -0,0 +1 @@
DROP TABLE IF EXISTS ai_chat_usage;
@@ -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);
+15 -4
View File
@@ -579,8 +579,17 @@ pub async fn send_stats(Extension(db): Extension<DB>, 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<DB>, authed: ApiAuthed) -> Result<String> {
pub async fn get_stats(
Extension(db): Extension<DB>,
authed: ApiAuthed,
) -> error::JsonResult<StatsDownload> {
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<DB>, 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<String> {
pub async fn get_stats() -> error::JsonResult<StatsDownload> {
Err(error::Error::BadRequest(
"Downloading telemetry is only available on enterprise edition".to_string(),
))
@@ -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<DB>,
Json(payload): Json<LogAiChatPayload>,
) -> Result<StatusCode> {
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)
}
+40 -4
View File
@@ -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
+2 -2
View File
@@ -67,7 +67,7 @@ pub async fn get_stats_payload(
}
#[cfg(not(feature = "private"))]
pub fn encrypt_stats(_stats: &Stats) -> Result<String> {
pub fn sign_stats(_json: &str) -> String {
// stats details are closed source
Ok(String::new())
String::new()
}
@@ -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 @@
<br />When minimal telemetry is disabled, the following is also collected:
<ul class="list-disc list-inside pl-2">
<li>job usage (language, total duration, count)</li>
<li>git sync repo count (sync vs promotion mode)</li>
<li
>AI chat usage (provider, model, mode, session count, message count — last 30 days)</li
>
</ul>
<br />For air-gapped instances, you can download the telemetry data and send it manually.
</div>
@@ -1012,6 +1017,9 @@
<li>worker usage (worker, worker instance, vCPUs, memory)</li>
<li>user usage (author count, operator count)</li>
<li>development instance status</li>
<li
>AI chat usage (provider, model, mode, session count, message count — last 30 days)</li
>
</ul>
</div>
{/if}
@@ -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')
}
@@ -65,6 +65,10 @@ export default class HistoryManager {
this.indexDB?.close()
}
getCurrentChatId() {
return this.currentChatId
}
getPastChats() {
return this.pastChats
}