feat(telemetry): emit partner_nodes_detected + stamp caller_kind on every event (BE-5633) - #647
feat(telemetry): emit partner_nodes_detected + stamp caller_kind on every event (BE-5633)#647mattmillerai wants to merge 4 commits into
Conversation
…very event (BE-5633) Partner-node detection already ran on every local `comfy run` but never reached telemetry, and caller attribution (caller.py) never reached event props. Wire both through tracking.py so partner-API usage is measurable and attributable. - `comfy run` (local) fires `partner_nodes_detected` whenever the workflow uses partner-API nodes, before the partner_node_requires_credential rejection so that funnel is counted (credential_present marks it). - `_dispatch` stamps `caller_kind` (user/pipe/agent/claude-code/custom COMFY_USER_AGENT label) on every event, alongside cli_version/tracing_id. Telemetry only — no change to run semantics.
|
Warning Review limit reachedYou’ve reached a temporary PR review limit under our Fair Usage Limits Policy. Next review available in: 30 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Plus Run ID: 📒 Files selected for processing (10)
Comment |
There was a problem hiding this comment.
🔍 Cursor Review — Consolidated panel
Triggered by @mattmillerai.
Found 5 finding(s).
| Severity | Count |
|---|---|
| 🟠 High | 1 |
| 🟡 Medium | 2 |
| 🟢 Low | 2 |
Panel: 8/8 reviewers contributed findings.
…de names (BE-5633) Addresses the Cursor review panel findings on #647. - caller: `detect_caller()` called `sys.stdout.isatty()` unguarded, but `_caller_kind` now evaluates it at `comfy_cli.tracking` import — which happens during CLI startup for every command. Under pythonw/detached processes `sys.stdout` is None (AttributeError) or already closed (ValueError), so a telemetry detail became an import-time crash for even `comfy --help` with tracking disabled. Guarded in `detect_caller` itself rather than at the tracking call site, so the pre-existing renderer path (`output/renderer.py`) is fixed by the same change. Falls through to kind="pipe" — a process with no usable stdout is by definition not a human at a terminal. Mirrors `_stdin_is_interactive`. - tracking: `caller_kind` can be an arbitrary `COMFY_USER_AGENT` label that detect_caller only lowercases, and it now rides every event, including `feedback_submitted` which dispatches even when passive telemetry consent is off. Scrub URL query strings and cap to 64 chars before it ships, the same treatment command kwargs get. - run: `partner_nodes[:20]` bounded the element count but not each element, and class_type strings come verbatim from untrusted workflow JSON — one multi-megabyte class name still shipped whole. Cap each name to 64 chars; `partner_node_count` stays exact. The same cap now bounds the node list echoed in the missing-credential error prose, with an "and N more" suffix; `details.partner_nodes` stays complete since that is the machine-readable field JSON consumers read. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Addressed the Cursor panel findings in 453d3e1 — 4 of 5 fixed, 1 deferred with reasoning. Re-triggered the panel to re-review the fix commit.
Two notes worth a reviewer's attention: The high-severity fix landed in I was wrong about the synchronous-send finding and changed my mind. I expected to rebut it on the grounds that I did not fix it here because the event has to precede the missing-credential rejection branch — that funnel is the metric BE-5633 exists to produce — and the real fix is making Mixpanel dispatch non-blocking, which has to be designed against the bounded atexit drain from BE-3403. Filed for the backlog and flagged as needing design sign-off rather than blind implementation. If you would rather not take that latency at all, the alternative is to hold this PR until the dispatch fix lands. Verification: full suite green locally (3763 passed, 37 skipped); Still needs a human approval — I do not merge. |
|
🤖 The reviews loop filed Linear follow-up ticket(s) for review thread(s) deferred as out of scope for this PR:
The following carry
|
There was a problem hiding this comment.
🔍 Cursor Review — Consolidated panel
Triggered by @mattmillerai.
Found 10 finding(s).
| Severity | Count |
|---|---|
| 🟠 High | 2 |
| 🟡 Medium | 5 |
| 🟢 Low | 2 |
| ⚪ Nit | 1 |
Panel: 8/8 reviewers contributed findings.
…details (BE-5633)
Second Cursor panel round. Six of ten findings were valid; the four
rebutted are answered on their threads.
Startup crash-safety (2 high):
- `stream_is_tty` in caller.py is now the one fail-safe probe for every
standard-stream TTY check on the startup path. Its handler is broad
(`Exception`) rather than the AttributeError/ValueError pair: `isatty`
is an arbitrary attribute on an arbitrary object, so a revoked fd
raises OSError (EBADF / WinError 6) and a non-conforming replacement
stream can raise anything. Every failure means the same thing.
- `Renderer.resolve` and `tracking.prompt_tracking_consent` still called
`sys.stdout.isatty()` unguarded, both from the main Typer callback —
so under `pythonw`/detached stdout `comfy --help` died moments after
detect_caller succeeded. The previous round's guard only covered
detect_caller, and the tests exercised it in isolation, which gave
false confidence. Both now route through the shared probe, as does
run's `_stdin_is_interactive`.
- `detect_caller` probes stdout lazily, only once every env branch has
declined, so an explicitly-attributed caller never touches the stream.
Telemetry hygiene:
- `_scrub_value` strips URL userinfo, not just the query and fragment:
`COMFY_USER_AGENT=https://svc:s3cret@host/agent` fit under the 64-char
cap and shipped a basic-auth secret to both providers on every event.
Generalized to any `<scheme>://` — the same slot carries credentials
for ftp/ssh/redis, and the strip only ever removes those components.
- `feedback_submitted` — the one path that sends without passive consent
— now carries the caller kind narrowed to the four intrinsic kinds
("custom" otherwise), instead of a free-text environment-derived label.
Consent-gated paths keep the full label.
Bounded node names:
- `details` is bounded like the prose message. Capping only the message
bounded nothing: `error_panel` prints details as key=value rows right
underneath it, and JSON mode serializes them. `partner_node_count`
carries the exact total, and docs/json-output.md records the contract.
- De-duplicate after truncation, not before: two class_types sharing a
64-char prefix collapsed to one string and listed the same name twice.
Full suite green (3783 passed, 37 skipped); ruff 0.15.15 (the CI pin)
clean on check and format.
|
Addressed the second Cursor panel in a273852 — 6 of 10 fixed, 3 rebutted with reasoning, 1 already tracked. Each thread has the individual reasoning; the three things worth a reviewer's attention are below.
The high-severity finding was right that last round's fix didn't actually fix anything. The panel's point was sharp: guarding I reversed myself on the Three findings are rebutted rather than fixed, each because the premise doesn't hold rather than because the concern is unimportant — non-string One thing I deliberately did not touch: the BE-4326 spend gate five lines above passes the same uncapped list in its own Verification: full suite green (3783 passed, 37 skipped); Still needs a human approval — I do not merge. |
There was a problem hiding this comment.
🔍 Cursor Review — Consolidated panel
Triggered by @mattmillerai.
Found 8 finding(s).
| Severity | Count |
|---|---|
| 🟠 High | 1 |
| 🟡 Medium | 3 |
| 🟢 Low | 3 |
| ⚪ Nit | 1 |
Panel: 8/8 reviewers contributed findings.
…(BE-5633) Third Cursor panel round, on the previous commit's own fixes. All eight findings were valid — four of them defects this branch introduced. Credential scrub (real leak, introduced in a273852): - `_scrub_value` split the query/fragment off BEFORE the userinfo, and bounded the authority at the first `/`. Both assume a well-formed URL, and the values that worry us are the malformed ones: a password containing `?` stranded its first half in the output, and one containing `/` was returned verbatim, intact. base64-ish tokens routinely contain both. Userinfo now comes off first, on the last `@` in the whole remainder, so the credential's own contents can't move the cut point. Startup crash-safety: - The `getattr(stream, "isatty", None)` lookup sat OUTSIDE the try, and `getattr` with a default only swallows AttributeError — a proxy stream whose `isatty` is a property or `__getattr__` raising ValueError/OSError escaped a function contracted to never raise. Lookup moved inside. - Resolving to JSON against a dead stdout only DEFERRED the crash to the first emit, after the command's side effects had landed. `_write_json_line` now no-ops on AttributeError/ValueError (stdout None or closed). `OSError` is deliberately still propagated: `comfy cloud login` depends on BrokenPipeError escaping the `login_url` emit to fail fast rather than block 300s, which the existing test pins. Guarding "no usable stream" must not become "ignore all I/O errors". - `_maybe_nudge_setup` still had a bare `sys.stderr.isatty()`; stderr can be closed independently of stdout (`comfy install 2>&-`). Caller kind: - Narrowing the feedback label keyed on membership in the intrinsic set, but `COMFY_USER_AGENT=user` produces exactly that string — an agentic caller could pass itself off as a human. Now keyed on `source_env`, which is authoritative. Node-name bounding: - The spend gate (the more commonly hit branch, and `execute_cloud` with it) still passed raw names. Bounding moved inside `_spend_gate` so both call sites are covered, rather than leaving the docstring overclaiming. - The 20-item cap was applied before de-duplication, so prefix-colliding names burned output slots and dropped later distinct ones. The cap is now on distinct truncated names. - `and N more` conflated cap-drops with dedup-collapses, promising unlisted nodes that didn't exist. It now counts only what the cap omitted. docs/json-output.md records the `spend_consent_required` contract change. Full suite green (3793 passed, 37 skipped); ruff 0.15.15 clean.
|
Third panel round, on the previous commit's own fixes — all 8 findings valid, all fixed in 9fb8218. Four were defects this branch introduced, so this round earned its keep.
The scrub finding was a genuine credential leak, not a hardening nit. My a273852 fix split the query and fragment off before the userinfo and bounded the authority at the first The high-severity fix is deliberately incomplete, and the repo's own test is why. The natural handler is I reversed myself on the spend gate, which I declined last round as pre-existing. That reasoning didn't survive my own docstring: I wrote "for every payload that leaves this process or reaches a terminal" into Verification: full suite green (3793 passed, 37 skipped); Still needs a human approval — I do not merge. |
ELI-5
The CLI already figures out when your workflow uses a paid "partner" node (Veo, Kling, BFL, Gemini, …) — but it never told analytics about it, so nobody could see how often that happens or how often it fails for a missing key. This adds one event for that. It also stamps every telemetry event with who ran the command — a human at a terminal, a pipe, an agent, Claude Code, or a custom
COMFY_USER_AGENTlabel — so agent-vs-human usage is finally measurable across the whole event stream.Telemetry only: no change to what
comfy rundoes, what it submits, or when it fails.What changed
1.
partner_nodes_detected(comfy_cli/command/run/__init__.py) — localcomfy runfires it whenever the workflow uses partner-API nodes, withpartner_nodes(sorted distinct class_types, capped at 20 to defend against pathological graphs),partner_node_count(exact, uncapped),where: "local", andcredential_present. It fires before thepartner_node_requires_credentialrejection, so runs turned away for a missing key are still counted withcredential_present: false— that funnel is exactly what the metric is for. The credential is resolved once and reused for both the event prop and the existing gate._detect_partner_nodesstays a pure function;preflight.pyis untouched.2.
caller_kindon every event (comfy_cli/tracking.py) —_dispatchnow enriches withcaller_kindalongsidecli_version/tracing_id, computed once at module scope fromdetect_caller().kind(matching the existingtracing_id/cli_versionpattern, soisattyisn't re-run per event). This lands on every event —execution_*, the new partner event, feedback — which is the point; it is purely additive, so no existing dashboard breaks.No PII: class_types are node names, the same data
workflow_unknown_nodeserror details already send.track_eventstill no-ops without consent (DO_NOT_TRACK/COMFY_NO_TELEMETRY/ no consent flag), so the new event is gated exactly like every other one.Judgment call — ordering vs the spend gate (please sanity-check this one)
The plan said to fire the event immediately after
_detect_partner_nodes(...). Since that plan was written, the BE-4326 spend gate landed between detection and credential resolution, and it is explicitly ordered to run before any credential resolution so a refusal never triggers a network OAuth refresh (there is a regression test asserting_resolve_partner_credentialis not called:test_paid_node_machine_mode_fails_closed_without_flag).credential_presentneeds that resolution, so firing the event pre-gate would have meant resolving the credential early and breaking that invariant. I kept the gate's invariant and placed the event immediately after it, still before the credential-rejection branch (the ordering the plan actually justified).Consequence, stated plainly: a run refused for lack of
--allow-spendemits nopartner_nodes_detected. The population that reaches the event is "partner run where spending was consented to." If the spend-declined funnel is also wanted, it deserves its own event (e.g. onspend_consent_required) rather than an early credential resolve here — happy to file that as a follow-up.test_does_not_fire_when_the_spend_gate_refusesdocuments the boundary so it can't drift silently.Scope notes
execute()path fires the event, per the ticket'swhere: "local"prop.execute_cloud()also detects partner nodes and is deliberately left alone — cloud usage is already measurable server-side, and adding awhere: "cloud"variant is a separate call.caller_kindis computed at import, so it reflects the process's TTY-ness at startup rather than at each event. That is intentional (and cheaper); a latersys.stdoutswap won't change it.Tests
tests/comfy_cli/test_tracking.py—caller_kindreaches provider.trackproperties for bothtrack_eventandsubmit_feedback; is one of the intrinsic kinds by default; an explicitCOMFY_USER_AGENTlabel flows through lowercased. Also updated the existing exact-key-set assertion intest_properties_default_to_empty_dict.tests/comfy_cli/command/test_run.py— newTestPartnerNodesDetectedTelemetry: fires with the right props under--api-keyand under an env key; does not fire for a partner-free workflow; does fire when the run is then rejected withpartner_node_requires_credential(credential_present: false); does not fire when the spend gate refuses; the 20-node cap truncates the list whilepartner_node_countstays exact.Verification
ruff check .+ruff format --diff .clean on the CI-pinned ruff 0.15.15, and the fullpytestsuite: 3752 passed, 37 skipped.