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:
hugocasa
2026-07-20 20:56:57 +02:00
committed by GitHub
parent f635bd5ae7
commit 11fda89b52
24 changed files with 566 additions and 122 deletions
@@ -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?;