mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 08:01:26 +00:00
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:
+26
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+26
@@ -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"
|
||||
}
|
||||
+26
@@ -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"
|
||||
}
|
||||
+20
@@ -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"
|
||||
}
|
||||
+17
@@ -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"
|
||||
}
|
||||
+26
@@ -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"
|
||||
}
|
||||
+26
@@ -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"
|
||||
}
|
||||
+173
@@ -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"
|
||||
}
|
||||
+26
@@ -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 @@
|
||||
af02d6bce55512b65c56adcbf69a8e15cd124d23
|
||||
f079db9e7962a413b349c4ff8036080894f30771
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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?.({
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user