Real-Time Monitoring

The Activities tab in the Web Admin Console shows live per-user bandwidth, transfer events, and session counts. This chapter covers how the monitoring subsystem is wired, how to configure it, how to read the charts, and how to disable it if it ever needs to be ruled out as a contributor to a performance incident.

Overview

Three charts share one toolbar (range combo, user combo, refresh button) and auto-refresh every 10 seconds:

All three honour the range combo (Last hour, Last 24 hours, Last 7 days, Last 30 days) and the user combo (All users plus every user with measurable activity).

Architecture

Bandwidth measurement is a four-stage pipeline:

  1. Counting stream wrappers tap every read and write at the StorageBackend interface. Both the local filesystem backend and the JDBC (database) backend wrap their streams with CountingInputStream / CountingOutputStream. SFTP, FTP, WebDAV, and the browser Portal all route through one of these backends, so a single hook covers every protocol.
  2. Bandwidth meter — a process-global ConcurrentHashMap of per-user AtomicLong pairs (bytes up, bytes down). The hook callback resolves the username to a user_id through a Caffeine cache (10k entries, 1h TTL) and increments the matching counter. Lock-free; about ten nanoseconds per chunk write on modern hardware.
  3. Sampler — a daemon thread on a fixed schedule (5 seconds by default). On each tick it atomically snapshots every non-zero counter pair (resetting them to zero in the same call), computes the wall-clock dt_ms since the previous tick, and inserts one row per active user into sftp_throughput_samples in the audit database. The drain-and-reset semantics mean each row represents a delta, not a cumulative — there is no cumulative state to lose on restart and no negative-delta edge case.
  4. REST query path — the Web Admin's Bandwidth chart calls GET /api/throughput/timeseries, which sums bytes_up / bytes_down per requested bucket (with optional user filter) and divides by SUM(dt_ms) / 1000 to return a true average rate for each bucket.
Why dt_ms matters. Storing the actual elapsed time per sample (not the nominal 5 s) keeps the rate calculation accurate when scheduler jitter or GC pauses stretch a tick to 5.1 s or 6 s. Without it, those samples would inflate the apparent rate during the busy hours when accuracy matters most.

Configuration

The monitoring subsystem is configured via the <bandwidth-sampler> element in sftp-server.xml. Defaults: enabled, 5-second sampling. The element is shipped commented-out in the default configuration; uncomment to customise.

<sftp-server>
    ...
    <bandwidth-sampler>
        <enabled>true</enabled>
        <interval-seconds>5</interval-seconds>
    </bandwidth-sampler>
    ...
</sftp-server>

enabled

When false, the sampler thread does not start and the retention worker does not run. The byte-counter hooks at the storage backends are still installed in the I/O pipeline but their callback short-circuits at a volatile-null check before any map mutation, so the cost when disabled is a few nanoseconds per stream call — below measurement noise.

interval-seconds

How often the sampler drains the counters and writes one row per active user. Larger values lower DB write rate and produce a coarser bandwidth chart. Reasonable range: 1 to 60. The default of 5 seconds gives ~17 280 rows per active user per day before the daily compactor folds them into hour aggregates.

JVM Kill Switch

Set the JVM system property stylus.sftpserver.bandwidth.disabled=true and the entire bandwidth subsystem is forced off, overriding the XML <enabled> setting. Add the flag to the service command-line, or for a one-off check pass it on the start script:

-Dstylus.sftpserver.bandwidth.disabled=true

When set, the server logs the choice at startup:

INFO  Bandwidth sampler disabled by -Dstylus.sftpserver.bandwidth.disabled=true

Intended as an operator escape hatch during incident response — no config file edit required, and the override is reversible by removing the property and restarting. The Activities tab still loads in the Web Admin; it just shows empty charts.

Retention & Compaction

A daily compactor task runs the following two passes in one transaction:

  1. Rollup. Every row in sftp_throughput_samples older than 24 hours is grouped by (hour_bucket, user_id), summed (bytes_up, bytes_down, and dt_ms all accumulate), then the source rows are deleted and one aggregate row per (hour, user) is inserted. A typical 5-second sample rate produces ~720 raw rows per active user per hour, so the rollup collapses that to a single row — a 720× reduction in long-tail storage.
  2. Retention. Every row older than 30 days is deleted. Rolled-up hour rows count against retention too; they age out on the same calendar.

The rollup is idempotent — running it twice on the same data produces the same result, because a row already at an hour boundary groups to itself. The rate math is preserved exactly: a chart query against rolled-up rows returns the same MB/s value it would have returned against the raw 5-second rows, because the aggregate row carries both SUM(bytes) and SUM(dt_ms).

Every compactor run logs at INFO, including the heartbeat case with both counters at zero so operators can see the schedule is alive:

INFO  Bandwidth compactor: rollup wrote 24 hour-bucket(s) for rows older than PT24H;
                          retention deleted 0 row(s) older than P30D.

Storage Footprint

Storage is small even on busy installations. Rough capacity planning at the default 5-second sampling rate:

Users with no transfer activity in a window do not appear in sftp_throughput_samples for that window — the drain skips zero-counter entries. A 200-user install where only 5 users are active at any moment carries ~5 MB of monitoring data in the audit database at steady state.

Database Schema

The sampler writes to one table in the audit database:

CREATE TABLE sftp_throughput_samples (
    sampled_at  TIMESTAMP NOT NULL,
    user_id     BIGINT    NOT NULL,
    dt_ms       INTEGER   NOT NULL,
    bytes_up    BIGINT    NOT NULL,
    bytes_down  BIGINT    NOT NULL,
    CONSTRAINT pk_sftp_throughput_samples PRIMARY KEY (sampled_at, user_id)
);

CREATE INDEX ix_sftp_throughput_user_time
    ON sftp_throughput_samples (user_id, sampled_at);
No foreign key to sftp_users. Same policy as sftp_audit: historical metrics survive a user deletion, and the table works in deployments where the user database and the audit database are physically separate. The user_id column carries the integer surrogate key, which is unambiguous over time even if usernames are renamed.

The table is auto-created on first server boot via the same ensureSchema path that creates sftp_audit and sftp_notification_log. No manual migration step is needed when upgrading from an earlier release.

REST API

The Web Admin charts are powered by two endpoints:

GET /api/throughput/timeseries

GET /api/throughput/timeseries?bucket=<bucket>&range=<range>[&user=<username>]

Returns one point per bucket as [{ ts, bytesPerSecUp, bytesPerSecDown }, ...]. Parameters:

GET /api/audit/timeseries

GET /api/audit/timeseries?bucket=<bucket>&range=<range>[&user=<username>]

Returns counts and byte totals per bucket as [{ ts, uploads, downloads, bytesUp, bytesDown, sessions }, ...]. Same parameter semantics as the throughput endpoint. Powers the Upload & Download Activity and Session Activity charts.

Troubleshooting

The Activities tab loads but every chart is empty.

The Bandwidth chart shows data but values look low compared to actual transfer speed.

How do I disable monitoring without editing config?

Add -Dstylus.sftpserver.bandwidth.disabled=true to the JVM command-line in the service definition and restart. The startup log will confirm the override: Bandwidth sampler disabled by -Dstylus.sftpserver.bandwidth.disabled=true.