feat: stream CLI log transcripts and run status to Socket backend#201
feat: stream CLI log transcripts and run status to Socket backend#201Benjamin Barslev Nielsen (barslev) wants to merge 14 commits into
Conversation
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.
|
🚀 Preview package published! Install with: pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple socketsecurity==2.4.9.dev4Docker image: |
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.
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.
# Conflicts: # CHANGELOG.md
2.4.8 already shipped with the full-scan retry fix; this release adds the opt-in --upload-logs streaming channel.
version-check reads socketsecurity/__init__.py; the previous bump only touched pyproject.toml.
| upload_logs: bool = False | ||
| decline_logs: bool = False |
There was a problem hiding this comment.
These two values are connected and would be more cleanly modelled by a single field with 3 possible values.
There was a problem hiding this comment.
The global state and atexit.register aren't that nice.
Would it be possible to refactor this module into a context manager.
The set_run_status and set_report_run_id methods could live on the manager.
The CLI's main function could then do with setup_streaming(...) as streaming_logs: ...
There was a problem hiding this comment.
With this design the set_run_status method doesn't need to exist at all, it should be handled inside the manager's __exit__
| cli_logger.setLevel(logging.DEBUG) | ||
| sdk_logger.setLevel(logging.DEBUG) | ||
| cli_logger.propagate = False | ||
| sdk_logger.propagate = False | ||
| cli_logger.addHandler(terminal_handler) | ||
| sdk_logger.addHandler(terminal_handler) | ||
| cli_logger.addHandler(upload_handler) | ||
| sdk_logger.addHandler(upload_handler) |
There was a problem hiding this comment.
Nit: use a loop over (cli_logger, sdk_logger).
| cli_logger.removeHandler(terminal_handler) | ||
| sdk_logger.removeHandler(terminal_handler) | ||
| cli_logger.setLevel(saved_levels[0]) | ||
| sdk_logger.setLevel(saved_levels[1]) | ||
| cli_logger.propagate = saved_propagate[0] | ||
| sdk_logger.propagate = saved_propagate[1] |
There was a problem hiding this comment.
Nit: use a loop over zip((cli_logger, sdk_logger), saved_levels, saved_propagate)
| _LEVEL_MAP = { | ||
| logging.DEBUG: "DEBUG", | ||
| logging.INFO: "INFO", | ||
| logging.WARNING: "WARN", | ||
| logging.ERROR: "ERROR", | ||
| logging.CRITICAL: "ERROR", |
There was a problem hiding this comment.
Use logging.getLevelName
| if self._thread is None: | ||
| self._flush() | ||
| return | ||
| self._stop.set() | ||
| self._thread.join(timeout=timeout) | ||
| self._thread = None |
There was a problem hiding this comment.
| if self._thread is None: | |
| self._flush() | |
| return | |
| self._stop.set() | |
| self._thread.join(timeout=timeout) | |
| self._thread = None | |
| if self._thread is not None: | |
| self._stop.set() | |
| self._thread.join(timeout=timeout) | |
| self._thread = None |
- collapse upload_logs/decline_logs config fields into a single Optional[bool] (tri-state); projection to share_logs/decline_logs happens at the setup_streaming call site. - streaming.py: replace module-level globals + atexit teardown with a StreamingLogs context manager. set_run_status disappears entirely — __exit__ infers the run status from the exception that closed the with block. set_report_run_id is now an instance method. Logger handler wiring iterates over (cli_logger, sdk_logger) instead of repeating itself. - log_uploader.py: drop _LEVEL_MAP, use logging.getLevelName directly. Wire format changes WARN/ERROR-for-CRITICAL to WARNING/CRITICAL. - log_uploader.py: tidy BatchedLogUploader.stop so the final _flush always runs and the thread shutdown only runs when there is a thread. - socketcli.py: wrap main_code body in 'with setup_streaming(...) as streaming:'; cli()'s exception handlers no longer need to set status before re-raising. - tests updated for the CM API and the new level strings.
| advanced_group.add_argument( | ||
| "--upload-logs", | ||
| dest="upload_logs", | ||
| action="store_true", | ||
| help="Upload the CLI's log output to the Socket backend for this run. " | ||
| "When set, the CLI registers the run with share_logs=true and streams " | ||
| "its log records in 5s batches. Default off. Mutually exclusive with " | ||
| "--no-upload-logs." | ||
| ) | ||
| advanced_group.add_argument( | ||
| "--upload_logs", | ||
| dest="upload_logs", | ||
| action="store_true", | ||
| help=argparse.SUPPRESS | ||
| ) | ||
| advanced_group.add_argument( | ||
| "--no-upload-logs", | ||
| dest="decline_logs", | ||
| action="store_true", | ||
| help="Explicitly opt out of uploading CLI logs to the Socket backend, even " | ||
| "when an org-level override would otherwise enable it. Mutually " | ||
| "exclusive with --upload-logs." | ||
| ) | ||
| advanced_group.add_argument( | ||
| "--no_upload_logs", | ||
| dest="decline_logs", | ||
| action="store_true", | ||
| help=argparse.SUPPRESS | ||
| ) |
There was a problem hiding this comment.
These can use the same dest (upload_logs) with the store_const action and an appropriate const. They can also be made mutually exclusive at the argparse level with a mutually exclusive group.
Adds a deterministic regression test that parks the uploader thread inside _flush() via a threading.Event, emits a real log record from the main thread while _FLUSH_GUARD.active is set on the uploader thread, and asserts the record lands in the next batch (not dropped). Documents that the thread-local guard only blocks recursive emits on the uploader thread itself.
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 logoutput are visible in the Socket admin views when the user opts in
with
--upload-logs. The Socket backend may also force-enablestreaming 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.py—register_cli_runandfinalize_cli_runlifecycle helpers, both best-effort.register_cli_runalways callsPOST /v0/python-cli-runswith theuser's
share_logschoice; the server responds withlog_streaming_enabledand a nullablerun_id. The CLI gates therest of the lifecycle on
log_streaming_enabled(the server canforce-enable for an org even when the client didn't ask).
finalize_cli_runaccepts an optionalreport_run_idthat linksthe run to the full-scan it produced.
socketsecurity/core/log_uploader.py—BatchedLogUploader(daemon-thread flusher, 5s flush interval, swallows all network
errors at-most-once, skips empty buffers, drains on shutdown) plus
UploadingLogHandler— alogging.Handlerthat routes recordsto 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.py—setup_streaming()wires theuploader and a terminal handler into the
socketcliandsocketdevloggers, forces both loggers toDEBUGso the uploadcaptures the full history regardless of
--enable-debugstate,and returns a teardown callable for the caller to register with
atexit. Module-level settersset_run_status()andset_report_run_id()propagate the terminal status and theassociated full-scan id into
finalize_cli_run.Existing files
socketsecurity/config.py— adds--upload-logs/--upload_logsopt-in flag (advanced group, default off). Whenset, the CLI sends
share_logs=truein the register payload.socketsecurity/socketcli.py— always callssetup_streaming()after
CliClientinit (register is cheap and the server mayforce-enable for an org); registers the teardown via
atexitonlywhen the server returned a run_id. Captures
diff.idat a singlechokepoint after the diff-producing branches converge and threads
it through
set_report_run_id(), guarded against theNO_DIFF_RAN/NO_SCAN_RANsentinel values. Exception handlersin
cli()now callset_run_status(...):KeyboardInterrupt→cancelledSystemExitwith non-zero code →failureException→failuresuccessThe
SystemExithandling is load-bearing: several failure pathsin
main_code()callsys.exit(3)directly, which bypassesexcept Exception(sinceSystemExitis aBaseExceptionsubclass, not
Exception).Test plan
uv run pytest tests/unit— 171 unit tests pass, includingthe new tests in
test_cli_run.py,test_log_uploader.py, andtest_streaming.py(covering opt-in/opt-out register paths andthe disabled-by-server response shape).
endpoints applied:
--upload-logsset, server says enabled → full CLI log captured,run reports
status: success,report_run_idpopulated withthe full-scan id and joinable to the underlying
full_scansrow--upload-logsset, simulatedRuntimeError→ run reportsstatus: failure, log captured up to the crashintervals while the CLI is still running
--upload-logs(and no org-side override) → CLI callsregister, gets
log_streaming_enabled: false, sends no furtherstreaming requests; scan otherwise unchanged
degrades, scan continues normally, no exception surfaces
Public Changelog
A new opt-in
--upload-logsflag 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-debugstate; 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.