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
parent 98bacab907
commit 53eb94659b
26 changed files with 632 additions and 60 deletions
@@ -0,0 +1,26 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT 'schedule' AS \"kind!\", COUNT(*)::BIGINT AS \"count!\" FROM schedule\n UNION ALL SELECT 'http', COUNT(*)::BIGINT FROM http_trigger\n UNION ALL SELECT 'websocket', COUNT(*)::BIGINT FROM websocket_trigger\n UNION ALL SELECT 'kafka', COUNT(*)::BIGINT FROM kafka_trigger\n UNION ALL SELECT 'nats', COUNT(*)::BIGINT FROM nats_trigger\n UNION ALL SELECT 'postgres', COUNT(*)::BIGINT FROM postgres_trigger\n UNION ALL SELECT 'mqtt', COUNT(*)::BIGINT FROM mqtt_trigger\n UNION ALL SELECT 'sqs', COUNT(*)::BIGINT FROM sqs_trigger\n UNION ALL SELECT 'gcp', COUNT(*)::BIGINT FROM gcp_trigger\n UNION ALL SELECT 'azure', COUNT(*)::BIGINT FROM azure_trigger\n UNION ALL SELECT 'amqp', COUNT(*)::BIGINT FROM amqp_trigger\n UNION ALL SELECT 'email', COUNT(*)::BIGINT FROM email_trigger\n -- Grouped, not a single 'native' key: these fire as nextcloud/google/github,\n -- so a lone key would not line up with the `trigger`/`fired` series.\n UNION ALL SELECT service_name::text, COUNT(*)::BIGINT FROM native_trigger GROUP BY service_name\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "kind!",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "count!",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null,
null
]
},
"hash": "1d346a14ad5586af347b8e7ac413500a39efa20e4915ffa56fd40537597db36e"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*)::BIGINT AS \"count!\" FROM flow\n WHERE archived = false AND pg_column_size(value) >= $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count!",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int4"
]
},
"nullable": [
null
]
},
"hash": "255d37bb63595ebfcc61582d0b5e265b861b8b4d650435533e90eeb1d5ee3a68"
}
@@ -0,0 +1,26 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n COUNT(*) FILTER (WHERE slack_command_script IS NOT NULL)::BIGINT AS \"slack!\",\n COUNT(*) FILTER (WHERE teams_command_script IS NOT NULL)::BIGINT AS \"teams!\"\n FROM workspace_settings",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "slack!",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "teams!",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null,
null
]
},
"hash": "3b0eb0571f287eb64c84cf2c8303401af7b78d0fb6cbaa10ccb90769320c8c7a"
}
@@ -0,0 +1,26 @@
{
"db_name": "PostgreSQL",
"query": "SELECT trigger_kind::text AS \"kind!\", COUNT(*)::BIGINT AS \"count!\"\n FROM capture WHERE created_at > now() - interval '30 days' GROUP BY trigger_kind",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "kind!",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "count!",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null,
null
]
},
"hash": "5344f222417c28efd4f724cbd83382fc69a223dfbb91ab40df895ab60d0f6228"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT config AS \"config!\" FROM config WHERE name LIKE 'worker__%' AND config IS NOT NULL",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "config!",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": []
},
"nullable": [
true
]
},
"hash": "6c6b4bd4bd19878fce25d3a8a5ee02686b358b616c318e4bfca084d96b38f1c4"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO feature_usage (feature, kind, key, value)\n SELECT * FROM UNNEST($1::text[], $2::text[], $3::text[], $4::bigint[])\n ON CONFLICT (feature, kind, key, entity_id, day)\n DO UPDATE SET value = feature_usage.value + EXCLUDED.value, updated_at = now()",
"describe": {
"columns": [],
"parameters": {
"Left": [
"TextArray",
"TextArray",
"TextArray",
"Int8Array"
]
},
"nullable": []
},
"hash": "772dc28e57666282d8993268843d3a87e8899b968827251745f998e4dc25863a"
}
@@ -0,0 +1,26 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n (SELECT COUNT(*) FROM script\n WHERE dedicated_worker = true AND archived = false AND deleted = false)::BIGINT AS \"scripts!\",\n (SELECT COUNT(*) FROM flow WHERE dedicated_worker = true AND archived = false)::BIGINT AS \"flows!\"",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "scripts!",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "flows!",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null,
null
]
},
"hash": "7b3eadb62ddd07e5e12eb8b5150ddce33008b61bbdab4006194ca7ef5b754802"
}
@@ -0,0 +1,26 @@
{
"db_name": "PostgreSQL",
"query": "SELECT trigger_kind::text AS \"kind!\", COUNT(*)::BIGINT AS \"count!\"\n FROM capture_config GROUP BY trigger_kind",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "kind!",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "count!",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null,
null
]
},
"hash": "a2f047f9ca4b8a47c985fa092ba0d2dc54f7169af1c940b84345c477865ae82c"
}
@@ -0,0 +1,173 @@
{
"db_name": "PostgreSQL",
"query": "\n WITH scanned AS (\n SELECT a.* FROM (\n SELECT value FROM flow\n WHERE archived = false AND pg_column_size(value) < $1\n LIMIT $2\n ) f,\n LATERAL (\n SELECT\n bool_or(m->'value'->>'type' = 'forloopflow') AS forloop,\n bool_or(m->'value'->>'type' = 'whileloopflow') AS whileloop,\n bool_or(m->'value'->>'type' = 'branchall') AS branchall,\n bool_or(m->'value'->>'type' = 'branchall'\n AND m->'value'->>'parallel' = 'false') AS branchall_seq,\n bool_or(m->'value'->>'type' = 'branchone') AS branchone,\n bool_or(m->'value'->>'type' = 'aiagent') AS aiagent,\n bool_or(m->'value'->>'type' = 'flow') AS subflow,\n bool_or(m->'value'->>'type' = 'identity') AS identity,\n bool_or(m->'value'->>'is_trigger' = 'true') AS trigger_step,\n bool_or(m->'value'->>'squash' = 'true') AS squash,\n bool_or(m->'value'->>'type' IN ('forloopflow', 'whileloopflow')\n AND m->'value'->>'parallel' = 'true') AS parallel_loop,\n bool_or(m->'value'->>'type' IN ('forloopflow', 'whileloopflow')\n AND m->'value'->>'skip_failures' = 'false') AS keep_failures,\n bool_or(m->'value' ? 'parallelism') AS parallelism,\n bool_or(m ? 'sleep') AS sleep,\n bool_or(m ? 'cache_ttl') AS cache,\n bool_or(m->'mock'->>'enabled' = 'true') AS mock,\n bool_or(m ? 'suspend') AS suspend,\n bool_or(m ? 'retry') AS retry,\n bool_or(m ? 'timeout') AS timeout,\n bool_or(m ? 'priority') AS priority,\n bool_or(m ? 'debouncing') AS debounce,\n bool_or(m ? 'delete_after_secs') AS lifetime,\n bool_or(m->>'continue_on_error' = 'true') AS continue_on_error,\n bool_or(m ? 'stop_after_if' OR m ? 'stop_after_all_iters_if') AS early_stop,\n bool_or(m ? 'skip_if') AS skip\n FROM jsonb_path_query(f.value, '$.**.modules[*]') m\n ) a\n )\n SELECT\n COUNT(*)::BIGINT AS \"flows_scanned!\",\n COUNT(*) FILTER (WHERE forloop)::BIGINT AS \"forloopflow!\",\n COUNT(*) FILTER (WHERE whileloop)::BIGINT AS \"whileloopflow!\",\n COUNT(*) FILTER (WHERE branchall)::BIGINT AS \"branchall!\",\n COUNT(*) FILTER (WHERE branchall_seq)::BIGINT AS \"branchall_sequential!\",\n COUNT(*) FILTER (WHERE branchone)::BIGINT AS \"branchone!\",\n COUNT(*) FILTER (WHERE aiagent)::BIGINT AS \"aiagent!\",\n COUNT(*) FILTER (WHERE subflow)::BIGINT AS \"subflow!\",\n COUNT(*) FILTER (WHERE identity)::BIGINT AS \"identity!\",\n COUNT(*) FILTER (WHERE trigger_step)::BIGINT AS \"trigger_step!\",\n COUNT(*) FILTER (WHERE squash)::BIGINT AS \"squash!\",\n COUNT(*) FILTER (WHERE parallel_loop)::BIGINT AS \"parallel_loop!\",\n COUNT(*) FILTER (WHERE keep_failures)::BIGINT AS \"keep_failures!\",\n COUNT(*) FILTER (WHERE parallelism)::BIGINT AS \"parallelism!\",\n COUNT(*) FILTER (WHERE sleep)::BIGINT AS \"sleep!\",\n COUNT(*) FILTER (WHERE cache)::BIGINT AS \"cache!\",\n COUNT(*) FILTER (WHERE mock)::BIGINT AS \"mock!\",\n COUNT(*) FILTER (WHERE suspend)::BIGINT AS \"suspend!\",\n COUNT(*) FILTER (WHERE retry)::BIGINT AS \"retry!\",\n COUNT(*) FILTER (WHERE timeout)::BIGINT AS \"timeout!\",\n COUNT(*) FILTER (WHERE priority)::BIGINT AS \"priority!\",\n COUNT(*) FILTER (WHERE debounce)::BIGINT AS \"debounce!\",\n COUNT(*) FILTER (WHERE lifetime)::BIGINT AS \"lifetime!\",\n COUNT(*) FILTER (WHERE continue_on_error)::BIGINT AS \"continue_on_error!\",\n COUNT(*) FILTER (WHERE early_stop)::BIGINT AS \"early_stop!\",\n COUNT(*) FILTER (WHERE skip)::BIGINT AS \"skip!\"\n FROM scanned\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "flows_scanned!",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "forloopflow!",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "whileloopflow!",
"type_info": "Int8"
},
{
"ordinal": 3,
"name": "branchall!",
"type_info": "Int8"
},
{
"ordinal": 4,
"name": "branchall_sequential!",
"type_info": "Int8"
},
{
"ordinal": 5,
"name": "branchone!",
"type_info": "Int8"
},
{
"ordinal": 6,
"name": "aiagent!",
"type_info": "Int8"
},
{
"ordinal": 7,
"name": "subflow!",
"type_info": "Int8"
},
{
"ordinal": 8,
"name": "identity!",
"type_info": "Int8"
},
{
"ordinal": 9,
"name": "trigger_step!",
"type_info": "Int8"
},
{
"ordinal": 10,
"name": "squash!",
"type_info": "Int8"
},
{
"ordinal": 11,
"name": "parallel_loop!",
"type_info": "Int8"
},
{
"ordinal": 12,
"name": "keep_failures!",
"type_info": "Int8"
},
{
"ordinal": 13,
"name": "parallelism!",
"type_info": "Int8"
},
{
"ordinal": 14,
"name": "sleep!",
"type_info": "Int8"
},
{
"ordinal": 15,
"name": "cache!",
"type_info": "Int8"
},
{
"ordinal": 16,
"name": "mock!",
"type_info": "Int8"
},
{
"ordinal": 17,
"name": "suspend!",
"type_info": "Int8"
},
{
"ordinal": 18,
"name": "retry!",
"type_info": "Int8"
},
{
"ordinal": 19,
"name": "timeout!",
"type_info": "Int8"
},
{
"ordinal": 20,
"name": "priority!",
"type_info": "Int8"
},
{
"ordinal": 21,
"name": "debounce!",
"type_info": "Int8"
},
{
"ordinal": 22,
"name": "lifetime!",
"type_info": "Int8"
},
{
"ordinal": 23,
"name": "continue_on_error!",
"type_info": "Int8"
},
{
"ordinal": 24,
"name": "early_stop!",
"type_info": "Int8"
},
{
"ordinal": 25,
"name": "skip!",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int4",
"Int8"
]
},
"nullable": [
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null
]
},
"hash": "e24252d48a1fcca73f20d62f37c9d7dc2071580be6d604979c95b3374cd4ad77"
}
@@ -0,0 +1,26 @@
{
"db_name": "PostgreSQL",
"query": "SELECT kind::text AS \"kind!\", COUNT(*)::BIGINT AS \"count!\"\n FROM script WHERE archived = false AND deleted = false GROUP BY kind",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "kind!",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "count!",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null,
null
]
},
"hash": "e63e275a158040659c41ec8d1ef9107558b0003f495ba3f5fba63b77616d12c8"
}
+1 -1
View File
@@ -1 +1 @@
af02d6bce55512b65c56adcbf69a8e15cd124d23
f079db9e7962a413b349c4ff8036080894f30771
+14
View File
@@ -3801,6 +3801,19 @@ pub async fn monitor_db(
}
};
// Not gated on server_mode: feature-usage counters accumulate wherever an
// instrumented call site runs, and a worker that never flushed would lose
// its counts on shutdown.
let feature_usage_f = async {
if !initial_load {
if let Some(db) = conn.as_sql() {
if let Err(e) = windmill_common::feature_usage::flush_feature_usage(db).await {
tracing::error!("Error flushing feature_usage counters: {e}");
}
}
}
};
let verify_license_key_f = async {
#[cfg(feature = "enterprise")]
if !initial_load {
@@ -4050,6 +4063,7 @@ pub async fn monitor_db(
join!(
expired_items_f,
feature_usage_f,
zombie_jobs_f,
stale_jobs_f,
trim_resource_versions_f,
@@ -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);
@@ -0,0 +1,26 @@
//! OSS fallback for anonymous feature-usage collection.
//!
//! Collection is a `private` feature (see `feature_usage_ee`). The public build
//! never sends a stats payload (`stats_oss`), so counting anything would only
//! write rows nothing reads: every entry point here is inert, and the
//! `log_feature_usage` endpoint accepts its posts without recording them.
use sqlx::{Pool, Postgres};
/// No action is recordable in the public build.
pub fn is_recordable_event(
_feature: &str,
_kind: &str,
_key: &str,
_entity_id: &str,
) -> bool {
false
}
/// No-op: nothing is counted in the public build.
pub fn log_feature_usage(_feature: &'static str, _kind: &'static str, _key: &str) {}
/// Nothing accumulates, so there is nothing to flush.
pub async fn flush_feature_usage(_db: &Pool<Postgres>) -> Result<(), sqlx::Error> {
Ok(())
}
+7
View File
@@ -56,6 +56,13 @@ pub mod email_ee;
pub mod email_oss;
pub mod error;
pub mod external_ip;
#[cfg(feature = "private")]
pub mod feature_usage_ee;
pub mod feature_usage_oss;
#[cfg(feature = "private")]
pub use feature_usage_ee as feature_usage;
#[cfg(not(feature = "private"))]
pub use feature_usage_oss as feature_usage;
pub mod flow_conversations;
pub mod flow_status;
pub mod flows;
+14
View File
@@ -6640,6 +6640,20 @@ async fn push_inner<'c, 'd>(
)
.unzip();
// Which trigger kinds an instance actually fires. Counted here rather than
// aggregated from `v2_job` later: that table's only usable index is
// (workspace_id, created_at), so a windowed GROUP BY over it is a full scan.
//
// Root jobs only. A scheduled flow hands every step push its own
// `schedule_path` (see `FlowJob::schedule_path`), so counting per push would
// score one run as a fire per step job — a loop pushes two of those per
// iteration — burying every other kind, and would sit on the per-step path.
if flow_step_id.is_none() {
if let Some(kind) = trigger_kind.as_ref() {
windmill_common::feature_usage::log_feature_usage("trigger", "fired", kind.as_str());
}
}
#[cfg(feature = "cloud")]
if *CLOUD_HOSTED {
check_workspace_queue_cap(&mut *tx, workspace_id).await?;
+28 -10
View File
@@ -4,9 +4,9 @@
anonymous usage-stats payload. It answers "does anyone use this, and which variant do they pick"
without any identifying data leaving the instance.
It currently carries 14 registered actions across three features (`ai_session`, `ai_chat`,
`flow_editor`). Nearly all of the product is uninstrumented, so new user-facing work is the
opportunity to change that.
It currently carries 20 registered actions across eight features (`ai_session`, `ai_chat`,
`flow_editor`, `flow_run`, `flow_step`, `trigger`, `command_script`, `hub_script`). Nearly all of
the product is uninstrumented, so new user-facing work is the opportunity to change that.
## When to instrument
@@ -49,9 +49,10 @@ vocabulary closed and small — enumerate the values in a TS union next to the c
Four steps. Skipping step 1 or 3 fails quietly.
**1. Register the pair** in `FEATURE_USAGE_KINDS`
(`backend/windmill-api-workspaces/src/workspaces.rs`). An unregistered `(feature, kind)` is
dropped by `valid_feature_usage_event` with a bare `continue` — no error, no log, still a 204 to
the browser. Frontend-only instrumentation records **nothing** and looks like it worked.
(`backend/windmill-common/src/feature_usage_ee.rs`, tracked in `windmill-ee-private`). An
unregistered `(feature, kind)` is dropped by `is_recordable_event` with a bare `continue` — no
error, no log, still a 204 to the browser. Frontend-only instrumentation records **nothing** and
looks like it worked.
**2. Log from the frontend:**
@@ -75,6 +76,10 @@ under-discloses what it sends. This has already drifted once.
SELECT feature, kind, key, entity_id, day, value FROM feature_usage ORDER BY updated_at DESC LIMIT 10;
```
Collection sits behind the `private` feature, so a public build records nothing from either the
HTTP route or the Rust helper. Run the backend with `--features enterprise,private` or this query
stays empty however correct the instrumentation is.
## Privacy rules
Only aggregated counts ever leave the instance, and only when telemetry is enabled and minimal
@@ -85,8 +90,21 @@ cannot be collected — drop it.
Counters aggregate over the last 30 days; rows are pruned after 60.
## Backend-only features
## Logging from the backend
Ingestion is frontend-only: `log_feature_usage` is an HTTP route the browser posts to, and there
is no Rust-side helper. A feature with no UI cannot be instrumented today without adding one.
Scope the default to user-facing work, and say so rather than implying backend coverage exists.
A feature with no UI is instrumented the same way, from Rust:
```rust
windmill_common::feature_usage::log_feature_usage("trigger", "fired", kind.as_str());
```
Same registry, same key rules, and the same silent drop when the pair is unregistered. `feature`
and `kind` are `&'static str` so a call site cannot pass a computed pair. The call increments an
in-memory counter and returns; the monitor loop flushes the accumulator, so it is cheap enough for
hot paths — but only cheap per call, not free: a key with unbounded cardinality would grow the map
until it hits the per-action cap and starts dropping new keys.
There is no `entity_id` and no explicit `value` on this path: it counts occurrences.
`feature_usage_ee` holds the registry and the writer; the public build gets the inert
`feature_usage_oss`, since a CE instance never sends a stats payload.
@@ -1,4 +1,5 @@
<script lang="ts">
import { logFeatureUsage } from '$lib/utils/featureUsage'
import FlowStatusViewerInner from './FlowStatusViewerInner.svelte'
import {
@@ -1517,7 +1518,11 @@
{/if}
{#if render}
{#if innerModules && innerModules.length > 0 && !isListJob}
<Tabs class="mx-auto pt-2 {wideResults ? '' : 'max-w-7xl'}" bind:selected>
<Tabs
class="mx-auto pt-2 {wideResults ? '' : 'max-w-7xl'}"
bind:selected
on:selected={(e) => logFeatureUsage('flow_run', 'tab', { key: e.detail })}
>
<Tab value="graph" label="Graph" />
<Tab
value="logs"
@@ -1063,8 +1063,12 @@
<li>job usage (language, total duration, count)</li>
<li>git sync repo count (sync vs promotion mode)</li>
<li
>feature usage telemetry: aggregated AI chat and AI session usage counts, including AI
provider and model identifiers (last 30 days)</li
>feature usage (counts of which product features are used, including AI provider and
model identifiers and the names of public hub scripts used, last 30 days)</li
>
<li
>feature adoption (counts of which flow, script, trigger and worker features your
deployed items use)</li
>
<li
>resource counts (workspaces, scripts per language, flows, workflows as code, low-code
@@ -1110,8 +1114,12 @@
<li>user usage (author count, operator count)</li>
<li>development instance status</li>
<li
>feature usage telemetry: aggregated AI chat and AI session usage counts, including AI
provider and model identifiers (last 30 days)</li
>feature usage (counts of which product features are used, including AI provider and
model identifiers and the names of public hub scripts used, last 30 days)</li
>
<li
>feature adoption (counts of which flow, script, trigger and worker features your
deployed items use)</li
>
<li
>resource counts (workspaces, scripts per language, flows, workflows as code, low-code
@@ -48,7 +48,7 @@ import {
} from '$lib/gen'
import uFuzzy from '@leeoniya/ufuzzy'
import { emptyString } from '$lib/utils'
import { logFeatureUsage } from '$lib/utils/featureUsage'
import { logFeatureUsage, logHubScriptPick } from '$lib/utils/featureUsage'
import { forLater } from '$lib/forLater'
import { scriptLangToEditorLang } from '$lib/scripts'
import { getCurrentModel } from '$lib/aiStore'
@@ -1333,6 +1333,9 @@ export const createSearchHubScriptsTool = (withContent: boolean = false) => ({
if (!withContent) {
return { path, summary: s.summary }
}
// The content fetch, not the listing above: these are the few candidates
// the AI pulled to choose between, which is the closest signal we have.
logHubScriptPick(s, 'ai')
try {
// get_full, not the raw content endpoint: callers are told to match the
// script's language, which raw content does not carry.
@@ -72,6 +72,8 @@
type HubCompletion = {
path: string
summary: string
/** The hub's own wording, before `summary` is rewritten as the display label. */
hubSummary: string
id: number
version_id: number
ask_id: number
@@ -9,6 +9,7 @@
import { Loader2 } from 'lucide-svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import { disableHubStore } from '$lib/stores'
import { logHubScriptPick } from '$lib/utils/featureUsage'
interface Props {
kind?: HubScriptKind & string
@@ -35,6 +36,8 @@
let items: {
path: string
summary: string
/** The hub's own wording, before `summary` is rewritten as the display label. */
hubSummary: string
id: number
version_id: number
ask_id: number
@@ -106,6 +109,9 @@
}) => ({
...x,
path: `hub/${x.version_id}/${x.app}/${x.summary.toLowerCase().replaceAll(/\s+/g, '_')}`,
// `summary` below becomes the display label; keep the hub's own wording,
// which is what telemetry keys off.
hubSummary: x.summary,
summary: `${x.summary} (${x.app})`
})
)
@@ -119,6 +125,10 @@
async function handlePick(item: (typeof items)[number]) {
if (item.path.startsWith('hub/')) {
logHubScriptPick(
{ version_id: item.version_id, app: item.app, summary: item.hubSummary },
'picker'
)
try {
await ScriptService.pickHubScriptByPath({ path: item.path })
} catch (error) {
@@ -49,6 +49,7 @@
import Button from '$lib/components/common/button/Button.svelte'
import { Alert } from '$lib/components/common'
import type { FlowBuilderWhitelabelCustomUi } from '$lib/components/custom_ui'
import { logHubScriptPick } from '$lib/utils/featureUsage'
let customUi: undefined | FlowBuilderWhitelabelCustomUi = getContext('customUi')
@@ -65,6 +66,8 @@
items?: {
path: string
summary: string
/** The hub's own wording, before `summary` is rewritten as the display label. */
hubSummary: string
id: number
version_id: number
ask_id: number
@@ -140,6 +143,9 @@
}) => ({
...x,
path: `hub/${x.version_id}/${x.app}/${x.summary.toLowerCase().replaceAll(/\s+/g, '_')}`,
// `summary` below becomes the display label; keep the hub's own wording,
// which is what telemetry keys off.
hubSummary: x.summary,
summary: `${x.summary} (${x.app})`
})
)
@@ -150,6 +156,10 @@
async function handlePickScript(item: (typeof items)[number]) {
if (item.path.startsWith('hub/')) {
logHubScriptPick(
{ version_id: item.version_id, app: item.app, summary: item.hubSummary },
'picker'
)
try {
await ScriptService.pickHubScriptByPath({ path: item.path })
} catch (error) {
@@ -31,6 +31,7 @@
import { base } from '$lib/base'
import { fade } from 'svelte/transition'
import type { FlowEditorContext, OutputViewerJob } from '../types'
import { logFeatureUsage } from '$lib/utils/featureUsage'
interface Props {
prefix?: string
@@ -170,6 +171,12 @@
}
function togglePin() {
// The adoption read counts pins still enabled on a deployed flow; this counts
// the act, including the pins undone before deploying. Keyed per direction,
// so unpinning shows how often a pin was temporary.
logFeatureUsage('flow_step', 'pinned', {
key: mock?.enabled && !preview ? 'off' : 'on'
})
if (mock?.enabled && !preview) {
// Unpin
onUpdateMock?.({
+30 -1
View File
@@ -3,7 +3,11 @@ import { describe, expect, it, vi } from 'vitest'
vi.mock('$lib/gen', () => ({ OpenAPI: { BASE: '/api' } }))
vi.mock('$lib/stores', () => ({ workspaceStore: { subscribe: () => () => {} } }))
import { createFeatureUsageBuffer, type FeatureUsageEventPayload } from './featureUsage'
import {
createFeatureUsageBuffer,
hubScriptUsageKey,
type FeatureUsageEventPayload
} from './featureUsage'
describe('createFeatureUsageBuffer', () => {
it('sums repeated events per (feature, kind, key, entity) and flushes one batch', async () => {
@@ -78,3 +82,28 @@ describe('createFeatureUsageBuffer', () => {
expect(sent).toHaveLength(60)
})
})
describe('hubScriptUsageKey', () => {
it('names a public hub script by app and summary', () => {
expect(
hubScriptUsageKey({ version_id: 9084, app: 'slack', summary: 'Send message to channel' })
).toBe('slack/send_message_to_channel')
})
it('never reports a private hub script name', () => {
// Above PRIVATE_HUB_MIN_VERSION the app and summary are the customer's own.
expect(
hubScriptUsageKey({
version_id: 10_000_001,
app: 'acme_internal',
summary: 'Payroll export'
})
).toBe('private')
})
it('slugifies punctuation rather than filing the script under private', () => {
expect(
hubScriptUsageKey({ version_id: 12, app: 'acme', summary: "List a user's items, sorted" })
).toBe('acme/list_a_user_s_items_sorted')
})
})
+52
View File
@@ -1,6 +1,7 @@
import { get } from 'svelte/store'
import { OpenAPI } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { PRIVATE_HUB_MIN_VERSION } from '$lib/hub'
// Anonymous product-usage counters (e.g. AI session activity), batched into the
// backend `feature_usage` accumulator. Only aggregated counts ever leave the
@@ -135,3 +136,54 @@ if (typeof document !== 'undefined') {
export function logFeatureUsage(feature: string, kind: string, opts: FeatureUsageOpts = {}): void {
buffer.log(feature, kind, opts)
}
/**
* Record a hub script the user or the AI settled on.
*
* Takes the structured fields the hub API returned rather than a
* `hub/<version>/<app>/<slug>` path. A path stored in a flow is workspace-authored
* text that nothing validates against the hub, so its segments could hold any name
* a user wrote; these fields came from the hub itself and are safe to report.
*
* `considered` rather than `picked` for the AI: the AI pulls a handful of
* candidates' content before choosing between them, and nothing downstream records
* which one it went on to use.
*
* A script from a private hub is still the customer's own content, so at or above
* `PRIVATE_HUB_MIN_VERSION` only the fact that one was used is recorded.
*/
export function logHubScriptPick(
script: { version_id: number; app: string; summary: string },
origin: 'picker' | 'ai'
): void {
logFeatureUsage('hub_script', origin === 'ai' ? 'considered_ai' : 'picked', {
key: hubScriptUsageKey(script)
})
}
const PRIVATE_HUB_KEY = 'private'
/** Lowercase, with every run of other characters collapsed to a single `_`. */
function slugify(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
}
export function hubScriptUsageKey(script: {
version_id: number
app: string
summary: string
}): string {
if (!Number.isInteger(script.version_id) || script.version_id >= PRIVATE_HUB_MIN_VERSION) {
return PRIVATE_HUB_KEY
}
// Slugified rather than shape-checked: hub summaries carry commas, apostrophes
// and parentheses, and rejecting those would file real public scripts under
// `private` and undercount exactly the integrations this is meant to surface.
const app = slugify(script.app)
const summary = slugify(script.summary)
if (!app) return PRIVATE_HUB_KEY
return (summary ? `${app}/${summary}` : app).slice(0, 100)
}