Skip to content

feat: stream CLI log transcripts and run status to Socket backend#201

Draft
Benjamin Barslev Nielsen (barslev) wants to merge 9 commits into
mainfrom
barslev/streaming-logs
Draft

feat: stream CLI log transcripts and run status to Socket backend#201
Benjamin Barslev Nielsen (barslev) wants to merge 9 commits into
mainfrom
barslev/streaming-logs

Conversation

@barslev

@barslev Benjamin Barslev Nielsen (barslev) commented May 12, 2026

Copy link
Copy Markdown

Adds an opt-in streaming log channel between the Python CLI and the
Socket backend so the CLI run's terminal status (in_progress /
success / failure / cancelled) and a transcript of its own log
output are visible in the Socket admin views when the user opts in
with --upload-logs. The Socket backend may also force-enable
streaming for specific orgs regardless of the flag.

Why?

The Socket backend currently has no visibility into what happens
inside a CLI invocation — there's no record of whether a scan ran
to completion or what was logged along the way. This PR opens a
bounded, opt-in side-channel that uploads the CLI's own log records
to the backend and reports the run's terminal status, without
changing any existing CLI request on the wire.

Changes

New modules

  • socketsecurity/core/cli_run.pyregister_cli_run and
    finalize_cli_run lifecycle helpers, both best-effort.
    register_cli_run always calls POST /v0/python-cli-runs with the
    user's share_logs choice; the server responds with
    log_streaming_enabled and a nullable run_id. The CLI gates the
    rest of the lifecycle on log_streaming_enabled (the server can
    force-enable for an org even when the client didn't ask).
    finalize_cli_run accepts an optional report_run_id that links
    the run to the full-scan it produced.
  • socketsecurity/core/log_uploader.pyBatchedLogUploader
    (daemon-thread flusher, 5s flush interval, swallows all network
    errors at-most-once, skips empty buffers, drains on shutdown) plus
    UploadingLogHandler — a logging.Handler that routes records
    to the uploader. Includes a thread-local recursion guard so the
    uploader's own request-error logs aren't re-enqueued mid-flush.
  • socketsecurity/core/streaming.pysetup_streaming() wires the
    uploader and a terminal handler into the socketcli and
    socketdev loggers, forces both loggers to DEBUG so the upload
    captures the full history regardless of --enable-debug state,
    and returns a teardown callable for the caller to register with
    atexit. Module-level setters set_run_status() and
    set_report_run_id() propagate the terminal status and the
    associated full-scan id into finalize_cli_run.

Existing files

  • socketsecurity/config.py — adds --upload-logs /
    --upload_logs opt-in flag (advanced group, default off). When
    set, the CLI sends share_logs=true in the register payload.

  • socketsecurity/socketcli.py — always calls setup_streaming()
    after CliClient init (register is cheap and the server may
    force-enable for an org); registers the teardown via atexit only
    when the server returned a run_id. Captures diff.id at a single
    chokepoint after the diff-producing branches converge and threads
    it through set_report_run_id(), guarded against the
    NO_DIFF_RAN / NO_SCAN_RAN sentinel values. Exception handlers
    in cli() now call set_run_status(...):

    • KeyboardInterruptcancelled
    • any SystemExit with non-zero code → failure
    • uncaught Exceptionfailure
    • clean exit → default success

    The SystemExit handling is load-bearing: several failure paths
    in main_code() call sys.exit(3) directly, which bypasses
    except Exception (since SystemExit is a BaseException
    subclass, not Exception).

Test plan

  • uv run pytest tests/unit — 171 unit tests pass, including
    the new tests in test_cli_run.py, test_log_uploader.py, and
    test_streaming.py (covering opt-in/opt-out register paths and
    the disabled-by-server response shape).
  • End-to-end against a local Socket backend with the matching
    endpoints applied:
    • --upload-logs set, server says enabled → full CLI log captured,
      run reports status: success, report_run_id populated with
      the full-scan id and joinable to the underlying full_scans row
    • --upload-logs set, simulated RuntimeError → run reports
      status: failure, log captured up to the crash
    • 15s continuous-log run → batches arrive at the backend at ~5s
      intervals while the CLI is still running
  • Without --upload-logs (and no org-side override) → CLI calls
    register, gets log_streaming_enabled: false, sends no further
    streaming requests; scan otherwise unchanged
  • Register failure (404 / 401 / network down) → CLI silently
    degrades, scan continues normally, no exception surfaces

Public Changelog

A new opt-in --upload-logs flag uploads the CLI's log output to the Socket backend for the duration of the run, alongside a per-run status (in_progress / success / failure / cancelled). The transcript is captured regardless of the local --enable-debug state; the existing terminal verbosity is unchanged. The Socket backend can also force-enable streaming for specific orgs regardless of the flag. Default off; CLI runs without the flag are unaffected.

Buffer the CLI's own log records and POST them in 5s batches to a new
register/upload/finalize lifecycle so the admin dashboard renders what
the user saw in their terminal alongside the run's terminal status.

New modules:
- core/cli_run.py — register_cli_run / finalize_cli_run helpers
- core/log_uploader.py — BatchedLogUploader (daemon-thread flusher,
  chunked under the 256KB cap, swallows network errors, drains on
  shutdown) and UploadingLogHandler routing log records to it
- core/streaming.py — setup_streaming() wires both into the socketcli
  and socketdev loggers, forces them to DEBUG so uploads capture the
  full history regardless of local terminal verbosity, and returns a
  teardown callable for the caller to register with atexit
- set_run_status() propagates the terminal status through the teardown;
  socketcli.py exception handlers call it for KeyboardInterrupt
  (cancelled), uncaught Exception (failure), and any SystemExit with a
  non-zero code (failure) so sys.exit() paths inside main_code surface
  correctly instead of defaulting to success

Best-effort end-to-end: registration failures fall back to no-streaming
and never block the scan. Opt out with --disable-server-log-streaming.

Tested against local depscan with the matching /v0/python-cli-runs/*
endpoints; 173 unit tests pass.
@github-actions

github-actions Bot commented May 12, 2026

Copy link
Copy Markdown

🚀 Preview package published!

Install with:

pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple socketsecurity==2.4.8.dev7

Docker image: socketdev/cli:pr-201

The 256 KB ceiling I added speculatively when the server cap was 256 KB
no longer matches the reference implementation we're mirroring, which
sends each flush as a single POST regardless of size. With the server
cap now well above any plausible single-flush volume, chunking is
unnecessary and divergent — drop it.

Removes _chunk_by_size, _MAX_BATCH_BYTES, and the four chunking tests.
_flush now POSTs the entire buffered batch as one request.
The server-side handler now rejects unknown fields and the integration
column has been removed from the schema (it was plumbed end-to-end but
never displayed, filtered, or grouped on). Stop sending it.

Removes the integration parameter from register_cli_run and
setup_streaming, drops the corresponding wiring in socketcli.py, and
prunes the now-pointless test_register_cli_run_omits_integration_when_falsy
case.
The depscan side now joins cli_run → full_scans → repositories via the
report_run_id field to surface the scanned repo in the admin dashboard
view of each CLI run. Wire the CLI to send the full_scan_id (== the
report_run_id depscan expects) when it has one.

- finalize_cli_run accepts an optional report_run_id and includes it
  (nullable) in the POST body.
- streaming.py adds a module-level _report_run_id holder and a
  set_report_run_id() setter; teardown passes it through to finalize.
- socketcli.py captures diff.id at a single chokepoint after the
  diff-producing branches converge, guarded against the NO_DIFF_RAN /
  NO_SCAN_RAN sentinel values.

The field is nullable end-to-end so CLI invocations that fail before
producing a diff (or are run in modes that don't create one) still
finalize cleanly.
- socketsecurity/__init__.py: __version__ → 2.2.87
- pyproject.toml: version → 2.2.87
- CHANGELOG.md: new 2.2.87 entry describing the streaming-logs feature

Required by .github/workflows/version-check.yml, which fails the PR if
the version isn't incremented relative to main.
@lelia lelia self-assigned this May 18, 2026
@lelia lelia added the enhancement New feature or request label May 31, 2026
The Socket backend changed its register contract so that log streaming
is now opt-in rather than default-on. The CLI always calls register
(cheap, lets the server force-enable for specific orgs) and gates the
downstream upload/finalize lifecycle on the response.

Wire changes:
- POST /v0/python-cli-runs body adds a required `share_logs` field.
- Response: { log_streaming_enabled: bool, run_id: <uuid|null> }.
  When log_streaming_enabled is false, run_id is null and the CLI
  skips the upload + finalize calls entirely.

CLI changes:
- New `--upload-logs` flag (default off). When set, the CLI sends
  share_logs=true on register.
- Removed `--disable-server-log-streaming` — default is off, so an
  opt-out flag no longer makes sense.
- register_cli_run takes a required share_logs arg and returns None
  whenever log_streaming_enabled is false (whatever the reason: client
  opted out, server denied, server unreachable).

Bumps version to 2.2.88 and updates the CHANGELOG entry to reflect
the opt-in shape.
# Conflicts:
#	CHANGELOG.md
#	pyproject.toml
#	socketsecurity/__init__.py
#	socketsecurity/socketcli.py
The version-check workflow added in main now requires uv.lock to be
updated whenever pyproject.toml changes, and the SFW smoke jobs run
`uv sync --locked`, which fails on an out-of-sync lockfile.
Backend now distinguishes "user wants out" from "user said nothing":
- `decline_logs: true` (the new flag) overrides every other signal
  including the server-side org-level override, so users with a
  legal/consent reason for no upload get a guaranteed off.
- `share_logs: true` (the existing --upload-logs) opts in.
- Otherwise the server applies its own policy.

Argparse enforces that --upload-logs and --no-upload-logs are mutually
exclusive (post-parse check via parser.error so dash/underscore aliases
on either side still coexist with the same dests).

register_cli_run now sends both `share_logs` and `decline_logs` in the
payload; setup_streaming forwards both. CHANGELOG 2.4.8 entry updated
to call out --no-upload-logs alongside --upload-logs.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants