feat: instrument sandbox isolation, data tables and in-flow script edits (#10981)

* feat: instrument sandbox isolation, data tables and in-flow script edits

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GLnp4v49BozkDd3KeWn5Q3

* fix: address review findings on the new telemetry counters

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GLnp4v49BozkDd3KeWn5Q3

* refactor: inline single-site telemetry helpers and trim what is collected

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GLnp4v49BozkDd3KeWn5Q3

* docs: tighten the telemetry disclosure copy

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GLnp4v49BozkDd3KeWn5Q3

* chore: update ee-repo-ref to 5921c03c8e28642efd1c390f590c0dab9834fa99

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

Previous ee-repo-ref: 548b5e0421a04a2d9a76cce6efc6c91b1d8560ee

New ee-repo-ref: 5921c03c8e28642efd1c390f590c0dab9834fa99

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
This commit is contained in:
hugocasa
2026-09-05 10:38:20 +00:00
committed by GitHub
co-authored by Claude Opus 5 windmill-internal-app[bot] Ruben Fiszel
parent 8aab5034a6
commit 130a2f7408
15 changed files with 292 additions and 14 deletions
@@ -0,0 +1,26 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n COALESCE(dt.value->'database'->>'resource_type', 'unknown') AS \"kind!\",\n COUNT(*)::BIGINT AS \"count!\"\n FROM workspace_settings ws,\n LATERAL jsonb_each(ws.datatable->'datatables') dt\n WHERE jsonb_typeof(ws.datatable->'datatables') = 'object'\n GROUP BY 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "kind!",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "count!",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null,
null
]
},
"hash": "0411a67eb9d88244fa654eda51123e16b5931c08c1073b09ab01dad205f161ed"
}
@@ -0,0 +1,32 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n COUNT(*) FILTER (WHERE dt.value->>'migrations_enabled' = 'true')::BIGINT AS \"enabled!\",\n COUNT(*) FILTER (WHERE dt.value->>'migrations_enabled' = 'false')::BIGINT AS \"disabled!\",\n COUNT(*) FILTER (WHERE dt.value->>'migrations_enabled' IS NULL)::BIGINT AS \"unset!\"\n FROM workspace_settings ws,\n LATERAL jsonb_each(ws.datatable->'datatables') dt\n WHERE jsonb_typeof(ws.datatable->'datatables') = 'object'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "enabled!",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "disabled!",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "unset!",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null,
null,
null
]
},
"hash": "209c96d522f9683b39f053707568f9943111e0bac7d4fb478300f0cd8b799f23"
}
@@ -0,0 +1,26 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n COUNT(*)::BIGINT AS \"total!\",\n COUNT(DISTINCT (workspace_id, datatable))::BIGINT AS \"datatables!\"\n FROM datatable_migrations",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "total!",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "datatables!",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null,
null
]
},
"hash": "2105d37be923a445933c899bb31523709f837b7e1969b7364f70cc2a60cdd520"
}
@@ -0,0 +1,26 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n COUNT(*) FILTER (WHERE av.raw_app = false)::BIGINT AS \"low_code!\",\n COUNT(*) FILTER (WHERE av.raw_app = true)::BIGINT AS \"raw!\"\n FROM app a\n JOIN app_version av ON av.id = a.versions[array_upper(a.versions, 1)]\n WHERE a.policy->>'sandbox' = 'true'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "low_code!",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "raw!",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null,
null
]
},
"hash": "9b727f03e74ea4a35146c9a339cda82104a9ee39bfbe5d932340a3c2c209c5d0"
}
@@ -0,0 +1,26 @@
{
"db_name": "PostgreSQL",
"query": "SELECT usage_kind::text AS \"kind!\", COUNT(*)::BIGINT AS \"count!\"\n FROM asset WHERE kind = 'datatable' AND usage_kind <> 'job'\n GROUP BY 1\n UNION ALL\n SELECT 'job_recent'::text, COUNT(DISTINCT (workspace_id, path))::BIGINT\n FROM asset\n WHERE kind = 'datatable' AND usage_kind = 'job'\n AND created_at > now() - interval '30 days'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "kind!",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "count!",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null,
null
]
},
"hash": "d3a6a27ece3b5d5071dd8074b8db6f369eceb079b31ca118cae23c3d15314590"
}
+1 -1
View File
@@ -1 +1 @@
c2270eb5fe2d9f0968253e6b460c33186363f4e7
5921c03c8e28642efd1c390f590c0dab9834fa99
@@ -416,6 +416,16 @@ async fn run_datatable_migrations(
let applied_versions = read_applied_versions_on_client(&client, &datatable_name).await?;
// How the user scoped the run, for the counter emitted on the first migration
// that lands below.
let scope = if query.only.is_some() {
"only"
} else if query.up_to.is_some() {
"up_to"
} else {
"all"
};
let mut applied = Vec::new();
for m in migrations {
if let Some(only) = query.only {
@@ -453,6 +463,14 @@ async fn run_datatable_migrations(
))
})?;
applied.push(AppliedMigration { version: m.timestamp, name: m.name });
// One event per run that moved the data table forward, emitted on the
// first migration that lands rather than after the loop: a later one
// failing returns early, and that run still advanced the data table. A
// run with nothing pending stays uncounted — it is the common outcome of
// opening the list and would drown out the runs that did something.
if applied.len() == 1 {
windmill_common::feature_usage::log_feature_usage("datatable", "migration_run", scope);
}
}
Ok(Json(RunDatatableMigrationsResult { applied }))
@@ -594,6 +612,12 @@ async fn rollback_datatable_migrations(
))
})?;
windmill_common::feature_usage::log_feature_usage(
"datatable",
"migration_rollback",
if query.only.is_some() { "only" } else { "last" },
);
Ok(Json(RollbackDatatableMigrationsResult {
rolled_back: vec![RolledBackMigration { version, name: definition.name }],
}))
@@ -824,6 +848,8 @@ async fn enable_datatable_migrations(
)
.await?;
windmill_common::feature_usage::log_feature_usage("datatable", "migrations_toggled", "on");
Ok(format!(
"Enabled migrations for data table {datatable_name}"
))
@@ -892,6 +918,8 @@ async fn disable_datatable_migrations(
.await?;
}
windmill_common::feature_usage::log_feature_usage("datatable", "migrations_toggled", "off");
Ok(format!(
"Disabled migrations for data table {datatable_name} and deleted its migrations"
))
@@ -1134,6 +1162,8 @@ async fn create_datatable_migration(
)
.await?;
windmill_common::feature_usage::log_feature_usage("datatable", "migration_created", "manual");
Ok(Json(DatatableMigration {
datatable: datatable_name,
timestamp,
@@ -1371,6 +1401,20 @@ async fn upsert_datatable_migration(
)
.await?;
// An unchanged re-push is not counted: `wmill sync push` sends every migration
// on every sync, so counting those would swamp the definitions people write.
if !unchanged {
windmill_common::feature_usage::log_feature_usage(
"datatable",
"migration_created",
if existing.is_none() {
"synced"
} else {
"edited"
},
);
}
Ok(format!(
"Upserted migration {} in {}",
payload.timestamp, datatable_name
@@ -1477,6 +1521,12 @@ async fn generate_initial_datatable_migration(
)
.await?;
windmill_common::feature_usage::log_feature_usage(
"datatable",
"migration_created",
"initial_snapshot",
);
Ok(Json(DatatableMigration {
datatable: datatable_name,
timestamp,
@@ -3524,6 +3524,9 @@ async fn edit_datatable_config(
// Migrations opt-in is owned by the enable/disable endpoints, not this config
// form: preserve each existing data table's flag, and default brand-new data
// tables to enabled.
// Counted here rather than after the write because this is where a rename is
// still distinguishable from a creation; emitted once the commit lands.
let mut created_substrates: Vec<&'static str> = Vec::new();
for (name, dt) in new_config.settings.datatables.iter_mut() {
let lookup = rename_src
.get(name.as_str())
@@ -3531,7 +3534,15 @@ async fn edit_datatable_config(
.unwrap_or(name.as_str());
dt.migrations_enabled = match old_datatables.get(lookup) {
Some(old) => old.migrations_enabled,
None => Some(true),
None => {
// Keyed by how the substrate is serialized into `workspace_settings`,
// so these line up with the `datatable_configured` adoption counts.
created_substrates.push(match dt.database.resource_type {
DataTableCatalogResourceType::Instance => "instance",
DataTableCatalogResourceType::Postgresql => "postgresql",
});
Some(true)
}
};
}
@@ -3589,6 +3600,10 @@ async fn edit_datatable_config(
tx.commit().await?;
for substrate in created_substrates {
windmill_common::feature_usage::log_feature_usage("datatable", "created", substrate);
}
crate::datatable_migrations::record_datatable_cascade_deployments(
&authed,
&db,
+4 -3
View File
@@ -4,9 +4,10 @@
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 32 registered actions across fifteen features (`ai_session`, `ai_chat`,
`ai_fix`, `ai_agent`, `ai_agent_eval`, `flow_editor`, `flow_run`, `flow_step`, `run_form`,
`debugger`, `trigger`, `command_script`, `hub_script`, `usage_meter`, `sso_groups_claim`). Nearly all of the
It currently carries 42 registered actions across seventeen features (`ai_session`, `ai_chat`,
`ai_fix`, `ai_agent`, `ai_agent_eval`, `app_sandbox`, `datatable`, `flow_editor`, `flow_run`,
`flow_step`, `run_form`, `debugger`, `trigger`, `command_script`, `hub_script`, `usage_meter`,
`sso_groups_claim`). Nearly all of the
product is uninstrumented, so new user-facing work is the opportunity to change that.
## When to instrument
@@ -4,6 +4,7 @@
import NewDataTableMigrationModal from './workspaceSettings/NewDataTableMigrationModal.svelte'
import DataTableMigrationsButton from './workspaceSettings/DataTableMigrationsButton.svelte'
import { splitSqlStatements, isDdlStatement } from './sqlDdl'
import { logDdlGuardChoice } from './workspaceSettings/datatableTelemetry'
import { CornerDownLeft } from 'lucide-svelte'
let { workspace, datatable }: { workspace: string; datatable: string } = $props()
@@ -97,9 +98,11 @@
for (;;) {
const choice = await promptDdl(statement)
if (choice === 'cancel') {
logDdlGuardChoice('cancelled')
return { proceed: false, code, ranMigration: migrationRan }
}
if (choice === 'run') {
logDdlGuardChoice('run_anyway')
kept.push(statement)
break
}
@@ -107,6 +110,7 @@
// created; if the modal was cancelled, loop back to the prompt.
const created = await openMigrationModal(statement)
if (created) {
logDdlGuardChoice('migrated')
break
}
}
@@ -1076,12 +1076,15 @@
model identifiers, the names of public hub scripts used, the languages debug sessions
are started for, whether AI chat skills are turned on or off and how often one is
loaded, whether SSO logins evaluate an IdP groups claim (SAML or OIDC) and change a
membership, and the plan tier and quota shown when the execution meter is opened, last
30 days)</li
membership, the plan tier and quota shown when the execution meter is opened, whether
app sandbox isolation is turned on, whether a step's workspace script is edited from
the flow editor, and how data tables and their migrations are set up and used, last 30
days)</li
>
<li
>feature adoption (counts of which flow, script, trigger and worker features your
deployed items use)</li
>feature adoption (counts of which flow, script, trigger, worker and data table
features your deployed items use, including how many apps run sandboxed, how many data
tables exist per database kind, how many use migrations, and what references them)</li
>
<li
>resource counts (workspaces, scripts per language, flows, workflows as code, low-code
@@ -1135,12 +1138,15 @@
model identifiers, the names of public hub scripts used, the languages debug sessions
are started for, whether AI chat skills are turned on or off and how often one is
loaded, whether SSO logins evaluate an IdP groups claim (SAML or OIDC) and change a
membership, and the plan tier and quota shown when the execution meter is opened, last
30 days)</li
membership, the plan tier and quota shown when the execution meter is opened, whether
app sandbox isolation is turned on, whether a step's workspace script is edited from
the flow editor, and how data tables and their migrations are set up and used, last 30
days)</li
>
<li
>feature adoption (counts of which flow, script, trigger and worker features your
deployed items use)</li
>feature adoption (counts of which flow, script, trigger, worker and data table
features your deployed items use, including how many apps run sandboxed, how many data
tables exist per database kind, how many use migrations, and what references them)</li
>
<li
>resource counts (workspaces, scripts per language, flows, workflows as code, low-code
@@ -25,6 +25,7 @@
} from '$lib/components/OnBehalfOfSelector.svelte'
import { canUserBypassRuleKind, protectionRulesState } from '$lib/workspaceProtectionRules.svelte'
import { FRONTEND_SDK_SCOPES } from '$lib/components/raw_apps/sdkScopes'
import { logFeatureUsage } from '$lib/utils/featureUsage'
const WM_DEPLOYERS_GROUP = 'wm_deployers'
@@ -352,6 +353,12 @@
checked={policy.sandbox == true}
on:change={(e) => {
policy.sandbox = e.detail || undefined
// Counted where the toggle is flipped rather than where the policy is
// persisted: a not-yet-deployed app only mutates it locally, and skipping
// those would read as unused in the case where it is picked up front.
logFeatureUsage('app_sandbox', 'toggled', {
key: `${rawApp ? 'raw' : 'low_code'}:${e.detail ? 'on' : 'off'}`
})
// Frontend API access exists only for a sandboxed app, so turning
// isolation off drops the declared scopes with it rather than leaving
// them set but inert.
@@ -25,6 +25,7 @@
import { sendUserToast, type Item } from '$lib/utils'
import { twMerge } from 'tailwind-merge'
import { getToolNameError } from '$lib/components/flows/agentToolUtils'
import { logFeatureUsage } from '$lib/utils/featureUsage'
import autosize from '$lib/autosize'
interface Props {
@@ -104,7 +105,16 @@
if (flowModuleValue?.type !== 'script') return
const hash =
flowModuleValue.hash ?? (await getLatestHashForScript(flowModuleValue.path, opWs))
$scriptEditorDrawer?.openDrawer(hash, () => {
// Same reason the settings item below is gated: the local-dev editors publish
// the context store but never render the drawer, so an unmounted one makes
// this a no-op — and a no-op must not be counted as an editor open.
const drawer = $scriptEditorDrawer
if (!drawer) return
logFeatureUsage('flow_step', 'script_edit', { key: 'opened' })
// The drawer only runs this callback once a new version is deployed, so it is
// what separates opening the editor from actually editing the script here.
drawer.openDrawer(hash, () => {
logFeatureUsage('flow_step', 'script_edit', { key: 'saved' })
dispatch('reload')
sendUserToast('Script has been updated')
})
@@ -34,6 +34,7 @@
import DataTableConnectionReport from './DataTableConnectionReport.svelte'
import { useSupabaseOauth } from './supabaseOauth.svelte'
import { probeDatatableConnection } from './datatableProbe'
import { logDatatableWizard } from './datatableTelemetry'
import {
anythingClaimed,
claimOf,
@@ -526,11 +527,13 @@
await loadTargetUser()
reset(parked ?? resume)
opened = true
logDatatableWizard({ step: 'opened' })
}
function selectProvider(key: Provider) {
if (key === wiz.provider) return
wiz.provider = key
logDatatableWizard({ step: 'picked', provider: key })
invalidate()
if (key === 'instance') wiz.instance.dbName ??= defaultInstanceDbName()
}
@@ -833,6 +836,11 @@
createdProjects
}
}
// The setup's own verdict, so a data table that exists counts as done even when the
// caller's appended `onFinishAlso` step failed after it.
if (wiz.provider) {
logDatatableWizard({ step: result?.ok ? 'done' : 'failed', provider: wiz.provider })
}
onDone()
}
}
@@ -0,0 +1,41 @@
import { logFeatureUsage } from '$lib/utils/featureUsage'
// Anonymous counters for the data table surfaces the backend cannot see: which substrate the
// add-wizard is pointed at and how far a run gets, and what the DDL guard talks people into.
// Same rules as every other `logFeatureUsage` caller: aggregated counts only, and the keys
// below are the whole vocabulary — no data table name, connection string, resource path or SQL
// ever reaches here.
/** The substrate a wizard run is pointed at. Mirrors the wizard's own `Provider`. */
export type DatatableWizardProvider = 'supabase' | 'instance' | 'resource'
export type DatatableWizardEvent =
/** The wizard was opened, including a run resumed from the Supabase redirect. */
| { step: 'opened' }
/** A substrate was picked. Re-picking a different one counts again, by design: the
* abandoned branch is the interesting half of a funnel. */
| { step: 'picked'; provider: DatatableWizardProvider }
/** A run finished, with the verdict the checklist reported. */
| { step: 'done' | 'failed'; provider: DatatableWizardProvider }
export function logDatatableWizard(event: DatatableWizardEvent): void {
const key = event.step === 'opened' ? 'opened' : `${event.step}_${event.provider}`
logFeatureUsage('datatable', 'wizard', { key })
}
export type DdlGuardChoice =
/** The DDL was run ad-hoc, against the guard's advice. */
| 'run_anyway'
/** The DDL became a migration definition. */
| 'migrated'
/** The statement was abandoned, so nothing ran. */
| 'cancelled'
/**
* Counted once per prompt that reaches a terminal choice. Picking "create a migration" and then
* dismissing the modal loops back to the prompt instead, and is deliberately not counted: it is
* the same statement still undecided, not a fourth outcome.
*/
export function logDdlGuardChoice(choice: DdlGuardChoice): void {
logFeatureUsage('datatable', 'ddl_guard', { key: choice })
}