mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
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:
+20
@@ -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"
|
||||
}
|
||||
+44
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+17
@@ -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 @@
|
||||
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);
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user