Files
windmill/frontend/src/lib/components/flows/flowEditorTelemetry.ts
T
GuilhemandClaude Opus 5 676256bacc feat(flow-editor): measure step panel placement (#10543)
* feat(flow-editor): measure the redesigned step panels

Instruments the flow editor's step, loop and branch panels on the existing
anonymous `feature_usage` channel, so the redesign can be judged on how the
panels are actually used rather than on nothing.

Eight event kinds under a new `flow_editor` feature: panel opens and their
dwell (bucketed, per placement), placement-preference overrides, which
settings get configured or cleared, settings that read as invalid, the
prop-picker connect lifecycle, AI input suggestions, and the step header
menu that "Save to workspace" now lives behind.

Settings changes are diffed off `describeStepSettings`, the same view the
graph badges render, so the telemetry vocabulary cannot drift from the one
on screen. Only `panel_open` and `setting` carry an entity id — one opaque
id per editor mount — since a per-entity row is only worth its cost where
the spread per editing session is the question.

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

* fix(flow-editor): keep the panel telemetry honest

Review follow-ups on the instrumentation:

- The top dwell bucket was `120s+`, and `+` is outside the charset
  `is_identifier_shaped` accepts, so `log_feature_usage` skipped those
  events and still answered 204 — the longest visits vanished with no
  error on either side. Renamed to `120s_plus` and pinned every emittable
  key against the backend's charset in a test, since the producer is
  TypeScript and the validator is Rust.
- Dropped the per-session entity id from `setting`: it would pay a row per
  session per day across twenty-four keys, for a distribution its plain
  counter already largely answers.
- An armed connect that went away with its component never reported, so
  `open` did not balance against `insert` + `abandon`.
- Session preview tabs keep hidden editors mounted, which billed panel
  time nobody spent. `FlowEditorView` now publishes the visibility it
  already knows about.
- Re-picking the active placement row logged a move, which also made
  `auto:from_docked` mean two different things.
- The last dwell of a session was lost on tab close, since Svelte tears
  components down on navigation but not on `pagehide`.

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

* refactor(flow-editor): narrow the telemetry to panel placement

The eight-kind instrumentation measured more than could be read. With nothing
recorded before the redesign there is no baseline to compare panel opens, dwell
times, settings usage or connect funnels against, so those counters answered
questions nobody could act on while costing a row per key per day in an
instance-wide table.

What remains are the three numbers the modal panel is actually judged on: how
often the 1280px breakpoint puts the panel in a modal, and how often people
override that in each direction.

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

* fix(flow-editor): stop counting placement in session preview tabs

Preview tabs keep every flow editor mounted and laid out at panel width
whether or not it is the visible one, and that panel is narrower than the
breakpoint by construction. Each flow tab opened in a session therefore
emitted a `breakpoint_modal` on mount, and one drag of the session panel
across 1280px emitted one per mounted tab — with no host dimension in the
key to separate that from the crossings the counter exists to measure.

Also corrects the comment on the no-op placement guard, which justified
itself with a key vocabulary that no longer exists.

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

* fix(flow-editor): make the three placement counters comparable

Sessions were excluded from the breakpoint counter but not from the two
override counters, so a pin made in a session landed in the same bucket
used to judge the breakpoint, with no crossing in the denominator to read
it against. All three are now gated together.

An override is also only counted when it moves the panel. Choosing
"Detached" on an editor the width had already put in a modal states a
preference without changing anything, and the aggregate carries no width
to separate that from the wide-screen override that is the actual signal.

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

* docs(flow-editor): describe the two override keys by what emits them

They documented themselves as overriding `auto`, which is no longer the
rule: pinning Attached on a wide editor overrides `auto` and emits
nothing, while going from an Attached pin to Detached below the
breakpoint emits `force_detach` even though `auto` would have produced a
modal there too. This file is what someone reads when interpreting the
numbers, and "override of auto" is the misreading the emission rule
exists to prevent.

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

* fix(flow-editor): count the panel moving, not the breakpoint being armed

The tracker held "the breakpoint is responsible for this modal" rather
than "the panel is modal", so on a narrow editor pinning Detached and
releasing it back to Auto emitted a second breakpoint_modal for a panel
that never moved. It also died with the editor, which FlowBuilder rebuilds
through a `{#key}` on every reload — each rebuild re-armed it and counted
the same narrow editor again.

Both inflate the denominator that the two override counters are read
against, and both bias it the same way: toward concluding that nobody
overrides the breakpoint.

The tracker now follows the panel's placement across preference changes,
and FlowBuilder owns it from above the `{#key}`, which also puts the
session exclusion in one place instead of at each call site. The
moves-only rule moves into `forcedPlacementEvent` so both halves of it sit
in the module the tests can reach.

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

* fix(flow-editor): ignore placements measured before the editor is laid out

A reload rebuilds the editor through `{#key renderCount}`, and the panel
controller is rebuilt with it: its width restarts at zero, which resolves to
`docked` because that is what is safe to render rather than because the editor
is wide. The breakpoint tracker read that transient as the panel having docked
and counted the real width landing as a fresh crossing, inflating the
denominator both override ratios are read against.

`useFlowPanelMode` now exposes `measured`, and the tracker skips anything
unmeasured instead of recording it as a placement.

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

* docs(flow-editor): state the placement invariants once each

The width-zero rule had accumulated at four sites, two of which forward it
without being able to break it. Keep it beside the guards that enforce it.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 12:29:31 +02:00

95 lines
3.8 KiB
TypeScript

import { getContext, setContext } from 'svelte'
import { logFeatureUsage } from '$lib/utils/featureUsage'
import type { FlowPanelMode, FlowPanelPreference } from './panelPlacement'
// Anonymous counters for where the flow editor's step panel ends up. Same rules as every
// other `logFeatureUsage` caller: aggregated counts only, and the three keys below are the
// whole vocabulary — no path, expression or step id ever reaches here.
const FEATURE = 'flow_editor'
const KIND = 'panel_placement'
export type FlowPanelPlacementEvent =
/** The width moved the panel into the modal, under `auto`. */
| 'breakpoint_modal'
/** The user pinned the panel into the pane while it was in the modal. */
| 'force_attach'
/** The user pinned the panel out into the modal while it was in the pane. */
| 'force_detach'
type Log = (event: FlowPanelPlacementEvent) => void
const log: Log = (event) => logFeatureUsage(FEATURE, KIND, { key: event })
/**
* The event a placement pin should produce, or nothing. `auto` is not a placement being
* forced, and a pin that matches where the panel already is moves nothing — the counters
* carry no width, so counting that would be indistinguishable from the override that did
* move the panel.
*/
export function forcedPlacementEvent(
preference: FlowPanelPreference,
mode: FlowPanelMode
): FlowPanelPlacementEvent | undefined {
if (preference === mode) return undefined
if (preference === 'docked') return 'force_attach'
if (preference === 'modal') return 'force_detach'
return undefined
}
/**
* Counts the width moving the panel into the modal, once per crossing — `mode` re-resolves
* continuously as a drag settles, so counting per evaluation would read one drag as hundreds.
*
* Both guards below are there because a modal panel is not by itself a crossing: `wasModal`
* follows where the panel was rather than whether the breakpoint put it there, and an
* unmeasured editor is no placement at all. Rune-free so each edge is testable directly.
*/
export function createBreakpointTracker(emit: Log) {
let wasModal = false
return {
observe(preference: FlowPanelPreference, mode: FlowPanelMode, measured: boolean) {
if (!measured) return
if (mode === 'modal' && !wasModal && preference === 'auto') emit('breakpoint_modal')
wasModal = mode === 'modal'
}
}
}
export interface FlowPanelPlacementTelemetry {
/** The panel's current placement; emits `breakpoint_modal` on a crossing into the modal. */
observe(preference: FlowPanelPreference, mode: FlowPanelMode, measured: boolean): void
/** A placement the user pinned, against where the panel was when they pinned it. */
forced(preference: FlowPanelPreference, mode: FlowPanelMode): void
}
const CONTEXT_KEY = 'flowPanelPlacementTelemetry'
const NOOP: FlowPanelPlacementTelemetry = { observe: () => {}, forced: () => {} }
/**
* Published by `FlowBuilder`, above the `{#key}` that rebuilds the editor on a reload: a
* tracker recreated mid-edit would re-arm and count a still-narrow editor again.
*
* `enabled` is false in session preview tabs. Those stay mounted and laid out at panel width
* even while hidden, and that panel is narrower than the breakpoint by construction: their
* crossings would bury the ones this measures, and their overrides would then be read
* against a denominator that no longer contains them.
*/
export function setFlowPanelPlacementTelemetry(enabled: boolean): void {
const emit: Log = enabled ? log : () => {}
const tracker = createBreakpointTracker(emit)
setContext<FlowPanelPlacementTelemetry>(CONTEXT_KEY, {
observe: tracker.observe,
forced: (preference, mode) => {
const event = forcedPlacementEvent(preference, mode)
if (event) emit(event)
}
})
}
export function useFlowPanelPlacementTelemetry(): FlowPanelPlacementTelemetry {
return getContext<FlowPanelPlacementTelemetry | undefined>(CONTEXT_KEY) ?? NOOP
}