mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-23 16:00:38 +00:00
feat(telemetry): generic feature-usage telemetry with AI session metrics (#10200)
* 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>
This commit is contained in:
@@ -199,7 +199,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))
|
||||
.route("/log_feature_usage", post(log_feature_usage))
|
||||
.route("/cloud_quotas", get(get_cloud_quotas))
|
||||
.route("/prune_versions", post(prune_versions))
|
||||
.route("/list_ws_specific", get(list_ws_specific))
|
||||
@@ -9559,25 +9559,96 @@ const TRIGGER_OR_SCHEDULE_TABLES: &[&str] = &[
|
||||
"email_trigger",
|
||||
];
|
||||
|
||||
const MAX_FEATURE_USAGE_EVENTS: usize = 50;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LogAiChatPayload {
|
||||
session_id: String,
|
||||
provider: String,
|
||||
model: String,
|
||||
mode: String,
|
||||
struct FeatureUsageEvent {
|
||||
feature: String,
|
||||
kind: String,
|
||||
#[serde(default)]
|
||||
key: String,
|
||||
#[serde(default)]
|
||||
entity_id: String,
|
||||
value: Option<i64>,
|
||||
}
|
||||
|
||||
async fn log_ai_chat(
|
||||
#[derive(Deserialize)]
|
||||
struct LogFeatureUsagePayload {
|
||||
events: Vec<FeatureUsageEvent>,
|
||||
}
|
||||
|
||||
// Only registered (feature, kind) actions are accepted, so telemetry stays
|
||||
// limited to predefined feature actions. Keys are shape-checked (identifier-like,
|
||||
// no spaces) rather than pinned to value sets: they come from our own frontend
|
||||
// (modes, tab/draft kinds, tool names, provider:model) and pinning every value
|
||||
// server-side was not worth the maintenance.
|
||||
const FEATURE_USAGE_KINDS: &[(&str, &str)] = &[
|
||||
("ai_session", "created"),
|
||||
("ai_session", "message"),
|
||||
("ai_session", "autonomy"),
|
||||
("ai_session", "tab"),
|
||||
("ai_session", "tokens"),
|
||||
("ai_session", "deployed"),
|
||||
("ai_session", "archived"),
|
||||
("ai_session", "deleted"),
|
||||
("ai_chat", "message"),
|
||||
("ai_chat", "model"),
|
||||
("ai_chat", "tool"),
|
||||
];
|
||||
|
||||
fn is_identifier_shaped(s: &str, max_len: usize) -> bool {
|
||||
!s.is_empty()
|
||||
&& s.len() <= max_len
|
||||
&& s.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | ':' | '.' | '/'))
|
||||
}
|
||||
|
||||
fn valid_feature_usage_event(e: &FeatureUsageEvent) -> bool {
|
||||
FEATURE_USAGE_KINDS.contains(&(e.feature.as_str(), e.kind.as_str()))
|
||||
&& (e.key.is_empty() || is_identifier_shaped(&e.key, 100))
|
||||
&& (e.entity_id.is_empty() || is_identifier_shaped(&e.entity_id, 50))
|
||||
}
|
||||
|
||||
async fn log_feature_usage(
|
||||
Extension(db): Extension<DB>,
|
||||
Json(payload): Json<LogAiChatPayload>,
|
||||
Json(payload): Json<LogFeatureUsagePayload>,
|
||||
) -> Result<StatusCode> {
|
||||
// Pre-sum duplicate keys: two rows hitting the same conflict target in a
|
||||
// single INSERT error out ("cannot affect row a second time").
|
||||
let mut agg: HashMap<(String, String, String, String), i64> = HashMap::new();
|
||||
for e in payload.events.into_iter().take(MAX_FEATURE_USAGE_EVENTS) {
|
||||
if !valid_feature_usage_event(&e) {
|
||||
continue;
|
||||
}
|
||||
let value = e.value.unwrap_or(1).clamp(1, 1_000_000);
|
||||
*agg.entry((e.feature, e.kind, e.key, e.entity_id))
|
||||
.or_insert(0) += value;
|
||||
}
|
||||
if agg.is_empty() {
|
||||
return Ok(StatusCode::NO_CONTENT);
|
||||
}
|
||||
let mut features = Vec::with_capacity(agg.len());
|
||||
let mut kinds = Vec::with_capacity(agg.len());
|
||||
let mut keys = Vec::with_capacity(agg.len());
|
||||
let mut entity_ids = Vec::with_capacity(agg.len());
|
||||
let mut values = Vec::with_capacity(agg.len());
|
||||
for ((feature, kind, key, entity_id), value) in agg {
|
||||
features.push(feature);
|
||||
kinds.push(kind);
|
||||
keys.push(key);
|
||||
entity_ids.push(entity_id);
|
||||
values.push(value);
|
||||
}
|
||||
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
|
||||
"INSERT INTO feature_usage (feature, kind, key, entity_id, value)
|
||||
SELECT * FROM UNNEST($1::text[], $2::text[], $3::text[], $4::text[], $5::bigint[])
|
||||
ON CONFLICT (feature, kind, key, entity_id, day)
|
||||
DO UPDATE SET value = feature_usage.value + EXCLUDED.value, updated_at = now()",
|
||||
&features,
|
||||
&kinds,
|
||||
&keys,
|
||||
&entity_ids,
|
||||
&values
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
Reference in New Issue
Block a user