feat(telemetry): extend feature-usage tracking beyond AI features (#10681)

* feat(telemetry): extend feature-usage tracking to long-tail features

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

* docs: describe telemetry as product feature usage rather than AI usage

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

* refactor(telemetry): trim disclosure copy and drop unused pick origin

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

* fix(telemetry): count trigger fires per run and key hub picks from hub data

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

* fix(telemetry): slugify hub keys and order both writers' upserts

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

* fix(telemetry): key native trigger adoption by service so it matches fires

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

* chore: update ee-repo-ref for native trigger adoption fix

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

* refactor(telemetry): move feature-usage collection into the ee crate

* docs: point feature-telemetry at the moved registry and rust writer

* docs: correct the trigger-fire gate comment to match measured step counts

* docs: put the private-build caveat on the verification step

* chore: update ee-repo-ref to f079db9e7962a413b349c4ff8036080894f30771

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

Previous ee-repo-ref: 055adb80416f9339c9a28ae7fbaeadad30d74959

New ee-repo-ref: f079db9e7962a413b349c4ff8036080894f30771

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
hugocasa
2026-08-14 18:50:38 +02:00
committed by GitHub
co-authored by Claude Opus 5 Ruben Fiszel windmill-internal-app[bot]
parent 98bacab907
commit 53eb94659b
26 changed files with 632 additions and 60 deletions
@@ -11489,41 +11489,6 @@ 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_session", "beta_optout"),
("ai_session", "beta_optin"),
("ai_chat", "message"),
("ai_chat", "model"),
("ai_chat", "tool"),
("flow_editor", "panel_placement"),
];
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<LogFeatureUsagePayload>,
@@ -11532,7 +11497,15 @@ async fn log_feature_usage(
// 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) {
// Which actions may be recorded lives in
// `windmill_common::feature_usage`, shared with the in-process writer so
// both admit exactly the same events.
if !windmill_common::feature_usage::is_recordable_event(
&e.feature,
&e.kind,
&e.key,
&e.entity_id,
) {
continue;
}
let value = e.value.unwrap_or(1).clamp(1, 1_000_000);
@@ -11542,12 +11515,18 @@ async fn log_feature_usage(
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 {
// Sorted for the same reason as `flush_feature_usage`: this endpoint and the
// backend flusher upsert the same rows, and two batches touching them in
// opposite orders deadlock.
let mut rows: Vec<((String, String, String, String), i64)> = agg.into_iter().collect();
rows.sort_unstable_by(|a, b| a.0.cmp(&b.0));
let mut features = Vec::with_capacity(rows.len());
let mut kinds = Vec::with_capacity(rows.len());
let mut keys = Vec::with_capacity(rows.len());
let mut entity_ids = Vec::with_capacity(rows.len());
let mut values = Vec::with_capacity(rows.len());
for ((feature, kind, key, entity_id), value) in rows {
features.push(feature);
kinds.push(kind);
keys.push(key);