From 53eb94659bd27e75ed4acf4ce414046ac8df4cc8 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Fri, 14 Aug 2026 18:50:38 +0200 Subject: [PATCH] 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) * docs: describe telemetry as product feature usage rather than AI usage Co-Authored-By: Claude Opus 5 (1M context) * refactor(telemetry): trim disclosure copy and drop unused pick origin Co-Authored-By: Claude Opus 5 (1M context) * fix(telemetry): count trigger fires per run and key hub picks from hub data Co-Authored-By: Claude Opus 5 (1M context) * fix(telemetry): slugify hub keys and order both writers' upserts Co-Authored-By: Claude Opus 5 (1M context) * fix(telemetry): key native trigger adoption by service so it matches fires Co-Authored-By: Claude Opus 5 (1M context) * chore: update ee-repo-ref for native trigger adoption fix Co-Authored-By: Claude Opus 5 (1M context) * 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) Co-authored-by: Ruben Fiszel Co-authored-by: windmill-internal-app[bot] --- ...3500a39efa20e4915ffa56fd40537597db36e.json | 26 +++ ...e265b861b8b4d650435533e90eeb1d5ee3a68.json | 22 +++ ...3401af7b78d0fb6cbaa10ccb90769320c8c7a.json | 26 +++ ...382fc69a223dfbb91ab40df895ab60d0f6228.json | 26 +++ ...e02686b358b616c318e4bfca084d96b38f1c4.json | 20 ++ ...d3a87e8899b968827251745f998e4dc25863a.json | 17 ++ ...ddce33008b61bbdab4006194ca7ef5b754802.json | 26 +++ ...0d2dc54f7169af1c940b84345c477865ae82c.json | 26 +++ ...9d7dc2071580be6d604979c95b3374cd4ad77.json | 173 ++++++++++++++++++ ...9107558b0003f495ba3f5fba63b77616d12c8.json | 26 +++ backend/ee-repo-ref.txt | 2 +- backend/src/monitor.rs | 14 ++ .../windmill-api-workspaces/src/workspaces.rs | 63 +++---- .../windmill-common/src/feature_usage_oss.rs | 26 +++ backend/windmill-common/src/lib.rs | 7 + backend/windmill-queue/src/jobs.rs | 14 ++ docs/feature-telemetry.md | 38 +++- .../components/FlowStatusViewerInner.svelte | 7 +- .../lib/components/InstanceSettings.svelte | 16 +- .../src/lib/components/copilot/chat/shared.ts | 5 +- .../flows/content/FlowInputsQuick.svelte | 2 + .../flows/pickers/PickHubScript.svelte | 10 + .../flows/pickers/PickHubScriptQuick.svelte | 10 + .../flows/propPicker/OutputPickerInner.svelte | 7 + frontend/src/lib/utils/featureUsage.test.ts | 31 +++- frontend/src/lib/utils/featureUsage.ts | 52 ++++++ 26 files changed, 632 insertions(+), 60 deletions(-) create mode 100644 backend/.sqlx/query-1d346a14ad5586af347b8e7ac413500a39efa20e4915ffa56fd40537597db36e.json create mode 100644 backend/.sqlx/query-255d37bb63595ebfcc61582d0b5e265b861b8b4d650435533e90eeb1d5ee3a68.json create mode 100644 backend/.sqlx/query-3b0eb0571f287eb64c84cf2c8303401af7b78d0fb6cbaa10ccb90769320c8c7a.json create mode 100644 backend/.sqlx/query-5344f222417c28efd4f724cbd83382fc69a223dfbb91ab40df895ab60d0f6228.json create mode 100644 backend/.sqlx/query-6c6b4bd4bd19878fce25d3a8a5ee02686b358b616c318e4bfca084d96b38f1c4.json create mode 100644 backend/.sqlx/query-772dc28e57666282d8993268843d3a87e8899b968827251745f998e4dc25863a.json create mode 100644 backend/.sqlx/query-7b3eadb62ddd07e5e12eb8b5150ddce33008b61bbdab4006194ca7ef5b754802.json create mode 100644 backend/.sqlx/query-a2f047f9ca4b8a47c985fa092ba0d2dc54f7169af1c940b84345c477865ae82c.json create mode 100644 backend/.sqlx/query-e24252d48a1fcca73f20d62f37c9d7dc2071580be6d604979c95b3374cd4ad77.json create mode 100644 backend/.sqlx/query-e63e275a158040659c41ec8d1ef9107558b0003f495ba3f5fba63b77616d12c8.json create mode 100644 backend/windmill-common/src/feature_usage_oss.rs diff --git a/backend/.sqlx/query-1d346a14ad5586af347b8e7ac413500a39efa20e4915ffa56fd40537597db36e.json b/backend/.sqlx/query-1d346a14ad5586af347b8e7ac413500a39efa20e4915ffa56fd40537597db36e.json new file mode 100644 index 0000000000..e8ffcfb95b --- /dev/null +++ b/backend/.sqlx/query-1d346a14ad5586af347b8e7ac413500a39efa20e4915ffa56fd40537597db36e.json @@ -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" +} diff --git a/backend/.sqlx/query-255d37bb63595ebfcc61582d0b5e265b861b8b4d650435533e90eeb1d5ee3a68.json b/backend/.sqlx/query-255d37bb63595ebfcc61582d0b5e265b861b8b4d650435533e90eeb1d5ee3a68.json new file mode 100644 index 0000000000..64b6d70542 --- /dev/null +++ b/backend/.sqlx/query-255d37bb63595ebfcc61582d0b5e265b861b8b4d650435533e90eeb1d5ee3a68.json @@ -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" +} diff --git a/backend/.sqlx/query-3b0eb0571f287eb64c84cf2c8303401af7b78d0fb6cbaa10ccb90769320c8c7a.json b/backend/.sqlx/query-3b0eb0571f287eb64c84cf2c8303401af7b78d0fb6cbaa10ccb90769320c8c7a.json new file mode 100644 index 0000000000..ca79568bb0 --- /dev/null +++ b/backend/.sqlx/query-3b0eb0571f287eb64c84cf2c8303401af7b78d0fb6cbaa10ccb90769320c8c7a.json @@ -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" +} diff --git a/backend/.sqlx/query-5344f222417c28efd4f724cbd83382fc69a223dfbb91ab40df895ab60d0f6228.json b/backend/.sqlx/query-5344f222417c28efd4f724cbd83382fc69a223dfbb91ab40df895ab60d0f6228.json new file mode 100644 index 0000000000..08c08bbf75 --- /dev/null +++ b/backend/.sqlx/query-5344f222417c28efd4f724cbd83382fc69a223dfbb91ab40df895ab60d0f6228.json @@ -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" +} diff --git a/backend/.sqlx/query-6c6b4bd4bd19878fce25d3a8a5ee02686b358b616c318e4bfca084d96b38f1c4.json b/backend/.sqlx/query-6c6b4bd4bd19878fce25d3a8a5ee02686b358b616c318e4bfca084d96b38f1c4.json new file mode 100644 index 0000000000..6576d06ffa --- /dev/null +++ b/backend/.sqlx/query-6c6b4bd4bd19878fce25d3a8a5ee02686b358b616c318e4bfca084d96b38f1c4.json @@ -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" +} diff --git a/backend/.sqlx/query-772dc28e57666282d8993268843d3a87e8899b968827251745f998e4dc25863a.json b/backend/.sqlx/query-772dc28e57666282d8993268843d3a87e8899b968827251745f998e4dc25863a.json new file mode 100644 index 0000000000..d5659d725b --- /dev/null +++ b/backend/.sqlx/query-772dc28e57666282d8993268843d3a87e8899b968827251745f998e4dc25863a.json @@ -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" +} diff --git a/backend/.sqlx/query-7b3eadb62ddd07e5e12eb8b5150ddce33008b61bbdab4006194ca7ef5b754802.json b/backend/.sqlx/query-7b3eadb62ddd07e5e12eb8b5150ddce33008b61bbdab4006194ca7ef5b754802.json new file mode 100644 index 0000000000..6ca02417f3 --- /dev/null +++ b/backend/.sqlx/query-7b3eadb62ddd07e5e12eb8b5150ddce33008b61bbdab4006194ca7ef5b754802.json @@ -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" +} diff --git a/backend/.sqlx/query-a2f047f9ca4b8a47c985fa092ba0d2dc54f7169af1c940b84345c477865ae82c.json b/backend/.sqlx/query-a2f047f9ca4b8a47c985fa092ba0d2dc54f7169af1c940b84345c477865ae82c.json new file mode 100644 index 0000000000..800d6c3305 --- /dev/null +++ b/backend/.sqlx/query-a2f047f9ca4b8a47c985fa092ba0d2dc54f7169af1c940b84345c477865ae82c.json @@ -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" +} diff --git a/backend/.sqlx/query-e24252d48a1fcca73f20d62f37c9d7dc2071580be6d604979c95b3374cd4ad77.json b/backend/.sqlx/query-e24252d48a1fcca73f20d62f37c9d7dc2071580be6d604979c95b3374cd4ad77.json new file mode 100644 index 0000000000..d52ba8fb85 --- /dev/null +++ b/backend/.sqlx/query-e24252d48a1fcca73f20d62f37c9d7dc2071580be6d604979c95b3374cd4ad77.json @@ -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" +} diff --git a/backend/.sqlx/query-e63e275a158040659c41ec8d1ef9107558b0003f495ba3f5fba63b77616d12c8.json b/backend/.sqlx/query-e63e275a158040659c41ec8d1ef9107558b0003f495ba3f5fba63b77616d12c8.json new file mode 100644 index 0000000000..d5a9d425da --- /dev/null +++ b/backend/.sqlx/query-e63e275a158040659c41ec8d1ef9107558b0003f495ba3f5fba63b77616d12c8.json @@ -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" +} diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 0ef4061583..3ed75a93fc 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -af02d6bce55512b65c56adcbf69a8e15cd124d23 +f079db9e7962a413b349c4ff8036080894f30771 diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index 8f2b013021..ab40597c9d 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -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, diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index eff742006e..9bc4890fe6 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -11489,41 +11489,6 @@ struct LogFeatureUsagePayload { events: Vec, } -// 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, Json(payload): Json, @@ -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); diff --git a/backend/windmill-common/src/feature_usage_oss.rs b/backend/windmill-common/src/feature_usage_oss.rs new file mode 100644 index 0000000000..3219e9d0ac --- /dev/null +++ b/backend/windmill-common/src/feature_usage_oss.rs @@ -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) -> Result<(), sqlx::Error> { + Ok(()) +} diff --git a/backend/windmill-common/src/lib.rs b/backend/windmill-common/src/lib.rs index 6194ed2c88..b72d0ae1d9 100644 --- a/backend/windmill-common/src/lib.rs +++ b/backend/windmill-common/src/lib.rs @@ -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; diff --git a/backend/windmill-queue/src/jobs.rs b/backend/windmill-queue/src/jobs.rs index ec84faa2d0..b286082b1c 100644 --- a/backend/windmill-queue/src/jobs.rs +++ b/backend/windmill-queue/src/jobs.rs @@ -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?; diff --git a/docs/feature-telemetry.md b/docs/feature-telemetry.md index fed78cc064..947329fc45 100644 --- a/docs/feature-telemetry.md +++ b/docs/feature-telemetry.md @@ -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. diff --git a/frontend/src/lib/components/FlowStatusViewerInner.svelte b/frontend/src/lib/components/FlowStatusViewerInner.svelte index fb99326e83..96b6eed293 100644 --- a/frontend/src/lib/components/FlowStatusViewerInner.svelte +++ b/frontend/src/lib/components/FlowStatusViewerInner.svelte @@ -1,4 +1,5 @@