Compare commits

...
12 Commits
Author SHA1 Message Date
Nick Craig-Wood 48da73ad5a accounting: make --bwlimit set with the rc options/set take effect
Setting the bandwidth limit with options/set changed the option but
never reached the token bucket, so it had no effect, whereas
core/bwlimit worked. Apply the limit when the config is reloaded.

Note that a bandwidth timetable set this way isn't scheduled - the
limit for the current time is applied.
2026-09-21 17:50:43 +01:00
Nick Craig-Wood 3cd41095b4 accounting: fix core/bwlimit ignoring the bandwidth limit toggle
Setting the bandwidth limit while the limits are toggled off with
SIGUSR2 turned them back on immediately, and the next toggle then put
the old limit back.

It also didn't record the limit it set, so SIGUSR2 afterwards said
"no bandwidth limit configured right now" and did nothing.

Set the limit the same way a scheduled --bwlimit change does, which
fixes both.
2026-09-21 17:50:43 +01:00
Nick Craig-Wood 4bd9dd6734 rc: allow _logs to be an object to leave out the unattributed logs
_logs can now be set to an object, e.g.

    {"level": "INFO", "unattributed": false}

Setting unattributed to false leaves out the logs which aren't
attributed to a job, so a client can have just the logs of its own
job. It defaults to true which is what _logs did before.
2026-09-21 17:50:43 +01:00
Nick Craig-Wood 0e3c9bb755 rcd: start the stats logging so --stats works
rcd doesn't run its command through cmd.Run, which is what starts the
periodic stats logging, so --stats had no effect. The serve commands
and mount all log the stats, so make rcd do it too.

This means the stats are available to a client reading the logs over
the rc as well.
2026-09-21 17:50:43 +01:00
Nick Craig-Wood 7e18810db8 rc: add --rc-log-calls to stop logging each rc call and its reply
Every rc call and its reply is logged at DEBUG level, so a client
reading the logs over the rc with core/events or core/log sees the
logs of its own polling, which can drown out everything else.

Setting --rc-log-calls=false stops rclone logging the calls. It can
be changed while rclone is running with options/set.
2026-09-21 17:50:43 +01:00
Nick Craig-Wood 846836adc0 rc: add core/events to stream the logs as they happen
This adds core/events which streams events over a single HTTP
connection as newline delimited JSON, one object per line, each with
a type. Log entries are sent as they are made with type "log" in the
same format as core/log returns, so they can be attributed to the rc
job which made them.

This doesn't need the log buffer to be enabled as the events aren't
stored, but if it is enabled then the since parameter sends the
entries from the buffer first so a client which reconnects doesn't
miss any.

The sequence numbers and the JSON for the log entries are now made
once for the log buffer and the subscribers to share.
2026-09-21 17:50:43 +01:00
Nick Craig-Wood cd54853ac7 rc: return the logs of the job from _logs rather than of everything running
Log entries which rclone can attribute to the rc job which made them
now have jobid and group entries in the JSON log. core/log can filter
on these with the new group and jobid parameters.

_logs and job/status now return the logs of the job itself plus the
logs made while it was running which aren't attributed to any job,
leaving out the logs of other jobs running at the same time. Note
that calls sharing a stats group with _group are attributed
separately as they are different jobs.
2026-09-21 17:50:43 +01:00
Nick Craig-Wood 61ce03ac02 operations, sync, march, walk, list, filter: attribute logs to the rc job
Use the context aware log functions throughout the code which runs
sync, copy, move, check and the other operations, and the directory
listings they do, so that the logs they make, such as the per file
"Copied (new)" logs, are attributed to the rc job and stats group of
the operation.
2026-09-21 17:50:43 +01:00
Nick Craig-Wood 818456978e fs: add context aware log functions so logs can be attributed
This adds ErrorfCtx, LogfCtx, InfofCtx, DebugfCtx and LogPrintfCtx
which take a context and pass it on to the log handler. The handler
can then attribute the log to what made it using the new
StatsGroupFromContext and JobIDFromContext function pointers, which
fs/accounting and fs/rc/jobs fill in.

The format string is no longer assigned to in logPrintf so that go
vet recognises these as printf wrappers and checks the arguments of
their callers.
2026-09-21 17:50:43 +01:00
Nick Craig-Wood 63e3d6f2d7 rc: add _logs parameter to return the logs made during a call
If _logs is set to true or a log level on an rc call then the logs
made while the call was running are returned as _logs in the result,
or in the error response if the call failed. For _async calls the logs
so far are returned as logs in job/status.

This needs the log buffer enabling with --log-buffer-size.

Logs aren't currently attributed to the call which made them so this
returns everything logged while the call was running. Because of this
_logs is refused on an rc server without authentication so the logs
can't be read through the calls which don't need authentication.
2026-09-21 17:50:43 +01:00
Nick Craig-Wood 02b3419f76 rc: add core/log to read the recent logs - enable with --log-buffer-size
This adds the --log-buffer-size flag which keeps the most recent logs
in memory in JSON format, dropping the oldest when it is full.

These can then be read with the new rc call core/log which returns
the log entries from a given sequence number so it can be polled to
follow the log.

The log buffer is disabled by default and can be enabled at runtime
with options/set.
2026-09-21 17:50:43 +01:00
Nick Craig-Wood d148f5c5ed accounting: attach the stats to the log entry even when not using --use-json-log
The stats were only attached to the log entry as a structured field
when --use-json-log was in use. Attach them always so that other JSON
consumers of the log, such as the log buffer, get them too. They are
hidden from the text output so this doesn't change.
2026-09-21 17:50:43 +01:00
46 changed files with 3662 additions and 312 deletions

No files matched your search

+6 -1
View File
@@ -208,7 +208,12 @@ func doCall(ctx context.Context, path string, in rc.Params) (out rc.Params, err
}
_, out, err := jobs.NewJob(ctx, call.Fn, in)
if err != nil {
return errorf(http.StatusInternalServerError, path, "loopback: call failed: %w", err)
out, err = errorf(http.StatusInternalServerError, path, "loopback: call failed: %w", err)
var logsErr *rc.ErrorWithLogs
if errors.As(err, &logsErr) {
out["_logs"] = logsErr.Logs
}
return out, err
}
// Reshape (serialize then deserialize) the data so it is in the form expected
err = rc.Reshape(&out, out)
+4
View File
@@ -49,6 +49,10 @@ See the [rc documentation](/rc/) for more info on the rc flags.
rc.Opt.Files = args[0]
}
// Print the stats periodically if --stats is in use, as
// the serve and mount commands do
defer cmd.StartStats()()
s, err := rcserver.Start(context.Background(), &rc.Opt)
if err != nil {
fs.Fatalf(nil, "Failed to start remote control: %v", err)
+570
View File
@@ -0,0 +1,570 @@
---
title: "WebDAV"
description: "Rclone docs for WebDAV"
versionIntroduced: "v1.39"
---
# WebDAV
Paths are specified as `remote:path`
Paths may be as deep as required, e.g. `remote:directory/subdirectory`.
## Configuration
To configure the WebDAV remote you will need to have a URL for it, and
a username and password. If you know what kind of system you are
connecting to then rclone can enable extra features.
Here is an example of how to make a remote called `remote`. First run:
```console
rclone config
```
This will guide you through an interactive setup process:
```text
No remotes found, make a new one?
n) New remote
s) Set configuration password
q) Quit config
n/s/q> n
name> remote
Type of storage to configure.
Choose a number from below, or type in your own value
[snip]
XX / WebDAV
\ "webdav"
[snip]
Storage> webdav
URL of http host to connect to
Choose a number from below, or type in your own value
1 / Connect to example.com
\ "https://example.com"
url> https://example.com/remote.php/webdav/
Name of the WebDAV site/service/software you are using
Choose a number from below, or type in your own value
1 / Fastmail Files
\ (fastmail)
2 / Nextcloud
\ (nextcloud)
3 / Owncloud
\ (owncloud)
4 / Sharepoint Online, authenticated by Microsoft account
\ (sharepoint)
5 / Sharepoint with NTLM authentication, usually self-hosted or on-premises
\ (sharepoint-ntlm)
6 / rclone WebDAV server to serve a remote over HTTP via the WebDAV protocol
\ (rclone)
7 / Other site/service or software
\ (other)
vendor> 2
User name
user> user
Password.
y) Yes type in my own password
g) Generate random password
n) No leave this optional password blank
y/g/n> y
Enter the password:
password:
Confirm the password:
password:
Bearer token instead of user/pass (e.g. a Macaroon)
bearer_token>
Remote config
Configuration complete.
Options:
- type: webdav
- url: https://example.com/remote.php/webdav/
- vendor: nextcloud
- user: user
- pass: *** ENCRYPTED ***
- bearer_token:
Keep this "remote" remote?
y) Yes this is OK
e) Edit this remote
d) Delete this remote
y/e/d> y
```
Once configured you can then use `rclone` like this (replace `remote` with the
name you gave your remote):
List directories in top level of your WebDAV
```console
rclone lsd remote:
```
List all the files in your WebDAV
```console
rclone ls remote:
```
To copy a local directory to an WebDAV directory called backup
```console
rclone copy /home/source remote:backup
```
### Modification times and hashes
Plain WebDAV does not support modified times. However when used with
Fastmail Files, ownCloud or Nextcloud rclone will support modified times.
Likewise plain WebDAV does not support hashes, however when used with
Fastmail Files, ownCloud or Nextcloud rclone will support SHA1 and MD5 hashes.
Depending on the exact version of ownCloud or Nextcloud hashes may
appear on all objects, or only on objects which had a hash uploaded
with them. With Nextcloud, rclone asks the server to calculate the SHA1
of uploads which had no hash to send, such as streamed uploads, and
after setting the modification time, which discards the stored hash.
<!-- autogenerated options start - DO NOT EDIT - instead edit fs.RegInfo in backend/webdav/webdav.go and run make backenddocs to verify --> <!-- markdownlint-disable-line line-length -->
### Standard options
Here are the Standard options specific to webdav (WebDAV).
#### --webdav-url
URL of http host to connect to.
E.g. https://example.com.
Properties:
- Config: url
- Env Var: RCLONE_WEBDAV_URL
- Type: string
- Required: true
#### --webdav-vendor
Name of the WebDAV site/service/software you are using.
Properties:
- Config: vendor
- Env Var: RCLONE_WEBDAV_VENDOR
- Type: string
- Required: false
- Examples:
- "fastmail"
- Fastmail Files
- "nextcloud"
- Nextcloud
- "owncloud"
- Owncloud 10 PHP based WebDAV server
- "infinitescale"
- ownCloud Infinite Scale
- "sharepoint"
- Sharepoint Online, authenticated by Microsoft account
- "sharepoint-ntlm"
- Sharepoint with NTLM authentication, usually self-hosted or on-premises
- "rclone"
- rclone WebDAV server to serve a remote over HTTP via the WebDAV protocol
- "other"
- Other site/service or software
#### --webdav-user
User name.
In case NTLM authentication is used, the username should be in the format 'Domain\User'.
Properties:
- Config: user
- Env Var: RCLONE_WEBDAV_USER
- Type: string
- Required: false
#### --webdav-pass
Password.
**NB** Input to this must be obscured - see [rclone obscure](/commands/rclone_obscure/).
Properties:
- Config: pass
- Env Var: RCLONE_WEBDAV_PASS
- Type: string
- Required: false
#### --webdav-bearer-token
Bearer token instead of user/pass (e.g. a Macaroon).
Properties:
- Config: bearer_token
- Env Var: RCLONE_WEBDAV_BEARER_TOKEN
- Type: string
- Required: false
### Advanced options
Here are the Advanced options specific to webdav (WebDAV).
#### --webdav-bearer-token-command
Command to run to get a bearer token.
Properties:
- Config: bearer_token_command
- Env Var: RCLONE_WEBDAV_BEARER_TOKEN_COMMAND
- Type: string
- Required: false
#### --webdav-encoding
The encoding for the backend.
See the [encoding section in the overview](/overview/#encoding) for more info.
Default encoding is Slash,LtGt,DoubleQuote,Colon,Question,Asterisk,Pipe,Hash,Percent,BackSlash,Del,Ctl,LeftSpace,LeftTilde,RightSpace,RightPeriod,InvalidUtf8 for sharepoint-ntlm or identity otherwise.
Properties:
- Config: encoding
- Env Var: RCLONE_WEBDAV_ENCODING
- Type: string
- Required: false
#### --webdav-headers
Set HTTP headers for all transactions.
Use this to set additional HTTP headers for all transactions
The input format is comma separated list of key,value pairs. Standard
[CSV encoding](https://godoc.org/encoding/csv) may be used.
For example, to set a Cookie use 'Cookie,name=value', or '"Cookie","name=value"'.
You can set multiple headers, e.g. '"Cookie","name=value","Authorization","xxx"'.
Properties:
- Config: headers
- Env Var: RCLONE_WEBDAV_HEADERS
- Type: CommaSepList
- Default:
#### --webdav-pacer-min-sleep
Minimum time to sleep between API calls.
Properties:
- Config: pacer_min_sleep
- Env Var: RCLONE_WEBDAV_PACER_MIN_SLEEP
- Type: Duration
- Default: 10ms
#### --webdav-nextcloud-chunk-size
Nextcloud upload chunk size.
We recommend configuring your NextCloud instance to increase the max chunk size to 1 GB for better upload performances.
See https://docs.nextcloud.com/server/latest/admin_manual/configuration_files/big_file_upload_configuration.html#adjust-chunk-size-on-nextcloud-side
Set to 0 to disable chunked uploading.
Properties:
- Config: nextcloud_chunk_size
- Env Var: RCLONE_WEBDAV_NEXTCLOUD_CHUNK_SIZE
- Type: SizeSuffix
- Default: 10Mi
#### --webdav-owncloud-exclude-shares
Exclude ownCloud shares
Properties:
- Config: owncloud_exclude_shares
- Env Var: RCLONE_WEBDAV_OWNCLOUD_EXCLUDE_SHARES
- Type: bool
- Default: false
#### --webdav-owncloud-exclude-mounts
Exclude ownCloud mounted storages
Properties:
- Config: owncloud_exclude_mounts
- Env Var: RCLONE_WEBDAV_OWNCLOUD_EXCLUDE_MOUNTS
- Type: bool
- Default: false
#### --webdav-unix-socket
Path to a unix domain socket to dial to, instead of opening a TCP connection directly
Properties:
- Config: unix_socket
- Env Var: RCLONE_WEBDAV_UNIX_SOCKET
- Type: string
- Required: false
#### --webdav-auth-redirect
Preserve authentication on redirect.
If the server redirects rclone to a new domain when it is trying to
read a file then normally rclone will drop the Authorization: header
from the request.
This is standard security practice to avoid sending your credentials
to an unknown webserver.
However this is desirable in some circumstances. If you are getting
an error like "401 Unauthorized" when rclone is attempting to read
files from the webdav server then you can try this option.
Note that enabling this also permits sending your credentials over a
plaintext HTTP connection if the server redirects from HTTPS to HTTP,
which rclone otherwise refuses to do.
Properties:
- Config: auth_redirect
- Env Var: RCLONE_WEBDAV_AUTH_REDIRECT
- Type: bool
- Default: false
#### --webdav-description
Description of the remote.
Properties:
- Config: description
- Env Var: RCLONE_WEBDAV_DESCRIPTION
- Type: string
- Required: false
<!-- autogenerated options stop -->
## Provider notes
See below for notes on specific providers.
### Fastmail Files
Use `https://webdav.fastmail.com/` or a subdirectory as the URL,
and your Fastmail email `username@domain.tld` as the username.
Follow [this documentation](https://www.fastmail.help/hc/en-us/articles/360058752854-App-passwords)
to create an app password with access to `Files (WebDAV)` and use
this as the password.
Fastmail supports modified times using the `X-OC-Mtime` header.
### ownCloud
Click on the settings cog in the bottom right of the page and this
will show the WebDAV URL that rclone needs in the config step. It
will look something like `https://example.com/remote.php/webdav/`.
ownCloud supports modified times using the `X-OC-Mtime` header.
### Nextcloud
This is configured in an identical way to ownCloud. Note that
Nextcloud initially did not support streaming of files (`rcat`) whereas
ownCloud did, but [this](https://github.com/nextcloud/nextcloud-snap/issues/365)
seems to be fixed as of 2020-11-27 (tested with rclone v1.53.1 and Nextcloud
Server v19).
### ownCloud Infinite Scale
The WebDAV URL for Infinite Scale can be found in the details panel of
any space in Infinite Scale, if the display was enabled in the personal
settings of the user through a checkbox there.
Infinite Scale works with the chunking [tus](https://tus.io) upload protocol.
The chunk size is currently fixed 10 MB.
### Sharepoint Online
Rclone can be used with Sharepoint provided by OneDrive for Business
or Office365 Education Accounts.
This feature is only needed for a few of these Accounts,
mostly Office365 Education ones. These accounts are sometimes not
verified by the domain owner [github#1975](https://github.com/rclone/rclone/issues/1975)
This means that these accounts can't be added using the official
API (other Accounts should work with the "onedrive" option). However,
it is possible to access them using webdav.
To use a sharepoint remote with rclone, add it like this:
First, you need to get your remote's URL:
- Go [here](https://onedrive.live.com/about/en-us/signin/)
to open your OneDrive or to sign in
- Now take a look at your address bar, the URL should look like this:
`https://[YOUR-DOMAIN]-my.sharepoint.com/personal/[YOUR-EMAIL]/_layouts/15/onedrive.aspx`
You'll only need this URL up to the email address. After that, you'll
most likely want to add "/Documents". That subdirectory contains
the actual data stored on your OneDrive.
Add the remote to rclone like this:
Configure the `url` as `https://[YOUR-DOMAIN]-my.sharepoint.com/personal/[YOUR-EMAIL]/Documents`
and use your normal account email and password for `user` and `pass`.
If you have 2FA enabled, you have to generate an app password.
Set the `vendor` to `sharepoint`.
Your config file should look like this:
```ini
[sharepoint]
type = webdav
url = https://[YOUR-DOMAIN]-my.sharepoint.com/personal/[YOUR-EMAIL]/Documents
vendor = sharepoint
user = YourEmailAddress
pass = encryptedpassword
```
### Sharepoint with NTLM Authentication
Use this option in case your (hosted) Sharepoint is not tied to OneDrive
accounts and uses NTLM authentication.
To get the `url` configuration, similarly to the above, first navigate to the
desired directory in your browser to get the URL, then strip everything after
the name of the opened directory.
Example:
If the URL is:
<https://example.sharepoint.com/sites/12345/Documents/Forms/AllItems.aspx>
The configuration to use would be:
<https://example.sharepoint.com/sites/12345/Documents>
Set the `vendor` to `sharepoint-ntlm`.
NTLM uses domain and user name combination for authentication,
set `user` to `DOMAIN\username`.
Your config file should look like this:
```ini
[sharepoint]
type = webdav
url = https://[YOUR-DOMAIN]/some-path-to/Documents
vendor = sharepoint-ntlm
user = DOMAIN\user
pass = encryptedpassword
```
#### Required Flags for SharePoint
As SharePoint does some special things with uploaded documents, you won't be
able to use the documents size or the documents hash to compare if a file has
been changed since the upload / which file is newer.
For Rclone calls copying files (especially Office files such as .docx, .xlsx, etc.)
from/to SharePoint (like copy, sync, etc.), you should append these flags to ensure
Rclone uses the "Last Modified" datetime property to compare your documents:
```text
--ignore-size --ignore-checksum --update
```
## Rclone
Use this option if you are hosting remotes over WebDAV provided by rclone.
Read [rclone serve webdav](commands/rclone_serve_webdav/) for more details.
rclone serve supports modified times using the `X-OC-Mtime` header.
### dCache
dCache is a storage system that supports many protocols and
authentication/authorisation schemes. For WebDAV clients, it allows
users to authenticate with username and password (BASIC), X.509,
Kerberos, and various bearer tokens, including
[Macaroons](https://www.dcache.org/manuals/workshop-2017-05-29-Umea/000-Final/anupam_macaroons_v02.pdf)
and [OpenID-Connect](https://en.wikipedia.org/wiki/OpenID_Connect)
access tokens.
Configure as normal using the `other` type. Don't enter a username or
password, instead enter your Macaroon as the `bearer_token`.
The config will end up looking something like this.
```ini
[dcache]
type = webdav
url = https://dcache...
vendor = other
user =
pass =
bearer_token = your-macaroon
```
There is a [script](https://github.com/sara-nl/GridScripts/blob/master/get-macaroon)
that obtains a Macaroon from a dCache WebDAV endpoint, and creates an rclone config
file.
Macaroons may also be obtained from the dCacheView
web-browser/JavaScript client that comes with dCache.
### OpenID-Connect
dCache also supports authenticating with OpenID-Connect access tokens.
OpenID-Connect is a protocol (based on OAuth 2.0) that allows services
to identify users who have authenticated with some central service.
Support for OpenID-Connect in rclone is currently achieved using
another software package called
[oidc-agent](https://github.com/indigo-dc/oidc-agent). This is a
command-line tool that facilitates obtaining an access token. Once
installed and configured, an access token is obtained by running the
`oidc-token` command. The following example shows a (shortened)
access token obtained from the *XDC* OIDC Provider.
```text
paul@celebrimbor:~$ oidc-token XDC
eyJraWQ[...]QFXDt0
paul@celebrimbor:~$
```
**Note** Before the `oidc-token` command will work, the refresh token
must be loaded into the oidc agent. This is done with the `oidc-add`
command (e.g., `oidc-add XDC`). This is typically done once per login
session. Full details on this and how to register oidc-agent with
your OIDC Provider are provided in the [oidc-agent
documentation](https://indigo-dc.gitbooks.io/oidc-agent/).
The rclone `bearer_token_command` configuration option is used to
fetch the access token from oidc-agent.
Configure as a normal WebDAV endpoint, using the 'other' vendor,
leaving the username and password empty. When prompted, choose to
edit the advanced config and enter the command to get a bearer token
(e.g., `oidc-agent XDC`).
The following example config shows a WebDAV endpoint that uses
oidc-agent to supply an access token from the *XDC* OIDC Provider.
```ini
[dcache]
type = webdav
url = https://dcache.example.org/
vendor = other
bearer_token_command = oidc-token XDC
```
+30 -1
View File
@@ -1725,6 +1725,32 @@ will only work well for the bucket based backends (eg s3, b2,
azureblob, swift) but these are the only backends likely to have
millions of entries in a directory.
### --log-buffer-size SizeSuffix
Keep this much of the most recent logs in memory so that they can be
read with the rc call [core/log](/rc/#core-log) or returned from rc
calls with the [_logs](/rc/#logs) parameter. For example
`--log-buffer-size 1M`.
When the log buffer is full the oldest log entries are dropped, and
making it smaller keeps the most recent entries. Note that when a
client is polling the rc the calls it makes are logged too, so size
this for the poll rate as well as the amount of data being
transferred, or turn the logging of the calls off with
[--rc-log-calls=false](/rc/#--rc-log-calls).
This is disabled by default (`--log-buffer-size 0`). Enabling it
causes every log entry to be formatted as JSON as well as its normal
format which will use a little more CPU.
Note that the log buffer only contains logs at the current
`--log-level`.
**Warning**: anyone who can authenticate to the rc can read the log
buffer, including logs made before they connected. If you use `--dump
auth` or `--dump bodies` then the log buffer will contain credentials
and file contents.
### --log-file string
Log all of rclone's output to a file. This is not active by default.
@@ -1878,7 +1904,10 @@ here for clarity.
Completed data transfer logs will have extra `size` information. Logs
which are about a particular object will have `object` and
`objectType` fields also.
`objectType` fields also. Logs which rclone can attribute to the rc job which
made them will have `jobid` and `group` fields with the ID of the job
and the name of its stats group. Note that the text log format has no
equivalent of these fields.
```json
{
+137
View File
@@ -210,6 +210,24 @@ Expire finished async jobs older than DURATION (default 60s).
Interval duration to check for expired async jobs (default 10s).
### --rc-log-calls
By default rclone logs each rc call and its reply at `DEBUG` level.
A client which reads the logs over the rc, with
[core/events](#core-events) or [core/log](#core-log), sees the logs of
its own calls this way, which can drown out everything else. Set this
to false to stop rclone logging the calls.
This can be changed while rclone is running with
[options/set](#options-set):
```console
$ rclone rc options/set --json '{"rc": {"LogCalls": false}}'
```
Default On.
### --rc-no-auth
By default rclone will require authorisation to have been set up on
@@ -562,6 +580,125 @@ $ rclone rc --json '{ "group": "job/1" }' core/stats
}
```
### Returning the logs of a call with _logs = true {#logs}
If `_logs` is set then the logs made while the call was running will
be returned along with its result. This needs the log buffer to be
enabled with [--log-buffer-size](/docs/#log-buffer-size-sizesuffix)
otherwise the call will return an error.
`_logs` can be set to `true` to return all the logs, or to a log level
(e.g. `"INFO"` or `"ERROR"`) to return only the logs at that level or
more severe.
It can also be set to an object to control what is returned:
- `level` - only return the logs at this level or more severe - optional
- `unattributed` - set this to `false` to leave out the logs which
aren't attributed to a job - optional, defaults to `true`
```console
$ rclone rc sync/copy srcFs=/tmp/src dstFs=/tmp/dst \
--json '{"_logs": {"level": "INFO", "unattributed": false}}'
```
The logs are returned as a `_logs` key in the result of the call, or
in the error response if the call failed. It has the following keys:
- `entries` - array of logs, oldest first, in the same format
as returned by [core/log](#core-log).
- `next` - the sequence number after the last entry which can be passed
as `since` to [core/log](#core-log) to read the logs made after the call.
- `lost` - the number of log entries which were made while the call was
running but had been dropped from the log buffer as it was too small.
```console
$ rclone rc sync/copy srcFs=/tmp/src dstFs=/tmp/dst _logs=true
{
"_logs": {
"entries": [
{
"group": "job/1",
"jobid": 1,
"level": "info",
"msg": "Copied (new)",
"object": "file.txt",
"objectType": "*local.Object",
"seq": 2,
"size": 3,
"source": "operations/copy.go:385",
"time": "2026-09-17T12:43:42.736052334+01:00"
}
],
"lost": 0,
"next": 3
}
}
```
If the call was started with `_async` then the logs made so far are
returned as `_logs` in the `output` of [job/status](#job-status), so
the `output` is the same as would have been returned if the call was
made synchronously. This returns at most 1000 entries by default -
pass the `next` value returned as `since` to `job/status` to read
more, or `limit` to change the number of entries returned.
Note that:
- Where rclone knows which rc job made a log entry it records the job's
ID as `jobid` and its stats group as `group` in the entry. This is
the case for the logs made by the sync, copy, move, check and the
other operations, such as the per file `Copied (new)` logs, and for
the directory listings they do.
- Logs from the backends, from the filters, from the retries made by
the rate limiter, and from `sync/bisync` are **not** attributed
yet. Neither are the logs of anything which isn't an rc job, such as
a mount.
- `_logs` returns the entries for the job and the unattributed entries
made **while** the job was running. The unattributed entries may have
come from other jobs, or other rclone activity, running at the same
time - set `unattributed` to `false` to leave them out. Entries
attributed to other jobs are always left out.
- The log buffer only contains logs at the current `--log-level`. This
is currently a global setting so it can't be changed for a call with
`_config`. Use [options/set](#options-set) to change it.
- `_logs` can't be used on an rc server without authentication unless
`--rc-no-auth` is in use.
### Following the logs with core/events {#events}
[core/events](#core-events) streams the logs over a single HTTP
connection as they happen, which avoids polling
[core/log](#core-log). It doesn't need `--log-buffer-size` to be set
as nothing is stored - the logs are sent as they are made.
The response is newline delimited JSON - one object per line, each
with a `type`:
```console
$ curl -u user:pass -N -X POST http://127.0.0.1:5572/core/events
{"type":"start","seq":42}
{"type":"log","seq":42,"time":"2026-09-21T12:16:41.134507+01:00","level":"info","msg":"Copied (new)","object":"file.txt","objectType":"*local.Object","size":3,"source":"operations/copy.go:385","jobid":1,"group":"job/1"}
{"type":"heartbeat","time":"2026-09-21T12:17:11.134507+01:00"}
```
The `log` events are in the same format as the entries returned by
[core/log](#core-log), so they carry `jobid` and `group` where rclone
can attribute them - see [_logs](#logs) for what can be attributed.
The stream can be filtered with `level`, `group`, `jobid` and
`unattributed` as `core/log` is.
If the client doesn't read the stream fast enough then events are
dropped and a `dropped` event says how many. If `--log-buffer-size` is
set then passing `since` with the `seq` of the last entry seen sends
the entries from the buffer first, so a client which reconnects
doesn't miss anything.
Note that every rc call is logged at `DEBUG` level, so a client
streaming at that level sees the logs of its own polling. Use
[--rc-log-calls=false](#--rc-log-calls) to turn that off.
## Data types {#data-types}
When the API returns types, these will mostly be straight forward
+5 -7
View File
@@ -548,14 +548,12 @@ func (s *StatsInfo) Transferred() []TransferSnapshot {
}
// Log outputs the StatsInfo to the log
//
// The stats are attached as a hidden structured field so they appear
// in JSON log output but not in the text.
func (s *StatsInfo) Log() {
if s.ci.UseJSONLog {
out, _ := s.RemoteStats(false)
fs.LogLevelPrintf(s.ci.StatsLogLevel, nil, "%v%v\n", s, fs.LogValueHide("stats", out))
} else {
fs.LogLevelPrintf(s.ci.StatsLogLevel, nil, "%v\n", s)
}
out, _ := s.RemoteStats(false)
fs.LogLevelPrintf(s.ci.StatsLogLevel, nil, "%v%v\n", s, fs.LogValueHide("stats", out))
}
// Bytes updates the stats for bytes bytes
+3
View File
@@ -17,6 +17,9 @@ var groups *statsGroups
func init() {
// Init stats container
groups = newStatsGroups()
// Attribute logs to stats groups
fs.StatsGroupFromContext = StatsGroupFromContext
}
func rcListStats(ctx context.Context, in rc.Params) (rc.Params, error) {
+10
View File
@@ -310,3 +310,13 @@ func TestNewStatsGroupDoesNotStartAverageLoop(t *testing.T) {
assert.False(t, startedAfterDone,
"averageLoop should stop when the last transfer is done")
}
// Check fs can read the stats group to attribute logs to it
func TestFsStatsGroupFromContext(t *testing.T) {
ctx := context.Background()
_, ok := fs.StatsGroupFromContext(ctx)
assert.False(t, ok)
group, ok := fs.StatsGroupFromContext(WithStatsGroup(ctx, "job/1"))
assert.True(t, ok)
assert.Equal(t, "job/1", group)
}
+28
View File
@@ -1,9 +1,12 @@
package accounting
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"testing"
"time"
@@ -512,3 +515,28 @@ func TestRemoveDoneTransfers(t *testing.T) {
assert.Equal(t, transfers, len(s.startedTransfers))
s.mu.Unlock()
}
// Check the log has the stats attached as a structured field for
// JSON output regardless of --use-json-log but that they don't
// appear in the text.
func TestStatsLog(t *testing.T) {
ctx, ci := fs.AddConfig(context.Background())
ci.StatsLogLevel = fs.LogLevelNotice
for _, useJSONLog := range []bool{false, true} {
t.Run(fmt.Sprintf("UseJSONLog=%v", useJSONLog), func(t *testing.T) {
ci.UseJSONLog = useJSONLog
var buf bytes.Buffer
fs.SetLogger(slog.NewJSONHandler(&buf, nil))
defer fs.SetLogger(slog.NewTextHandler(io.Discard, nil))
s := NewStats(ctx)
s.Log()
var out struct {
Msg string `json:"msg"`
Stats map[string]any `json:"stats"`
}
require.NoError(t, json.Unmarshal(buf.Bytes(), &out), buf.String())
assert.Equal(t, s.String()+"\n", out.Msg)
assert.Contains(t, out.Stats, "totalTransfers")
})
}
}
+40 -2
View File
@@ -35,9 +35,30 @@ type tokenBucket struct {
curr buckets
prev buckets
toggledOff bool
started bool
currLimit fs.BwTimeSlot
}
func init() {
// Apply --bwlimit when the config is reloaded, e.g. by the rc
fs.BwLimitReload = TokenBucket.reload
}
// reload applies a changed --bwlimit to the token bucket.
//
// This does nothing before the token bucket is started as
// StartTokenBucket reads the limit itself.
func (tb *tokenBucket) reload(ci *fs.ConfigInfo) error {
limitNow := ci.BwLimit.LimitAt(time.Now())
tb.mu.Lock()
defer tb.mu.Unlock()
if !tb.started || tb.currLimit.Bandwidth == limitNow.Bandwidth {
return nil
}
tb._setBwLimit(limitNow.Bandwidth)
return nil
}
// Return true if limit is disabled
//
// Call with lock held
@@ -111,6 +132,7 @@ func (tb *tokenBucket) StartTokenBucket(ctx context.Context) {
tb.mu.Lock()
defer tb.mu.Unlock()
ci := fs.GetConfig(ctx)
tb.started = true
tb.currLimit = ci.BwLimit.LimitAt(time.Now())
if tb.currLimit.Bandwidth.IsSet() {
tb.curr = newTokenBucket(tb.currLimit.Bandwidth)
@@ -190,13 +212,29 @@ func (tb *tokenBucket) LimitBandwidth(i TokenBucketSlot, n int) {
func (tb *tokenBucket) SetBwLimit(bandwidth fs.BwPair) {
tb.mu.Lock()
defer tb.mu.Unlock()
tb._setBwLimit(bandwidth)
}
// Set the current bandwidth limit
//
// Call with lock held
func (tb *tokenBucket) _setBwLimit(bandwidth fs.BwPair) {
// If the limits are toggled off then the change should only
// become active on the next toggle, which swaps curr and prev
targetBucket := &tb.curr
if tb.toggledOff {
targetBucket = &tb.prev
}
if bandwidth.IsSet() {
tb.curr = newTokenBucket(bandwidth)
*targetBucket = newTokenBucket(bandwidth)
fs.Logf(nil, "Bandwidth limit set to %v", bandwidth)
} else {
tb.curr._setOff()
targetBucket._setOff()
fs.Logf(nil, "Bandwidth limit reset to unlimited")
}
// Record the limit so the SIGUSR2 toggle knows there is one
// and a scheduled change knows what it is replacing
tb.currLimit.Bandwidth = bandwidth
}
// read and set the bandwidth limits
+73
View File
@@ -114,3 +114,76 @@ func TestRcBwLimit(t *testing.T) {
}, out)
}
// Check options/set applies a changed --bwlimit to the token bucket
func TestBwLimitOptionsSet(t *testing.T) {
ctx := context.Background()
ci := fs.GetConfig(ctx)
oldBwLimit, oldStarted, oldCurr, oldCurrLimit := ci.BwLimit, TokenBucket.started, TokenBucket.curr, TokenBucket.currLimit
defer func() {
ci.BwLimit = oldBwLimit
TokenBucket.started, TokenBucket.curr, TokenBucket.currLimit = oldStarted, oldCurr, oldCurrLimit
}()
setBwLimit := func(rate string) {
t.Helper()
call := rc.Calls.Get("options/set")
require.NotNil(t, call)
_, err := call.Fn(ctx, rc.Params{"main": rc.Params{"BwLimit": rate}})
require.NoError(t, err)
}
// Nothing happens before the token bucket is started
TokenBucket.started = false
TokenBucket.curr = buckets{}
setBwLimit("100k")
assert.Nil(t, TokenBucket.curr[TokenBucketSlotAccounting])
// Once started the limit is applied
TokenBucket.started = true
TokenBucket.currLimit = fs.BwTimeSlot{}
setBwLimit("200k")
require.NotNil(t, TokenBucket.curr[TokenBucketSlotTransportTx])
assert.Equal(t, rate.Limit(200*1024), TokenBucket.curr[TokenBucketSlotTransportTx].Limit())
// and changed again
setBwLimit("300k")
assert.Equal(t, rate.Limit(300*1024), TokenBucket.curr[TokenBucketSlotTransportTx].Limit())
// and turned off
setBwLimit("off")
assert.Nil(t, TokenBucket.curr[TokenBucketSlotTransportTx])
}
// Check setting the limit records it and respects the SIGUSR2 toggle
func TestSetBwLimitToggledOff(t *testing.T) {
oldCurr, oldPrev, oldToggledOff, oldCurrLimit := TokenBucket.curr, TokenBucket.prev, TokenBucket.toggledOff, TokenBucket.currLimit
defer func() {
TokenBucket.curr, TokenBucket.prev = oldCurr, oldPrev
TokenBucket.toggledOff, TokenBucket.currLimit = oldToggledOff, oldCurrLimit
}()
TokenBucket.curr, TokenBucket.prev = buckets{}, buckets{}
TokenBucket.toggledOff, TokenBucket.currLimit = false, fs.BwTimeSlot{}
// Normally the limit is set straight away
TokenBucket.SetBwLimit(fs.BwPair{Tx: 1024, Rx: 1024})
require.NotNil(t, TokenBucket.curr[TokenBucketSlotTransportTx])
assert.Equal(t, rate.Limit(1024), TokenBucket.curr[TokenBucketSlotTransportTx].Limit())
// The limit is recorded, otherwise SIGUSR2 ignores it
assert.True(t, TokenBucket.currLimit.Bandwidth.IsSet())
// Toggled off with SIGUSR2 - curr is off and prev holds the limits
TokenBucket.toggledOff = true
TokenBucket.curr, TokenBucket.prev = TokenBucket.prev, TokenBucket.curr
// Setting a limit now must not turn the limits back on
TokenBucket.SetBwLimit(fs.BwPair{Tx: 2048, Rx: 2048})
assert.Nil(t, TokenBucket.curr[TokenBucketSlotTransportTx], "limits toggled off must stay off")
// It becomes active on the next toggle
TokenBucket.toggledOff = false
TokenBucket.curr, TokenBucket.prev = TokenBucket.prev, TokenBucket.curr
require.NotNil(t, TokenBucket.curr[TokenBucketSlotTransportTx])
assert.Equal(t, rate.Limit(2048), TokenBucket.curr[TokenBucketSlotTransportTx].Limit())
}
+24
View File
@@ -43,6 +43,22 @@ var (
// implementation from the fs
CountError = func(ctx context.Context, err error) error { return err }
// StatsGroupFromContext returns the stats group from ctx if set.
//
// This is used to attribute logs to stats groups.
//
// This is a function pointer to decouple the accounting
// implementation from the fs
StatsGroupFromContext = func(ctx context.Context) (string, bool) { return "", false }
// JobIDFromContext returns the ID of the rc job in ctx if set.
//
// This is used to attribute logs to the rc job which made them.
//
// This is a function pointer to decouple the rc jobs
// implementation from the fs
JobIDFromContext = func(ctx context.Context) (int64, bool) { return 0, false }
// ConfigProvider is the config key used for provider options
ConfigProvider = "provider"
@@ -697,6 +713,10 @@ func init() {
// be there but we can't move due to them being visible here in the rc.
var LogReload = func(*ConfigInfo) error { return nil }
// BwLimitReload is written by fs/accounting to apply --bwlimit when
// the config is reloaded, e.g. by the rc options/set.
var BwLimitReload = func(*ConfigInfo) error { return nil }
// Reload assumes the config has been edited and does what is necessary to make it live
func (ci *ConfigInfo) Reload(ctx context.Context) error {
// Set -vv if --dump is in use
@@ -749,6 +769,10 @@ func (ci *ConfigInfo) Reload(ctx context.Context) error {
nonZero(&ci.Transfers)
nonZero(&ci.Checkers)
if err := BwLimitReload(ci); err != nil {
return err
}
return LogReload(ci)
}
+2 -2
View File
@@ -583,7 +583,7 @@ func (f *Filter) IncludeObject(ctx context.Context, o fs.Object) bool {
var err error
metadata, err = fs.GetMetadata(ctx, o)
if err != nil {
fs.Errorf(o, "Failed to read metadata: %v", err)
fs.ErrorfCtx(ctx, o, "Failed to read metadata: %v", err)
metadata = nil
}
@@ -653,7 +653,7 @@ func (f *Filter) MakeListR(ctx context.Context, NewObject func(ctx context.Conte
// Count and log the error but carry on so that one
// unreadable file (e.g. permission denied) doesn't stop
// the other --files-from files being processed.
fs.Errorf(remote, "--files-from failed to read file: %v", err)
fs.ErrorfCtx(ctx, remote, "--files-from failed to read file: %v", err)
_ = fs.CountError(gCtx, err)
} else {
err = callback(entries)
+7 -7
View File
@@ -67,7 +67,7 @@ func DirSorted(ctx context.Context, f fs.Fs, includeAll bool, dir string) (entri
// called.
fi := filter.GetConfig(ctx)
if !includeAll && fi.ListContainsExcludeFile(entries) {
fs.Debugf(dir, "Excluded")
fs.DebugfCtx(ctx, dir, "Excluded")
return nil, nil
}
return filterAndSortDir(ctx, entries, includeAll, dir, fi.IncludeObject, fi.IncludeDirectory(ctx, f))
@@ -113,7 +113,7 @@ func DirSortedFn(ctx context.Context, f fs.Fs, includeAll bool, dir string, call
// starting directory, otherwise ListDirSorted should not be
// called.
if !includeAll && fi.ListContainsExcludeFile(entries) {
fs.Debugf(dir, "Excluded")
fs.DebugfCtx(ctx, dir, "Excluded")
return nil
}
@@ -150,7 +150,7 @@ func filterDir(ctx context.Context, entries fs.DirEntries, includeAll bool, dir
// Make sure we don't delete excluded files if not required
if !includeAll && !IncludeObject(ctx, x) {
ok = false
fs.Debugf(x, "Excluded")
fs.DebugfCtx(ctx, x, "Excluded")
}
case fs.Directory:
if !includeAll {
@@ -160,7 +160,7 @@ func filterDir(ctx context.Context, entries fs.DirEntries, includeAll bool, dir
}
if !include {
ok = false
fs.Debugf(x, "Excluded")
fs.DebugfCtx(ctx, x, "Excluded")
}
}
default:
@@ -173,13 +173,13 @@ func filterDir(ctx context.Context, entries fs.DirEntries, includeAll bool, dir
// ignore
case !strings.HasPrefix(remote, prefix):
ok = false
fs.Errorf(entry, "Entry doesn't belong in directory %q (too short) - ignoring", dir)
fs.ErrorfCtx(ctx, entry, "Entry doesn't belong in directory %q (too short) - ignoring", dir)
case remote == dir:
ok = false
fs.Errorf(entry, "Entry doesn't belong in directory %q (same as directory) - ignoring", dir)
fs.ErrorfCtx(ctx, entry, "Entry doesn't belong in directory %q (same as directory) - ignoring", dir)
case strings.ContainsRune(remote[len(prefix):], '/') && !bucket.IsAllSlashes(remote[len(prefix):]):
ok = false
fs.Errorf(entry, "Entry doesn't belong in directory %q (contains subdir) - ignoring", dir)
fs.ErrorfCtx(ctx, entry, "Entry doesn't belong in directory %q (contains subdir) - ignoring", dir)
default:
// ok
}
+4 -4
View File
@@ -111,7 +111,7 @@ func (ls *Sorter) keyToEntry(ctx context.Context, key string) (entry fs.DirEntry
} else {
obj, err := ls.f.NewObject(ctx, remote)
if err != nil {
fs.Errorf(ls.f, "sorter: failed to re-create object %q: %v", remote, err)
fs.ErrorfCtx(ctx, ls.f, "sorter: failed to re-create object %q: %v", remote, err)
return nil, fmt.Errorf("sorter: failed to re-create object: %w", err)
}
entry = obj
@@ -137,7 +137,7 @@ func (ls *Sorter) sendEntriesToExtSort(entries fs.DirEntries) (err error) {
}
func (ls *Sorter) startExtSort() (err error) {
fs.Logf(ls.f, "Switching to on disk sorting as more than %d entries in one directory detected", ls.cutoff)
fs.LogfCtx(ls.ctx, ls.f, "Switching to on disk sorting as more than %d entries in one directory detected", ls.cutoff)
ls.inputChan = make(chan string, 100)
// Options to control the extsort
opt := extsort.Config{
@@ -171,9 +171,9 @@ func (ls *Sorter) startExtSort() (err error) {
ls.extSort = true
// Send the accumulated entries to the sorter
fs.Debugf(ls.f, "Sending accumulated directory entries to disk")
fs.DebugfCtx(ls.ctx, ls.f, "Sending accumulated directory entries to disk")
err = ls.sendEntriesToExtSort(ls.entries)
fs.Debugf(ls.f, "Done sending accumulated directory entries to disk")
fs.DebugfCtx(ls.ctx, ls.f, "Done sending accumulated directory entries to disk")
clear(ls.entries)
ls.entries = nil
return err
+61 -16
View File
@@ -140,11 +140,16 @@ func LogLevelToSlog(level LogLevel) slog.Level {
return slogLevel
}
func logSlog(level LogLevel, text string, attrs []any) {
logger.Log(context.Background(), LogLevelToSlog(level), text, attrs...)
func logSlog(ctx context.Context, level LogLevel, text string, attrs []any) {
if ctx == nil {
ctx = context.Background()
}
// ctx is passed to the log handler which uses it to attribute
// the log to the rc job and stats group it was made by.
logger.Log(ctx, LogLevelToSlog(level), text, attrs...)
}
func logSlogWithObject(level LogLevel, o any, text string, attrs []any) {
func logSlogWithObject(ctx context.Context, level LogLevel, o any, text string, attrs []any) {
if o != nil {
var object string
switch o.(type) {
@@ -160,24 +165,46 @@ func logSlogWithObject(level LogLevel, o any, text string, attrs []any) {
"objectType", fmt.Sprintf("%T", o),
})
}
logSlog(level, text, attrs)
logSlog(ctx, level, text, attrs)
}
// LogPrint produces a log string from the arguments passed in
func LogPrint(level LogLevel, o any, text string) {
logSlogWithObject(level, o, text, nil)
}
// LogPrintf produces a log string from the arguments passed in
func LogPrintf(level LogLevel, o any, text string, args ...any) {
text = fmt.Sprintf(text, args...)
func logPrintf(ctx context.Context, level LogLevel, o any, text string, args ...any) {
// Note that text isn't assigned to here so go vet can see this
// is a printf wrapper and check the format strings of the
// callers.
msg := fmt.Sprintf(text, args...)
var fields []any
for _, arg := range args {
if item, ok := arg.(LogValueItem); ok {
fields = append(fields, item.key, item.value)
}
}
logSlogWithObject(level, o, text, fields)
logSlogWithObject(ctx, level, o, msg, fields)
}
func logLevelPrintf(ctx context.Context, level LogLevel, o any, text string, args ...any) {
// This uses the global log level, not the one in ctx, as the
// log handler filters on the global log level.
if GetConfig(context.TODO()).LogLevel >= level {
logPrintf(ctx, level, o, text, args...)
}
}
// LogPrint produces a log string from the arguments passed in
func LogPrint(level LogLevel, o any, text string) {
logSlogWithObject(context.Background(), level, o, text, nil)
}
// LogPrintf produces a log string from the arguments passed in
func LogPrintf(level LogLevel, o any, text string, args ...any) {
logPrintf(context.Background(), level, o, text, args...)
}
// LogPrintfCtx produces a log string from the arguments passed in.
//
// The log is attributed to the stats group in ctx, if any.
func LogPrintfCtx(ctx context.Context, level LogLevel, o any, text string, args ...any) {
logPrintf(ctx, level, o, text, args...)
}
// LogLevelPrint writes logs at the given level
@@ -189,9 +216,7 @@ func LogLevelPrint(level LogLevel, o any, text string) {
// LogLevelPrintf writes logs at the given level
func LogLevelPrintf(level LogLevel, o any, text string, args ...any) {
if GetConfig(context.TODO()).LogLevel >= level {
LogPrintf(level, o, text, args...)
}
logLevelPrintf(context.Background(), level, o, text, args...)
}
// Panic writes alert log output for this Object or Fs and calls panic().
@@ -273,6 +298,11 @@ func Errorf(o any, text string, args ...any) {
LogLevelPrintf(LogLevelError, o, text, args...)
}
// ErrorfCtx is Errorf with the log attributed to the stats group in ctx, if any.
func ErrorfCtx(ctx context.Context, o any, text string, args ...any) {
logLevelPrintf(ctx, LogLevelError, o, text, args...)
}
// Print writes log output for this Object or Fs, same as Logf.
func Print(o any, text string) {
LogLevelPrint(LogLevelNotice, o, text)
@@ -301,6 +331,11 @@ func Logf(o any, text string, args ...any) {
LogLevelPrintf(LogLevelNotice, o, text, args...)
}
// LogfCtx is Logf with the log attributed to the stats group in ctx, if any.
func LogfCtx(ctx context.Context, o any, text string, args ...any) {
logLevelPrintf(ctx, LogLevelNotice, o, text, args...)
}
// Infoc writes info on transfers for this Object or Fs. Use this
// level for logging transfers, deletions and things which should
// appear with the -v flag.
@@ -316,6 +351,11 @@ func Infof(o any, text string, args ...any) {
LogLevelPrintf(LogLevelInfo, o, text, args...)
}
// InfofCtx is Infof with the log attributed to the stats group in ctx, if any.
func InfofCtx(ctx context.Context, o any, text string, args ...any) {
logLevelPrintf(ctx, LogLevelInfo, o, text, args...)
}
// Debug writes debugging output for this Object or Fs. Use this for
// debug only. The user must have to specify -vv to see this.
func Debug(o any, text string) {
@@ -328,6 +368,11 @@ func Debugf(o any, text string, args ...any) {
LogLevelPrintf(LogLevelDebug, o, text, args...)
}
// DebugfCtx is Debugf with the log attributed to the stats group in ctx, if any.
func DebugfCtx(ctx context.Context, o any, text string, args ...any) {
logLevelPrintf(ctx, LogLevelDebug, o, text, args...)
}
// LogDirName returns an object for the logger, logging a root
// directory which would normally be "" as the Fs
func LogDirName(f Fs, dir string) any {
+404
View File
@@ -0,0 +1,404 @@
package log
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"math"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"github.com/rclone/rclone/fs"
"github.com/rclone/rclone/fs/rc"
)
// ErrBufferDisabled is returned when the recent logs are needed but
// --log-buffer-size has not been set.
var ErrBufferDisabled = rc.NewErrParamInvalid(errors.New("log buffer not enabled - set --log-buffer-size"))
// Recent holds the most recent log entries if --log-buffer-size is set.
var Recent = &Buffer{}
// Maximum number of entries Get looks at when a limit is in force.
const maxExamine = 100_000
// bufferEntry is a single log entry in the Buffer.
type bufferEntry struct {
seq int64
level slog.Level
attribution Attribution
json json.RawMessage
}
// Filter selects which entries Buffer.Get returns.
//
// Note that the zero value of Level is INFO, so the zero value
// returns the entries at INFO or more severe.
type Filter struct {
Level slog.Level // only return entries at this level or more severe - note the zero value is INFO
JobID int64 // if set, only return entries attributed to this rc job
Group string // if set, only return entries attributed to this stats group
Unattributed bool // if set with JobID or Group, also return entries which aren't attributed
}
// match returns true if an entry at level attributed to attribution
// should be returned.
func (f *Filter) match(level slog.Level, attribution Attribution) bool {
if level < f.Level {
return false
}
if f.JobID == 0 && f.Group == "" {
return true
}
if f.JobID != 0 && attribution.JobID == f.JobID {
return true
}
if f.Group != "" && attribution.Group == f.Group {
return true
}
return f.Unattributed && !attribution.Attributed()
}
// Entries is a list of log entries in JSON format as returned by Buffer.Get.
type Entries []json.RawMessage
// String returns a summary of the entries.
//
// The entries aren't rendered as they are logged when rc replies
// are logged at DEBUG level and that would cause the log to feed
// back into itself.
func (e Entries) String() string {
return fmt.Sprintf("%d log entries", len(e))
}
// Buffer is a size limited in memory store of the most recent log
// entries in JSON format.
//
// Each entry is given a sequence number starting from 0 which
// increases by one for each entry added. This is included in the
// entry as the "seq" field.
type Buffer struct {
mu sync.Mutex
entries []bufferEntry // oldest first in increasing sequence number
size int64 // total size of the JSON in entries
maxSize int64 // drop old entries to keep size <= this - disabled if <= 0
}
// SetSize sets the maximum size in bytes of the log entries kept.
//
// Setting this <= 0 disables the Buffer and discards its entries.
func (b *Buffer) SetSize(maxSize int64) {
b.mu.Lock()
defer b.mu.Unlock()
b.maxSize = maxSize
b.trim()
}
// Enabled returns whether the Buffer is storing log entries.
func (b *Buffer) Enabled() bool {
b.mu.Lock()
defer b.mu.Unlock()
return b.maxSize > 0
}
// Seq returns the sequence number the next log entry will be given.
func (b *Buffer) Seq() int64 {
return nextSeq.Load()
}
// trim drops the oldest entries until the Buffer is within its size.
//
// Call with the mutex held.
func (b *Buffer) trim() {
for len(b.entries) > 0 && b.size > b.maxSize {
b.size -= int64(len(b.entries[0].json))
b.entries[0] = bufferEntry{} // release the memory
b.entries = b.entries[1:]
}
if len(b.entries) == 0 {
b.entries = nil
}
}
// add adds a log entry made by makeEntry to the Buffer.
//
// This is called with the log handler's mutex held so it must not log.
func (b *Buffer) add(seq int64, level slog.Level, attribution Attribution, entry json.RawMessage) {
b.mu.Lock()
defer b.mu.Unlock()
if b.maxSize <= 0 {
return
}
// Don't store an entry bigger than the buffer as it would evict
// everything else - it will show up as lost
if int64(len(entry)) > b.maxSize {
return
}
b.entries = append(b.entries, bufferEntry{seq: seq, level: level, attribution: attribution, json: entry})
b.size += int64(len(entry))
b.trim()
}
// Get returns the entries with sequence numbers from <= seq < to
// which match filter, oldest first.
//
// If limit > 0 then at most limit entries are returned and at most
// maxExamine entries are looked at, so Get may return early with next
// short of to.
//
// It returns next which is the sequence number to pass as from to
// carry on reading where this call finished, and lost which is the
// number of entries in the range which are no longer in the Buffer,
// either because they were dropped to make space or because they
// were too big to store.
func (b *Buffer) Get(from, to int64, filter Filter, limit int) (entries Entries, next, lost int64) {
b.mu.Lock()
defer b.mu.Unlock()
// Sequence numbers are given out for every log entry, whether
// it is stored or not, so use that as the end of the log
end := nextSeq.Load()
to = min(to, end)
from = min(max(from, 0), end)
entries = Entries{}
next = max(to, from)
// Find the first entry with seq >= from
i := sort.Search(len(b.entries), func(i int) bool {
return b.entries[i].seq >= from
})
examined := 0
for ; i < len(b.entries) && b.entries[i].seq < to; i++ {
entry := &b.entries[i]
// Stop when we have enough entries, or have looked at
// enough of them, so this doesn't hold the lock for
// too long when the filter matches very little.
if limit > 0 && (len(entries) >= limit || examined >= maxExamine) {
next = entry.seq
break
}
examined++
if filter.match(entry.level, entry.attribution) {
entries = append(entries, entry.json)
}
}
lost = max(next-from, 0) - int64(examined)
return entries, next, lost
}
// setBufferSize sets the size of Recent from the options.
func setBufferSize() {
Recent.SetSize(int64(Opt.BufferSize))
updateOutput()
}
// nextSeq is the sequence number the next log entry will be given.
var nextSeq atomic.Int64
// makeEntry makes the JSON for a log entry from the JSON formatted
// log text, adding the sequence number to it.
func makeEntry(seq int64, text string) json.RawMessage {
text = strings.TrimSpace(text)
if !(strings.HasPrefix(text, `{"`) && strings.HasSuffix(text, "}")) {
// This shouldn't happen but make sure we always make valid JSON
msg, _ := json.Marshal(text)
text = `{"msg":` + string(msg) + `}`
}
// Build the entry with the sequence number added in a single allocation
const prefix = `{"seq":`
entry := make(json.RawMessage, 0, len(prefix)+20+len(text))
entry = append(entry, prefix...)
entry = strconv.AppendInt(entry, seq, 10)
entry = append(entry, ',')
entry = append(entry, text[1:]...)
return entry
}
// logOutput is the log output which feeds the log buffer and the
// core/events subscribers.
//
// This is called with the log handler's mutex held so it must not log.
func logOutput(r slog.Record, attribution Attribution, text string) {
seq := nextSeq.Add(1) - 1
entry := makeEntry(seq, text)
Recent.add(seq, r.Level, attribution, entry)
publish(r.Level, attribution, entry)
}
// updateOutput connects logOutput to the log handler if the log
// buffer or any subscriber needs it and disconnects it if not.
//
// The output is only connected when needed as it causes every log
// entry to be formatted as JSON.
func updateOutput() {
outputMu.Lock()
defer outputMu.Unlock()
needed := Recent.Enabled() || subscribed()
if needed && outputRemove == nil {
outputRemove = Handler.addRecordOutput(true, logOutput)
} else if !needed && outputRemove != nil {
outputRemove()
outputRemove = nil
}
}
// outputRemove disconnects logOutput from the log handler if not nil.
var (
outputMu sync.Mutex
outputRemove func()
)
func init() {
rc.Add(rc.Call{
Path: "core/log",
Fn: rcLog,
Title: "Returns the most recent log entries.",
Help: strings.ReplaceAll(`
This returns the most recent log entries from the in memory log buffer.
The log buffer must be enabled with the |--log-buffer-size| flag
otherwise this will return an error.
Note that the log buffer only contains logs which were output at the
current |--log-level|.
Parameters:
- since - return entries with sequence numbers starting from this - optional
- Defaults to 0 which returns the oldest entries first.
- If negative, e.g. -N then this starts from the most recent N entries.
- It is an error for this to be beyond the end of the log (which will
happen if rclone has been restarted).
- limit - maximum number of entries to return - optional, defaults to 1000, 0 for no limit
- level - only return entries at this level or more severe, e.g. "INFO" - optional
- group - only return entries attributed to this stats group, e.g. "job/1" - optional
- jobid - only return entries attributed to this rc job, e.g. 1 - optional
Returns:
- entries - array of log entries, oldest first.
- next - pass this as |since| to carry on reading the log from where this call finished.
- lost - the number of entries from |since| onwards which had already been dropped from the log buffer.
Each entry is an object in the same format as used by |--use-json-log|
with an additional |seq| entry which is the sequence number of
the log entry. Sequence numbers start from 0 and increase by one for
each log entry.
Entries which rclone can attribute to the rc job which made them, such
as the per file logs made by a job doing a sync or copy, have a
|jobid| entry with the ID of the job and a |group| entry with the name
of its stats group, e.g. "job/1" or the value of |_group| passed to
the rc call.
Not all logs can be attributed - see the [_logs](/rc/#logs) docs for
which can.
Example:
|||sh
rclone rc core/log since=-2
|||
Returns:
|||json
{
"entries": [
{
"level": "notice",
"msg": "Serving remote control on http://127.0.0.1:5572/",
"seq": 1,
"source": "rcserver/rcserver.go:119",
"time": "2026-09-17T12:39:29.875104149+01:00"
},
{
"level": "info",
"msg": "Copied (new)",
"object": "file.txt",
"objectType": "*local.Object",
"seq": 2,
"size": 3,
"source": "operations/copy.go:385",
"time": "2026-09-17T12:39:29.960848893+01:00"
}
],
"lost": 0,
"next": 3
}
|||
To follow the log, call this repeatedly passing in the |next| value
returned as the |since| parameter. If |lost| is non zero then the log
buffer was too small to hold all the entries since the last call and
that many entries were missed.
Sequence numbers restart from 0 when rclone restarts, but not when the
log buffer is turned off and on again - the entries made while it was
off show up as |lost|.
Note that this filters strictly, so |core/log jobid=N| returns fewer
entries than that job's [_logs](/rc/#logs), which also returns the
entries which aren't attributed to any job.
`, "|", "`"),
})
}
// ParseLevel parses a log level as used in the rc, e.g. "INFO", into a slog.Level.
func ParseLevel(s string) (slog.Level, error) {
var level fs.LogLevel
if err := level.Set(s); err != nil {
return 0, err
}
return fs.LogLevelToSlog(level), nil
}
// rcLog returns the most recent log entries.
func rcLog(ctx context.Context, in rc.Params) (out rc.Params, err error) {
if !Recent.Enabled() {
return nil, ErrBufferDisabled
}
since, err := in.GetInt64("since")
if rc.NotErrParamNotFound(err) {
return nil, err
}
seq := Recent.Seq()
if since < 0 {
since = max(seq+since, 0)
} else if since > seq {
return nil, rc.NewErrParamInvalid(fmt.Errorf("since %d is beyond the end of the log %d - has rclone been restarted?", since, seq))
}
limit, err := in.GetInt64("limit")
if rc.IsErrParamNotFound(err) {
limit = 1000
} else if err != nil {
return nil, err
}
level := slog.LevelDebug
levelString, err := in.GetString("level")
if err == nil {
level, err = ParseLevel(levelString)
if err != nil {
return nil, rc.NewErrParamInvalid(err)
}
} else if rc.NotErrParamNotFound(err) {
return nil, err
}
group, err := in.GetString("group")
if rc.NotErrParamNotFound(err) {
return nil, err
}
jobID, err := in.GetInt64("jobid")
if rc.NotErrParamNotFound(err) {
return nil, err
}
filter := Filter{Level: level, Group: group, JobID: jobID}
entries, next, lost := Recent.Get(since, math.MaxInt64, filter, int(min(limit, math.MaxInt32)))
return rc.Params{
"entries": entries,
"next": next,
"lost": lost,
}, nil
}
+471
View File
@@ -0,0 +1,471 @@
package log
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"math"
"sync"
"testing"
"github.com/rclone/rclone/fs"
"github.com/rclone/rclone/fs/rc"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type testAttributionKey struct{}
// testAttributionCtx is attributed to rc job 1 in stats group "job/1"
var testAttributionCtx = context.WithValue(context.Background(), testAttributionKey{}, Attribution{JobID: 1, Group: "job/1"})
func init() {
// Attribute logs in these tests from the context value above,
// in place of the fs/accounting and fs/rc/jobs implementations.
fs.StatsGroupFromContext = func(ctx context.Context) (string, bool) {
attribution, _ := ctx.Value(testAttributionKey{}).(Attribution)
return attribution.Group, attribution.Group != ""
}
fs.JobIDFromContext = func(ctx context.Context) (int64, bool) {
attribution, _ := ctx.Value(testAttributionKey{}).(Attribution)
return attribution.JobID, attribution.JobID != 0
}
}
// testEntry is a decoded log entry from the Buffer.
type testEntry struct {
Seq int64 `json:"seq"`
Level string `json:"level"`
Msg string `json:"msg"`
}
// decodeEntries decodes entries checking they are all valid JSON.
func decodeEntries(t *testing.T, entries Entries) (out []testEntry) {
t.Helper()
for _, entry := range entries {
var e testEntry
require.NoError(t, json.Unmarshal(entry, &e), string(entry))
out = append(out, e)
}
return out
}
// addEntry adds a log entry in the same format as the JSON log to b.
func addEntry(b *Buffer, level slog.Level, msg string) {
add(b, level, Attribution{}, fmt.Sprintf(`{"level":%q,"msg":%q}`+"\n", slogLevelToString(level), msg))
}
// add adds a log entry to b the way the log output does.
func add(b *Buffer, level slog.Level, attribution Attribution, text string) {
seq := nextSeq.Add(1) - 1
b.add(seq, level, attribution, makeEntry(seq, text))
}
// newTestBuffer makes an empty buffer of size with sequence numbers starting from 0.
func newTestBuffer(size int64) *Buffer {
nextSeq.Store(0)
b := &Buffer{}
b.SetSize(size)
return b
}
func TestBufferDisabled(t *testing.T) {
b := newTestBuffer(0)
assert.False(t, b.Enabled())
addEntry(b, slog.LevelInfo, "dropped")
// The entry used up a sequence number but wasn't stored
assert.Equal(t, int64(1), b.Seq())
entries, next, lost := b.Get(0, math.MaxInt64, Filter{Level: slog.LevelDebug}, 0)
assert.Equal(t, Entries{}, entries)
assert.Equal(t, int64(1), next)
assert.Equal(t, int64(1), lost)
}
func TestBufferGet(t *testing.T) {
b := newTestBuffer(1024)
assert.True(t, b.Enabled())
for i := range 10 {
level := slog.LevelInfo
if i%2 == 1 {
level = slog.LevelError
}
addEntry(b, level, fmt.Sprintf("msg%d", i))
}
assert.Equal(t, int64(10), b.Seq())
for _, test := range []struct {
name string
from, to int64
level slog.Level
limit int
wantSeqs []int64
wantNext int64
wantLost int64
}{
{name: "all", from: 0, to: math.MaxInt64, level: slog.LevelDebug, wantSeqs: []int64{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}, wantNext: 10},
{name: "range", from: 3, to: 6, level: slog.LevelDebug, wantSeqs: []int64{3, 4, 5}, wantNext: 6},
{name: "level", from: 0, to: math.MaxInt64, level: slog.LevelError, wantSeqs: []int64{1, 3, 5, 7, 9}, wantNext: 10},
{name: "limit", from: 2, to: math.MaxInt64, level: slog.LevelDebug, limit: 3, wantSeqs: []int64{2, 3, 4}, wantNext: 5},
{name: "limit and level", from: 0, to: math.MaxInt64, level: slog.LevelError, limit: 2, wantSeqs: []int64{1, 3}, wantNext: 4},
{name: "negative from", from: -5, to: 2, level: slog.LevelDebug, wantSeqs: []int64{0, 1}, wantNext: 2},
{name: "from the end", from: 10, to: math.MaxInt64, level: slog.LevelDebug, wantNext: 10},
{name: "from past the end", from: 100, to: math.MaxInt64, level: slog.LevelDebug, wantNext: 10},
{name: "to before from", from: 5, to: 2, level: slog.LevelDebug, wantNext: 5},
} {
t.Run(test.name, func(t *testing.T) {
entries, next, lost := b.Get(test.from, test.to, Filter{Level: test.level}, test.limit)
var gotSeqs []int64
for _, e := range decodeEntries(t, entries) {
gotSeqs = append(gotSeqs, e.Seq)
assert.Equal(t, fmt.Sprintf("msg%d", e.Seq), e.Msg)
}
assert.Equal(t, test.wantSeqs, gotSeqs)
assert.Equal(t, test.wantNext, next)
assert.Equal(t, test.wantLost, lost)
})
}
}
func TestBufferDropsOldEntries(t *testing.T) {
b := newTestBuffer(1024)
for i := range 100 {
addEntry(b, slog.LevelInfo, fmt.Sprintf("message number %d", i))
}
assert.LessOrEqual(t, b.size, int64(1024))
entries, next, lost := b.Get(0, math.MaxInt64, Filter{Level: slog.LevelDebug}, 0)
decoded := decodeEntries(t, entries)
require.NotEmpty(t, decoded)
assert.Greater(t, lost, int64(0))
assert.Equal(t, lost, decoded[0].Seq, "lost should be the number of entries dropped")
assert.Equal(t, int64(100), next)
assert.Equal(t, int64(99), decoded[len(decoded)-1].Seq)
assert.Equal(t, int(100-lost), len(decoded))
// The window is entirely in the dropped entries
entries, next, gotLost := b.Get(1, 3, Filter{Level: slog.LevelDebug}, 0)
assert.Empty(t, entries)
assert.Equal(t, int64(2), gotLost)
assert.Equal(t, int64(3), next)
// An entry bigger than the Buffer uses a sequence number but
// isn't stored and doesn't evict the other entries
addEntry(b, slog.LevelInfo, string(make([]byte, 2048)))
assert.Equal(t, int64(101), b.Seq())
entries, next, lost = b.Get(0, math.MaxInt64, Filter{Level: slog.LevelDebug}, 0)
assert.Equal(t, len(decoded), len(entries))
assert.Equal(t, int64(101), next)
assert.Equal(t, decoded[0].Seq+1, lost, "dropped entries plus the oversized one")
entries, next, lost = b.Get(100, math.MaxInt64, Filter{Level: slog.LevelDebug}, 0)
assert.Empty(t, entries)
assert.Equal(t, int64(101), next)
assert.Equal(t, int64(1), lost)
// A gap in the middle of the range is counted as lost too
addEntry(b, slog.LevelInfo, "after the gap")
entries, next, lost = b.Get(99, math.MaxInt64, Filter{Level: slog.LevelDebug}, 0)
assert.Len(t, entries, 2)
assert.Equal(t, int64(102), next)
assert.Equal(t, int64(1), lost)
// Limit stops before the gap
entries, next, lost = b.Get(99, math.MaxInt64, Filter{Level: slog.LevelDebug}, 1)
assert.Len(t, entries, 1)
assert.Equal(t, int64(101), next)
assert.Equal(t, int64(1), lost)
// Disabling discards the entries
addEntry(b, slog.LevelInfo, "kept")
b.SetSize(0)
assert.False(t, b.Enabled())
entries, _, _ = b.Get(0, math.MaxInt64, Filter{Level: slog.LevelDebug}, 0)
assert.Empty(t, entries)
assert.Equal(t, int64(103), b.Seq(), "sequence numbers carry on")
}
// Get shouldn't scan the whole buffer when the filter matches very little
func TestBufferGetMaxExamine(t *testing.T) {
b := newTestBuffer(int64(16 * fs.Mebi))
for i := range maxExamine + 10 {
add(b, slog.LevelInfo, Attribution{JobID: 2}, fmt.Sprintf(`{"msg":"msg%d"}`, i))
}
add(b, slog.LevelInfo, Attribution{JobID: 1}, `{"msg":"wanted"}`)
// Nothing matches in the first maxExamine entries so Get stops there
entries, next, lost := b.Get(0, math.MaxInt64, Filter{Level: slog.LevelDebug, JobID: 1}, 10)
assert.Empty(t, entries)
assert.Equal(t, int64(maxExamine), next)
assert.Equal(t, int64(0), lost)
// Carrying on from next finds it
entries, next, _ = b.Get(next, math.MaxInt64, Filter{Level: slog.LevelDebug, JobID: 1}, 10)
decoded := decodeEntries(t, entries)
require.Len(t, decoded, 1)
assert.Equal(t, "wanted", decoded[0].Msg)
assert.Equal(t, int64(maxExamine+11), next)
// With no limit everything is examined
entries, _, _ = b.Get(0, math.MaxInt64, Filter{Level: slog.LevelDebug, JobID: 1}, 0)
assert.Len(t, entries, 1)
}
func TestBufferInvalidJSON(t *testing.T) {
b := newTestBuffer(1024)
for _, text := range []string{
"not \"JSON\"\n",
"control chars \a\x00\xff\U0001F600",
"{",
"{ }",
"{}",
} {
add(b, slog.LevelInfo, Attribution{}, text)
}
entries, _, _ := b.Get(0, math.MaxInt64, Filter{Level: slog.LevelDebug}, 0)
decoded := decodeEntries(t, entries)
require.Len(t, decoded, 5)
assert.Equal(t, `not "JSON"`, decoded[0].Msg)
assert.Equal(t, "control chars \a\x00\ufffd\U0001F600", decoded[1].Msg)
assert.Equal(t, "{", decoded[2].Msg)
assert.Equal(t, "{ }", decoded[3].Msg)
assert.Equal(t, "{}", decoded[4].Msg)
// Check they can all be marshalled
_, err := json.Marshal(entries)
require.NoError(t, err)
}
func TestEntriesString(t *testing.T) {
entries := Entries{json.RawMessage(`{"seq":0}`), json.RawMessage(`{"seq":1}`)}
assert.Equal(t, "2 log entries", entries.String())
// Check that the entries aren't rendered when logging an rc reply
assert.Equal(t, "map[_logs:map[entries:2 log entries]]", fmt.Sprintf("%+v", rc.Params{"_logs": rc.Params{"entries": entries}}))
// but that they marshal as JSON
out, err := json.Marshal(entries)
require.NoError(t, err)
assert.Equal(t, `[{"seq":0},{"seq":1}]`, string(out))
}
// Check setting the option connects and disconnects the buffer
func TestSetBufferSize(t *testing.T) {
oldOpt := Opt.BufferSize
defer func() {
Opt.BufferSize = oldOpt
setBufferSize()
}()
outputs := func() int {
Handler.mu.Lock()
defer Handler.mu.Unlock()
return len(Handler.outputExtra)
}
Opt.BufferSize = 0
setBufferSize()
assert.False(t, Recent.Enabled())
start := outputs()
fs.Errorf(nil, "not buffered")
Opt.BufferSize = 64 * 1024
setBufferSize()
assert.True(t, Recent.Enabled())
assert.Equal(t, start+1, outputs())
seq := Recent.Seq()
fs.Errorf(nil, "buffered")
entries, _, _ := Recent.Get(seq, math.MaxInt64, Filter{Level: slog.LevelDebug}, 0)
decoded := decodeEntries(t, entries)
require.Len(t, decoded, 1)
assert.Equal(t, "buffered", decoded[0].Msg)
// Setting again shouldn't connect again
setBufferSize()
assert.Equal(t, start+1, outputs())
Opt.BufferSize = 0
setBufferSize()
assert.False(t, Recent.Enabled())
assert.Equal(t, start, outputs())
fs.Errorf(nil, "not buffered")
entries, _, _ = Recent.Get(0, math.MaxInt64, Filter{Level: slog.LevelDebug}, 0)
assert.Empty(t, entries)
}
// Check the Buffer works as an output of the handler.
func TestBufferFromHandler(t *testing.T) {
b := newTestBuffer(64 * 1024)
h := NewOutputHandler(io.Discard, nil, logFormatDate|logFormatTime)
h.addRecordOutput(true, func(r slog.Record, attribution Attribution, text string) {
seq := nextSeq.Add(1) - 1
b.add(seq, r.Level, attribution, makeEntry(seq, text))
})
r := slog.NewRecord(t0, fs.SlogLevelNotice, "hello", 0)
r.AddAttrs(slog.String("object", "file.txt"))
require.NoError(t, h.Handle(testAttributionCtx, r))
require.NoError(t, h.Handle(context.Background(), slog.NewRecord(t0, slog.LevelError, "oops", 0)))
entries, next, lost := b.Get(0, math.MaxInt64, Filter{Level: slog.LevelDebug}, 0)
assert.Equal(t, int64(2), next)
assert.Equal(t, int64(0), lost)
decoded := decodeEntries(t, entries)
require.Len(t, decoded, 2)
assert.Equal(t, testEntry{Seq: 0, Level: "notice", Msg: "hello"}, decoded[0])
assert.Equal(t, testEntry{Seq: 1, Level: "error", Msg: "oops"}, decoded[1])
var full map[string]any
require.NoError(t, json.Unmarshal(entries[0], &full))
assert.Equal(t, "file.txt", full["object"])
assert.Equal(t, "job/1", full["group"])
assert.Equal(t, float64(1), full["jobid"])
assert.Contains(t, full, "time")
assert.Contains(t, full, "source")
// Check the group was recorded for filtering
entries, _, _ = b.Get(0, math.MaxInt64, Filter{Level: slog.LevelDebug, Group: "job/1"}, 0)
decoded = decodeEntries(t, entries)
require.Len(t, decoded, 1)
assert.Equal(t, "hello", decoded[0].Msg)
}
func TestBufferGetGroup(t *testing.T) {
b := newTestBuffer(1024)
for i, jobID := range []int64{0, 1, 2, 1, 0, 2} {
attribution := Attribution{}
if jobID != 0 {
attribution = Attribution{JobID: jobID, Group: fmt.Sprintf("job/%d", jobID)}
}
add(b, slog.LevelInfo, attribution, fmt.Sprintf(`{"msg":"msg%d"}`, i))
}
add(b, slog.LevelDebug, Attribution{JobID: 1, Group: "job/1"}, `{"msg":"msg6"}`)
for _, test := range []struct {
name string
filter Filter
want []int64
}{
{"all", Filter{Level: slog.LevelDebug}, []int64{0, 1, 2, 3, 4, 5, 6}},
{"group", Filter{Level: slog.LevelDebug, Group: "job/1"}, []int64{1, 3, 6}},
{"group and unattributed", Filter{Level: slog.LevelDebug, Group: "job/1", Unattributed: true}, []int64{0, 1, 3, 4, 6}},
{"group and level", Filter{Level: slog.LevelInfo, Group: "job/1"}, []int64{1, 3}},
{"unknown group", Filter{Level: slog.LevelDebug, Group: "job/3"}, nil},
{"unknown group and unattributed", Filter{Level: slog.LevelDebug, Group: "job/3", Unattributed: true}, []int64{0, 4}},
{"job", Filter{Level: slog.LevelDebug, JobID: 1}, []int64{1, 3, 6}},
{"job and unattributed", Filter{Level: slog.LevelDebug, JobID: 2, Unattributed: true}, []int64{0, 2, 4, 5}},
{"job or group", Filter{Level: slog.LevelDebug, JobID: 2, Group: "job/1"}, []int64{1, 2, 3, 5, 6}},
} {
t.Run(test.name, func(t *testing.T) {
entries, next, lost := b.Get(0, math.MaxInt64, test.filter, 0)
var got []int64
for _, e := range decodeEntries(t, entries) {
got = append(got, e.Seq)
}
assert.Equal(t, test.want, got)
assert.Equal(t, int64(7), next)
assert.Equal(t, int64(0), lost, "filtered entries aren't lost")
})
}
}
func TestBufferConcurrency(t *testing.T) {
b := newTestBuffer(4096)
var wg sync.WaitGroup
for range 4 {
wg.Go(func() {
for i := range 500 {
addEntry(b, slog.LevelInfo, fmt.Sprintf("msg%d", i))
}
})
}
wg.Go(func() {
var from int64
for range 500 {
var entries []json.RawMessage
entries, from, _ = b.Get(from, math.MaxInt64, Filter{Level: slog.LevelDebug}, 10)
_ = decodeEntries(t, entries)
_ = b.Seq()
}
})
wg.Wait()
assert.Equal(t, int64(2000), b.Seq())
}
func TestRcLog(t *testing.T) {
call := rc.Calls.Get("core/log")
require.NotNil(t, call)
ctx := context.Background()
oldRecent := Recent
defer func() { Recent = oldRecent }()
Recent = newTestBuffer(0)
_, err := call.Fn(ctx, rc.Params{})
assert.Equal(t, ErrBufferDisabled, err)
assert.True(t, rc.IsErrParamInvalid(err), err)
Recent = newTestBuffer(64 * 1024)
for i := range 10 {
level := slog.LevelInfo
if i%2 == 1 {
level = slog.LevelError
}
addEntry(Recent, level, fmt.Sprintf("msg%d", i))
}
seqs := func(out rc.Params) (seqs []int64) {
for _, e := range decodeEntries(t, out["entries"].(Entries)) {
seqs = append(seqs, e.Seq)
}
return seqs
}
out, err := call.Fn(ctx, rc.Params{})
require.NoError(t, err)
assert.Equal(t, []int64{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}, seqs(out))
assert.Equal(t, int64(10), out["next"])
assert.Equal(t, int64(0), out["lost"])
out, err = call.Fn(ctx, rc.Params{"since": 4, "limit": 2})
require.NoError(t, err)
assert.Equal(t, []int64{4, 5}, seqs(out))
assert.Equal(t, int64(6), out["next"])
out, err = call.Fn(ctx, rc.Params{"since": -3})
require.NoError(t, err)
assert.Equal(t, []int64{7, 8, 9}, seqs(out))
out, err = call.Fn(ctx, rc.Params{"since": -100, "level": "error"})
require.NoError(t, err)
assert.Equal(t, []int64{1, 3, 5, 7, 9}, seqs(out))
_, err = call.Fn(ctx, rc.Params{"level": "potato"})
require.Error(t, err)
assert.True(t, rc.IsErrParamInvalid(err), err)
// since at the end is OK but beyond the end is an error
out, err = call.Fn(ctx, rc.Params{"since": 10})
require.NoError(t, err)
assert.Empty(t, out["entries"])
assert.Equal(t, int64(10), out["next"])
_, err = call.Fn(ctx, rc.Params{"since": 11})
require.Error(t, err)
assert.True(t, rc.IsErrParamInvalid(err), err)
assert.Contains(t, err.Error(), "beyond the end")
// Check the output can be serialized
out, err = call.Fn(ctx, rc.Params{"since": 9})
require.NoError(t, err)
var buf []byte
buf, err = json.Marshal(out)
require.NoError(t, err)
assert.JSONEq(t, `{"entries":[{"seq":9,"level":"ERROR","msg":"msg9"}],"next":10,"lost":0}`, string(buf))
// Filter by group
addEntry(Recent, slog.LevelInfo, "not in a group")
add(Recent, slog.LevelInfo, Attribution{JobID: 1, Group: "job/1"}, `{"msg":"in job/1"}`)
out, err = call.Fn(ctx, rc.Params{"since": 10, "group": "job/1"})
require.NoError(t, err)
assert.Equal(t, []int64{11}, seqs(out))
assert.Equal(t, int64(12), out["next"])
// Filter by rc job
out, err = call.Fn(ctx, rc.Params{"since": 10, "jobid": 1})
require.NoError(t, err)
assert.Equal(t, []int64{11}, seqs(out))
}
+344
View File
@@ -0,0 +1,344 @@
package log
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/rclone/rclone/fs"
"github.com/rclone/rclone/fs/rc"
)
// Number of frames buffered for each subscriber before they get dropped.
const subscriberBacklog = 1024
// newline ends each frame in the stream
var newline = []byte{'\n'}
// subscriber receives log entries as they are made.
type subscriber struct {
filter Filter
frames chan json.RawMessage
mu sync.Mutex
dropped int64 // frames dropped because frames was full
}
// subscribers receiving log entries
var (
subscribersMu sync.Mutex
subscribers = map[*subscriber]struct{}{}
)
// subscribed returns whether anything is subscribed to the log.
func subscribed() bool {
subscribersMu.Lock()
defer subscribersMu.Unlock()
return len(subscribers) > 0
}
// subscribe returns a subscriber receiving the log entries which
// match filter and a function to unsubscribe it again.
func subscribe(filter Filter) (*subscriber, func()) {
s := &subscriber{
filter: filter,
frames: make(chan json.RawMessage, subscriberBacklog),
}
subscribersMu.Lock()
subscribers[s] = struct{}{}
subscribersMu.Unlock()
updateOutput()
return s, func() {
subscribersMu.Lock()
delete(subscribers, s)
subscribersMu.Unlock()
updateOutput()
}
}
// takeDropped returns the number of frames dropped since it was last called.
func (s *subscriber) takeDropped() int64 {
s.mu.Lock()
defer s.mu.Unlock()
dropped := s.dropped
s.dropped = 0
return dropped
}
// send queues frame for the subscriber, dropping it if it isn't
// keeping up. It never blocks as it is called while logging.
//
// Any frames dropped earlier are reported first so the client is
// told where the gap in the stream is.
func (s *subscriber) send(frame json.RawMessage) {
s.mu.Lock()
defer s.mu.Unlock()
if s.dropped > 0 {
select {
case s.frames <- droppedFrame(s.dropped):
s.dropped = 0
default:
s.dropped++
return
}
}
select {
case s.frames <- frame:
default:
s.dropped++
}
}
// logFrame makes the frame sent to the subscribers for a log entry.
func logFrame(entry json.RawMessage) json.RawMessage {
const prefix = `{"type":"log",`
frame := make(json.RawMessage, 0, len(prefix)+len(entry))
frame = append(frame, prefix...)
frame = append(frame, entry[1:]...)
return frame
}
// publish sends a log entry to the subscribers which want it.
//
// This is called with the log handler's mutex held so it must not log
// or block.
func publish(level slog.Level, attribution Attribution, entry json.RawMessage) {
subscribersMu.Lock()
defer subscribersMu.Unlock()
var frame json.RawMessage
for s := range subscribers {
if !s.filter.match(level, attribution) {
continue
}
if frame == nil {
frame = logFrame(entry)
}
s.send(frame)
}
}
func init() {
rc.Add(rc.Call{
Path: "core/events",
Fn: rcEvents,
NeedsResponse: true,
WritesResponse: true,
Title: "Streams events, such as the logs, as they happen.",
Help: strings.ReplaceAll(`
This streams events as they happen until the connection is closed. It
is only available over HTTP.
Events are returned as newline delimited JSON objects, one per line,
each with a |type| saying what it is. The types are:
- |start| - sent when the stream starts
- seq - the sequence number the next log entry will be given
- jobid - the rc job of the stream, which [job/stop](#job-stop) stops
- |log| - a log entry in the format used by [core/log](#core-log)
- |dropped| - sent if the client isn't reading the stream fast enough
- count - the number of events dropped
- |heartbeat| - sent when the stream is idle so it doesn't time out
Unlike [core/log](#core-log) this doesn't need |--log-buffer-size| to
be set as the events are not stored - they are sent as they happen.
Parameters:
- level - only stream logs at this level or more severe, e.g. "INFO" - optional
- group - only stream logs attributed to this stats group, e.g. "job/1" - optional
- jobid - only stream logs attributed to this rc job, e.g. 1 - optional
- unattributed - if set with group or jobid, also stream the logs which
aren't attributed to a job - optional
- since - send the entries from this sequence number before streaming
the new ones - optional
- This needs the log buffer to be enabled with |--log-buffer-size|.
- Use it with the |seq| from the last entry seen to carry on where a
previous stream left off.
- heartbeat - seconds between heartbeats, 0 to disable - optional, defaults to 30
Example:
|||sh
curl -u user:pass -N -X POST http://127.0.0.1:5572/core/events
|||
Returns
|||json
{"type":"start","seq":42}
{"type":"log","seq":42,"time":"2026-09-21T12:16:41.134507+01:00","level":"info","msg":"Copied (new)","object":"file.txt","objectType":"*local.Object","size":3,"source":"operations/copy.go:385","jobid":1,"group":"job/1"}
{"type":"heartbeat","time":"2026-09-21T12:17:11.134507+01:00"}
|||
Note that the rc calls made by the client reading the stream are
logged at DEBUG level like any other, so a client streaming at that
level sees its own polling. Use |--rc-log-calls=false| to turn that
off.
`, "|", "`"),
})
}
// rcEvents streams the events to the HTTP response until the
// connection is closed.
func rcEvents(ctx context.Context, in rc.Params) (out rc.Params, err error) {
w, err := in.GetHTTPResponseWriter()
if err != nil {
return nil, fmt.Errorf("response object is required: %w", err)
}
filter, err := eventsFilter(in)
if err != nil {
return nil, err
}
since, err := in.GetInt64("since")
if rc.NotErrParamNotFound(err) {
return nil, err
}
replay := err == nil
if replay && !Recent.Enabled() {
return nil, ErrBufferDisabled
}
heartbeat := 30 * time.Second
if seconds, err := in.GetInt64("heartbeat"); err == nil {
heartbeat = time.Duration(seconds) * time.Second
} else if rc.NotErrParamNotFound(err) {
return nil, err
}
// Subscribe before reading the buffer so no entry is missed
// between the two - the ones sent from the buffer are skipped
// from the stream below.
s, unsubscribe := subscribe(filter)
defer unsubscribe()
startSeq := Recent.Seq()
// The write deadline is cleared as this runs until the client
// goes away, then set for each write so a stuck client doesn't
// block forever.
rcw := http.NewResponseController(w)
if err := rcw.SetWriteDeadline(time.Time{}); err != nil {
return nil, fmt.Errorf("can't stream events: %w", err)
}
w.Header().Set("Content-Type", "application/x-ndjson")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(http.StatusOK)
// The frames are shared between the subscribers so they must
// not be written to - the newline goes in a separate write.
write := func(frame json.RawMessage) error {
if err := rcw.SetWriteDeadline(time.Now().Add(time.Minute)); err != nil {
return err
}
if _, err := w.Write(frame); err != nil {
return err
}
if _, err := w.Write(newline); err != nil {
return err
}
return rcw.Flush()
}
start := `{"type":"start","seq":` + strconv.FormatInt(startSeq, 10)
if jobID, ok := fs.JobIDFromContext(ctx); ok {
start += `,"jobid":` + strconv.FormatInt(jobID, 10)
}
if err := write(json.RawMessage(start + `}`)); err != nil {
return nil, nil
}
if replay {
entries, _, lost := Recent.Get(since, startSeq, filter, 0)
if lost > 0 {
if err := write(droppedFrame(lost)); err != nil {
return nil, nil
}
}
for _, entry := range entries {
if err := write(logFrame(entry)); err != nil {
return nil, nil
}
}
}
var ticker *time.Ticker
tick := make(<-chan time.Time)
if heartbeat > 0 {
ticker = time.NewTicker(heartbeat)
defer ticker.Stop()
tick = ticker.C
}
for {
select {
case <-ctx.Done():
return nil, nil
case frame := <-s.frames:
// Skip the entries already sent from the buffer
if replay && frameSeq(frame) < startSeq {
continue
}
if err := write(frame); err != nil {
return nil, nil
}
case now := <-tick:
// Report any drops which the log going quiet left
// unreported
if dropped := s.takeDropped(); dropped > 0 {
if err := write(droppedFrame(dropped)); err != nil {
return nil, nil
}
}
frame := `{"type":"heartbeat","time":` + strconv.Quote(now.Format(time.RFC3339Nano)) + `}`
if err := write(json.RawMessage(frame)); err != nil {
return nil, nil
}
}
}
}
// droppedFrame makes a frame saying count events were dropped.
func droppedFrame(count int64) json.RawMessage {
return json.RawMessage(`{"type":"dropped","count":` + strconv.FormatInt(count, 10) + `}`)
}
// frameSeq returns the sequence number of a log frame or -1 if it doesn't have one.
func frameSeq(frame json.RawMessage) int64 {
var entry struct {
Seq *int64 `json:"seq"`
}
if err := json.Unmarshal(frame, &entry); err != nil || entry.Seq == nil {
return -1
}
return *entry.Seq
}
// eventsFilter reads the log filter from the parameters.
func eventsFilter(in rc.Params) (filter Filter, err error) {
filter.Level = slog.LevelDebug
levelString, err := in.GetString("level")
if err == nil {
filter.Level, err = ParseLevel(levelString)
if err != nil {
return filter, rc.NewErrParamInvalid(err)
}
} else if rc.NotErrParamNotFound(err) {
return filter, err
}
filter.Group, err = in.GetString("group")
if rc.NotErrParamNotFound(err) {
return filter, err
}
filter.JobID, err = in.GetInt64("jobid")
if rc.NotErrParamNotFound(err) {
return filter, err
}
filter.Unattributed, err = in.GetBool("unattributed")
if rc.NotErrParamNotFound(err) {
return filter, err
}
return filter, nil
}
+266
View File
@@ -0,0 +1,266 @@
package log
import (
"bufio"
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/rclone/rclone/fs"
"github.com/rclone/rclone/fs/rc"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// frame is a decoded event from the stream.
type frame struct {
Type string `json:"type"`
Seq int64 `json:"seq"`
Msg string `json:"msg"`
JobID int64 `json:"jobid"`
Count int64 `json:"count"`
}
// eventServer serves core/events and returns a function to read the
// frames from it one at a time and a function to stop it.
func eventServer(t *testing.T, in rc.Params) (next func() frame, stop func()) {
call := rc.Calls.Get("core/events")
require.NotNil(t, call)
assert.True(t, call.NeedsResponse)
assert.True(t, call.WritesResponse)
done := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
params := rc.Params{"_response": w}
for k, v := range in {
params[k] = v
}
_, err := call.Fn(r.Context(), params)
assert.NoError(t, err)
close(done)
}))
resp, err := http.Get(server.URL)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, "application/x-ndjson", resp.Header.Get("Content-Type"))
scanner := bufio.NewScanner(resp.Body)
next = func() frame {
t.Helper()
require.True(t, scanner.Scan(), "no more frames: %v", scanner.Err())
var f frame
require.NoError(t, json.Unmarshal(scanner.Bytes(), &f), scanner.Text())
return f
}
stop = func() {
_ = resp.Body.Close()
server.CloseClientConnections()
server.Close()
select {
case <-done:
case <-time.After(30 * time.Second):
t.Error("core/events didn't return after the client went away")
}
}
return next, stop
}
// logAt logs msg via the real log handler so it goes through the
// output to the subscribers.
func logAt(ctx context.Context, level fs.LogLevel, msg string) {
fs.LogPrintfCtx(ctx, level, nil, "%s", msg)
}
func TestRcEvents(t *testing.T) {
ctx := context.Background()
setTestLogLevel(t, fs.LogLevelInfo)
next, stop := eventServer(t, rc.Params{"heartbeat": 0})
defer stop()
start := next()
assert.Equal(t, "start", start.Type)
logAt(ctx, fs.LogLevelNotice, "first")
f := next()
assert.Equal(t, "log", f.Type)
assert.Equal(t, "first", f.Msg)
assert.Equal(t, start.Seq, f.Seq, "the stream starts at the sequence number in the start frame")
logAt(testAttributionCtx, fs.LogLevelNotice, "attributed")
f = next()
assert.Equal(t, "attributed", f.Msg)
assert.Equal(t, int64(1), f.JobID)
// Logs below the requested level aren't sent
logAt(ctx, fs.LogLevelDebug, "debug")
logAt(ctx, fs.LogLevelNotice, "second")
f = next()
assert.Equal(t, "second", f.Msg)
}
func TestRcEventsFilter(t *testing.T) {
ctx := context.Background()
setTestLogLevel(t, fs.LogLevelInfo)
next, stop := eventServer(t, rc.Params{"heartbeat": 0, "jobid": 1})
defer stop()
assert.Equal(t, "start", next().Type)
// Only the logs of job 1 are sent
logAt(ctx, fs.LogLevelNotice, "unattributed")
logAt(testAttributionCtx, fs.LogLevelNotice, "job 1")
f := next()
assert.Equal(t, "job 1", f.Msg)
assert.Equal(t, int64(1), f.JobID)
}
func TestRcEventsHeartbeat(t *testing.T) {
setTestLogLevel(t, fs.LogLevelInfo)
next, stop := eventServer(t, rc.Params{"heartbeat": 1})
defer stop()
assert.Equal(t, "start", next().Type)
assert.Equal(t, "heartbeat", next().Type)
}
func TestRcEventsSince(t *testing.T) {
ctx := context.Background()
setTestLogLevel(t, fs.LogLevelInfo)
// Needs the log buffer for the replay
_, err := rc.Calls.Get("core/events").Fn(ctx, rc.Params{"_response": httptest.NewRecorder(), "since": 0})
assert.Equal(t, ErrBufferDisabled, err)
oldRecent, oldSize := Recent, Opt.BufferSize
t.Cleanup(func() {
Recent, Opt.BufferSize = oldRecent, oldSize
setBufferSize()
})
Recent = newTestBuffer(0)
Opt.BufferSize = 64 * fs.Kibi
setBufferSize()
logAt(ctx, fs.LogLevelNotice, "before the stream")
since := Recent.Seq() - 1
next, stop := eventServer(t, rc.Params{"heartbeat": 0, "since": since})
defer stop()
assert.Equal(t, "start", next().Type)
// The entry from before the stream started is replayed first
f := next()
assert.Equal(t, "log", f.Type)
assert.Equal(t, "before the stream", f.Msg)
assert.Equal(t, since, f.Seq)
// then the new ones follow with no repeats
logAt(ctx, fs.LogLevelNotice, "during the stream")
f = next()
assert.Equal(t, "during the stream", f.Msg)
assert.Equal(t, since+1, f.Seq)
}
// Check a subscriber which isn't reading gets told what it missed
func TestRcEventsDropped(t *testing.T) {
ctx := context.Background()
setTestLogLevel(t, fs.LogLevelInfo)
s, unsubscribe := subscribe(Filter{Level: slog.LevelDebug})
defer unsubscribe()
for i := range subscriberBacklog + 10 {
logAt(ctx, fs.LogLevelNotice, fmt.Sprintf("msg%d", i))
}
assert.Equal(t, subscriberBacklog, len(s.frames))
assert.Equal(t, int64(10), s.dropped)
// Read the backlog - the dropped marker comes after it, where
// the gap in the stream is, and not before
var msgs []string
var dropped int64
for range subscriberBacklog {
var f frame
require.NoError(t, json.Unmarshal(<-s.frames, &f))
if f.Type == "dropped" {
dropped = f.Count
} else {
msgs = append(msgs, f.Msg)
}
}
assert.Equal(t, int64(0), dropped, "nothing is reported before the backlog is read")
assert.Equal(t, "msg0", msgs[0])
// The marker is queued as soon as there is room for it
logAt(ctx, fs.LogLevelNotice, "after the gap")
var f frame
require.NoError(t, json.Unmarshal(<-s.frames, &f))
assert.Equal(t, "dropped", f.Type)
assert.Equal(t, int64(10), f.Count)
require.NoError(t, json.Unmarshal(<-s.frames, &f))
assert.Equal(t, "after the gap", f.Msg)
assert.Equal(t, int64(0), s.takeDropped())
}
// Two streams must not write to each other's frames
func TestRcEventsTwoStreams(t *testing.T) {
ctx := context.Background()
setTestLogLevel(t, fs.LogLevelInfo)
next1, stop1 := eventServer(t, rc.Params{"heartbeat": 0})
defer stop1()
next2, stop2 := eventServer(t, rc.Params{"heartbeat": 0})
defer stop2()
assert.Equal(t, "start", next1().Type)
assert.Equal(t, "start", next2().Type)
var wg sync.WaitGroup
for _, next := range []func() frame{next1, next2} {
wg.Go(func() {
for range 100 {
f := next()
assert.Equal(t, "log", f.Type)
}
})
}
for i := range 100 {
logAt(ctx, fs.LogLevelNotice, fmt.Sprintf("msg%d", i))
}
wg.Wait()
}
// Check the log output is only connected while it is needed
func TestSubscribeConnectsOutput(t *testing.T) {
outputs := func() int {
Handler.mu.Lock()
defer Handler.mu.Unlock()
return len(Handler.outputExtra)
}
before := outputs()
s1, unsubscribe1 := subscribe(Filter{Level: slog.LevelDebug})
assert.Equal(t, before+1, outputs())
_, unsubscribe2 := subscribe(Filter{Level: slog.LevelDebug})
assert.Equal(t, before+1, outputs(), "one output serves all the subscribers")
unsubscribe1()
assert.Equal(t, before+1, outputs())
unsubscribe2()
assert.Equal(t, before, outputs())
assert.Empty(t, s1.frames)
}
// setTestLogLevel sets the log level for the duration of the test
func setTestLogLevel(t *testing.T, level fs.LogLevel) {
ci := fs.GetConfig(context.Background())
oldLevel := ci.LogLevel
oldSlogLevel := Handler.SetLevel(fs.LogLevelToSlog(level))
ci.LogLevel = level
t.Cleanup(func() {
ci.LogLevel = oldLevel
Handler.SetLevel(oldSlogLevel)
})
}
+13 -1
View File
@@ -47,6 +47,11 @@ var OptionsInfo = fs.Options{{
Default: logFormatDate | logFormatTime,
Help: "Comma separated list of log format options",
Groups: "Logging",
}, {
Name: "log_buffer_size",
Default: fs.SizeSuffix(0),
Help: "Keep this much of the most recent logs in memory for reading with the rc (0 to disable)",
Groups: "Logging,RC",
}, {
Name: "syslog",
Default: false,
@@ -83,6 +88,7 @@ type Options struct {
MaxAge fs.Duration `config:"log_file_max_age"` // Max age of log file
Compress bool `config:"log_file_compress"` // Set to compress log file
Format logFormat `config:"log_format"` // Comma separated list of log format options
BufferSize fs.SizeSuffix `config:"log_buffer_size"` // Size of the in memory buffer of recent logs
UseSyslog bool `config:"syslog"` // Use Syslog for logging
SyslogFacility string `config:"syslog_facility"` // Facility for syslog, e.g. KERN,USER,...
LogSystemdSupport bool `config:"log_systemd"` // set if using systemd logging
@@ -90,7 +96,13 @@ type Options struct {
}
func init() {
fs.RegisterGlobalOptions(fs.OptionsInfo{Name: "log", Opt: &Opt, Options: OptionsInfo})
fs.RegisterGlobalOptions(fs.OptionsInfo{Name: "log", Opt: &Opt, Options: OptionsInfo, Reload: optReload})
}
// optReload makes the options which can be changed at runtime live.
func optReload(ctx context.Context) error {
setBufferSize()
return nil
}
// Opt is the options for the logger
+71 -9
View File
@@ -10,6 +10,7 @@ import (
"log/slog"
"os"
"runtime"
"slices"
"strings"
"sync"
"time"
@@ -139,9 +140,9 @@ type OutputHandler struct {
levelVar slog.LevelVar
writer io.Writer
mu sync.Mutex
output []outputFn // log to writer if empty or the last item
outputExtra []outputExtra // log to all these additional places
format logFormat // protected by mu
output []outputFn // log to writer if empty or the last item
outputExtra []*outputExtra // log to all these additional places
format logFormat // protected by mu
jsonBuf bytes.Buffer
jsonHandler *slog.JSONHandler
}
@@ -149,7 +150,37 @@ type OutputHandler struct {
// Records the type and function pointer for extra logging output.
type outputExtra struct {
json bool
output outputFn
output outputRecordFn
}
// Define the type of an extra logging output which is passed the
// record and what the log is attributed to
type outputRecordFn func(r slog.Record, attribution Attribution, text string)
// Attribution records what made a log entry.
//
// Logs made with the context aware log functions, e.g. fs.DebugfCtx,
// are attributed to the rc job and stats group in their context.
type Attribution struct {
JobID int64 // ID of the rc job or 0 if not from an rc job
Group string // stats group or "" if not in a stats group
}
// Attributed returns whether the log was attributed to anything.
func (a Attribution) Attributed() bool {
return a.JobID != 0 || a.Group != ""
}
// attributionFromContext works out what to attribute a log made with
// ctx to.
func attributionFromContext(ctx context.Context) (attribution Attribution) {
if group, ok := fs.StatsGroupFromContext(ctx); ok {
attribution.Group = group
}
if jobID, ok := fs.JobIDFromContext(ctx); ok {
attribution.JobID = jobID
}
return attribution
}
// Define the type of the override logger
@@ -213,13 +244,31 @@ func (h *OutputHandler) ResetOutput() {
}
// AddOutput adds an additional logging destination of the type specified.
func (h *OutputHandler) AddOutput(json bool, fn outputFn) {
//
// It returns a function which removes the destination again.
func (h *OutputHandler) AddOutput(json bool, fn outputFn) (remove func()) {
return h.addRecordOutput(json, func(r slog.Record, attribution Attribution, text string) {
fn(r.Level, text)
})
}
// addRecordOutput is AddOutput with the output passed the log record.
func (h *OutputHandler) addRecordOutput(json bool, fn outputRecordFn) (remove func()) {
h.mu.Lock()
defer h.mu.Unlock()
h.outputExtra = append(h.outputExtra, outputExtra{
out := &outputExtra{
json: json,
output: fn,
})
}
h.outputExtra = append(h.outputExtra, out)
return func() {
h.mu.Lock()
defer h.mu.Unlock()
// Make a new slice as Handle may be using the old one
h.outputExtra = slices.DeleteFunc(slices.Clone(h.outputExtra), func(x *outputExtra) bool {
return x == out
})
}
}
// SetLevel sets a new log level, returning the old one.
@@ -380,6 +429,19 @@ func (h *OutputHandler) Handle(ctx context.Context, r slog.Record) (err error) {
}
}
// Work out what this log is attributed to and record it in the
// JSON output. The text output doesn't show it.
var attribution Attribution
if needJSON {
attribution = attributionFromContext(ctx)
if attribution.JobID != 0 {
r.AddAttrs(slog.Int64("jobid", attribution.JobID))
}
if attribution.Group != "" {
r.AddAttrs(slog.String("group", attribution.Group))
}
}
if needJSON {
var bufJSONBack [256]byte
bufJSON = bytes.NewBuffer(bufJSONBack[:0])
@@ -417,9 +479,9 @@ func (h *OutputHandler) Handle(ctx context.Context, r slog.Record) (err error) {
// Log to any additional destinations required
for _, out := range outputExtra {
if out.json {
out.output(r.Level, bufJSON.String())
out.output(r, attribution, bufJSON.String())
} else {
out.output(r.Level, bufText.String())
out.output(r, attribution, bufText.String())
}
}
return err
+13 -3
View File
@@ -182,12 +182,21 @@ func TestAddOutput(t *testing.T) {
extraText = txt
}
h.AddOutput(false, out)
remove := h.AddOutput(false, out)
r := slog.NewRecord(t0, slog.LevelInfo, "world", 0)
require.NoError(t, h.Handle(context.Background(), r))
assert.Equal(t, "2020/01/02 03:04:05 INFO : world\n", buf.String())
assert.Equal(t, "2020/01/02 03:04:05 INFO : world\n", extraText)
// Check the output can be removed again
remove()
extraText = ""
r = slog.NewRecord(t0, slog.LevelInfo, "again", 0)
require.NoError(t, h.Handle(context.Background(), r))
assert.Equal(t, "2020/01/02 03:04:05 INFO : world\n2020/01/02 03:04:05 INFO : again\n", buf.String())
assert.Equal(t, "", extraText)
assert.Empty(t, h.outputExtra)
}
// Test AddOutputJSON sends JSON to extra destinations.
@@ -346,12 +355,13 @@ func TestOutputHandlerConcurrency(t *testing.T) {
}
})
// Goroutine calling AddOutput alternating JSON and text
// Goroutine calling AddOutput alternating JSON and text and removing them
wg.Go(func() {
noop := func(_ slog.Level, _ string) {}
for j := range 20 {
h.AddOutput(j%2 == 0, noop)
remove := h.AddOutput(j%2 == 0, noop)
time.Sleep(time.Millisecond)
remove()
}
})
+80 -3
View File
@@ -2,6 +2,7 @@ package fs
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
@@ -41,19 +42,19 @@ func TestLogSlogWithObject(t *testing.T) {
SetLogger(slog.NewTextHandler(&buf, nil))
// Objects with a String method are rendered with it
logSlogWithObject(LogLevelError, withString{}, "message", nil)
logSlogWithObject(context.Background(), LogLevelError, withString{}, "message", nil)
assert.Contains(t, buf.String(), "object=hello")
// Plain strings are rendered as themselves
buf.Reset()
logSlogWithObject(LogLevelError, "potato", "message", nil)
logSlogWithObject(context.Background(), LogLevelError, "potato", "message", nil)
assert.Contains(t, buf.String(), "object=potato")
// Anything else shows only its type as it may contain
// sensitive data such as credentials
buf.Reset()
type secrets struct{ Password string }
logSlogWithObject(LogLevelError, &secrets{Password: "SECRET"}, "message", nil)
logSlogWithObject(context.Background(), LogLevelError, &secrets{Password: "SECRET"}, "message", nil)
assert.Contains(t, buf.String(), "object=*fs.secrets")
assert.NotContains(t, buf.String(), "SECRET")
}
@@ -119,3 +120,79 @@ func TestLogLevelUnmarshalJSON(t *testing.T) {
assert.Equal(t, test.want, logLevel, test.in)
}
}
type testGroupKey struct{}
// ctxHandler records the contexts it was called with
type ctxHandler struct {
slog.Handler
ctxs []context.Context
}
func (h *ctxHandler) Handle(ctx context.Context, r slog.Record) error {
h.ctxs = append(h.ctxs, ctx)
return h.Handler.Handle(ctx, r)
}
// The context aware log functions pass their context to the log
// handler which uses it to attribute the log.
func TestLogCtx(t *testing.T) {
var buf bytes.Buffer
oldLogger := logger
defer func() { logger = oldLogger }()
h := &ctxHandler{Handler: slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})}
SetLogger(h)
ci := GetConfig(context.Background())
oldLogLevel := ci.LogLevel
ci.LogLevel = LogLevelDebug
defer func() { ci.LogLevel = oldLogLevel }()
groupCtx := context.WithValue(context.Background(), testGroupKey{}, "job/1")
check := func(want string) {
t.Helper()
var got struct {
Level string `json:"level"`
Msg string `json:"msg"`
Object string `json:"object"`
Size int `json:"size"`
}
require.NoError(t, json.Unmarshal(buf.Bytes(), &got), buf.String())
assert.Equal(t, want, fmt.Sprintf("%s|%s|%s|%d", got.Level, got.Msg, got.Object, got.Size))
buf.Reset()
}
ErrorfCtx(groupCtx, "obj", "error %d", 1)
check("ERROR|error 1|obj|0")
LogfCtx(groupCtx, nil, "notice")
check("INFO+2|notice||0")
InfofCtx(groupCtx, nil, "info")
check("INFO|info||0")
DebugfCtx(groupCtx, nil, "debug")
check("DEBUG|debug||0")
LogPrintfCtx(groupCtx, LogLevelInfo, nil, "size %v", LogValue("size", 3))
check("INFO|size 3||3")
// All of those were passed the context they were called with
require.Len(t, h.ctxs, 5)
for _, ctx := range h.ctxs {
assert.Equal(t, "job/1", ctx.Value(testGroupKey{}))
}
// The non context functions and a nil context log with a
// context which isn't attributed to anything
h.ctxs = nil
Errorf(nil, "plain")
check("ERROR|plain||0")
ErrorfCtx(nil, nil, "nil ctx") //nolint:staticcheck // deliberately testing a nil context
check("ERROR|nil ctx||0")
require.Len(t, h.ctxs, 2)
for _, ctx := range h.ctxs {
require.NotNil(t, ctx)
assert.Nil(t, ctx.Value(testGroupKey{}))
}
// Logs below the log level are dropped
ci.LogLevel = LogLevelNotice
InfofCtx(groupCtx, nil, "dropped")
assert.Equal(t, "", buf.String())
}
+6 -6
View File
@@ -347,7 +347,7 @@ func (m *March) matchListings(srcChan, dstChan <-chan fs.DirEntry, dstCancel fun
}
if src != nil && srcPrev != nil {
if srcName == srcPrevName && fs.DirEntryType(srcPrev) == fs.DirEntryType(src) {
fs.Logf(src, "Duplicate %s found in source - ignoring", fs.DirEntryType(src))
fs.LogfCtx(m.Ctx, src, "Duplicate %s found in source - ignoring", fs.DirEntryType(src))
srcDone() // skip the src and retry the dst
continue
} else if srcName < srcPrevName {
@@ -357,7 +357,7 @@ func (m *March) matchListings(srcChan, dstChan <-chan fs.DirEntry, dstCancel fun
}
if dst != nil && dstPrev != nil {
if dstName == dstPrevName && fs.DirEntryType(dst) == fs.DirEntryType(dstPrev) {
fs.Logf(dst, "Duplicate %s found in destination - ignoring", fs.DirEntryType(dst))
fs.LogfCtx(m.Ctx, dst, "Duplicate %s found in destination - ignoring", fs.DirEntryType(dst))
dstDone() // skip the dst and retry the src
continue
} else if dstName < dstPrevName {
@@ -563,9 +563,9 @@ func (m *March) processJob(job listDirJob) ([]listDirJob, error) {
wg.Wait()
if srcListErr != nil {
if job.srcRemote != "" {
fs.Errorf(job.srcRemote, "error reading source directory: %v", srcListErr)
fs.ErrorfCtx(m.Ctx, job.srcRemote, "error reading source directory: %v", srcListErr)
} else {
fs.Errorf(m.Fsrc, "error reading source root directory: %v", srcListErr)
fs.ErrorfCtx(m.Ctx, m.Fsrc, "error reading source root directory: %v", srcListErr)
}
srcListErr = fs.CountError(m.Ctx, srcListErr)
return nil, srcListErr
@@ -576,9 +576,9 @@ func (m *March) processJob(job listDirJob) ([]listDirJob, error) {
// Copy the stuff anyway
} else if dstListErr != nil {
if job.dstRemote != "" {
fs.Errorf(job.dstRemote, "error reading destination directory: %v", dstListErr)
fs.ErrorfCtx(m.Ctx, job.dstRemote, "error reading destination directory: %v", dstListErr)
} else {
fs.Errorf(m.Fdst, "error reading destination root directory: %v", dstListErr)
fs.ErrorfCtx(m.Ctx, m.Fdst, "error reading destination root directory: %v", dstListErr)
}
dstListErr = fs.CountError(m.Ctx, dstListErr)
return nil, dstListErr
+30 -30
View File
@@ -83,7 +83,7 @@ func (c *checkMarch) DstOnly(dst fs.DirEntry) (recurse bool) {
return false
}
err := fmt.Errorf("file not in %v", c.opt.Fsrc)
fs.Errorf(dst, "%v", err)
fs.ErrorfCtx(c.ctx, dst, "%v", err)
_ = fs.CountError(c.ctx, err)
c.differences.Add(1)
c.srcFilesMissing.Add(1)
@@ -105,7 +105,7 @@ func (c *checkMarch) SrcOnly(src fs.DirEntry) (recurse bool) {
switch src.(type) {
case fs.Object:
err := fmt.Errorf("file not in %v", c.opt.Fdst)
fs.Errorf(src, "%v", err)
fs.ErrorfCtx(c.ctx, src, "%v", err)
_ = fs.CountError(c.ctx, err)
c.differences.Add(1)
c.dstFilesMissing.Add(1)
@@ -128,7 +128,7 @@ func (c *checkMarch) checkIdentical(ctx context.Context, dst, src fs.Object) (di
}()
if sizeDiffers(ctx, src, dst) {
err = fmt.Errorf("sizes differ")
fs.Errorf(src, "%v", err)
fs.ErrorfCtx(ctx, src, "%v", err)
return true, false, nil
}
if ci.SizeOnly {
@@ -155,7 +155,7 @@ func (c *checkMarch) Match(ctx context.Context, dst, src fs.DirEntry) (recurse b
}()
differ, noHash, err := c.checkIdentical(ctx, dstX, srcX)
if err != nil {
fs.Errorf(src, "%v", err)
fs.ErrorfCtx(ctx, src, "%v", err)
_ = fs.CountError(ctx, err)
c.report(src, c.opt.Error, '!')
} else if differ {
@@ -169,15 +169,15 @@ func (c *checkMarch) Match(ctx context.Context, dst, src fs.DirEntry) (recurse b
c.report(src, c.opt.Match, '=')
if noHash {
c.noHashes.Add(1)
fs.Debugf(dstX, "OK - could not check hash")
fs.DebugfCtx(ctx, dstX, "OK - could not check hash")
} else {
fs.Debugf(dstX, "OK")
fs.DebugfCtx(ctx, dstX, "OK")
}
}
}()
} else {
err := fmt.Errorf("is file on %v but directory on %v", c.opt.Fsrc, c.opt.Fdst)
fs.Errorf(src, "%v", err)
fs.ErrorfCtx(ctx, src, "%v", err)
_ = fs.CountError(ctx, err)
c.differences.Add(1)
c.dstFilesMissing.Add(1)
@@ -190,7 +190,7 @@ func (c *checkMarch) Match(ctx context.Context, dst, src fs.DirEntry) (recurse b
return true
}
err := fmt.Errorf("is file on %v but directory on %v", c.opt.Fdst, c.opt.Fsrc)
fs.Errorf(dst, "%v", err)
fs.ErrorfCtx(ctx, dst, "%v", err)
_ = fs.CountError(ctx, err)
c.differences.Add(1)
c.srcFilesMissing.Add(1)
@@ -230,7 +230,7 @@ func CheckFn(ctx context.Context, opt *CheckOpt) error {
NoTraverse: ci.NoTraverse,
NoUnicodeNormalization: ci.NoUnicodeNormalization,
}
fs.Debugf(c.opt.Fdst, "Waiting for checks to finish")
fs.DebugfCtx(ctx, c.opt.Fdst, "Waiting for checks to finish")
err := m.Run(ctx)
c.wg.Wait() // wait for background go-routines
@@ -239,25 +239,25 @@ func CheckFn(ctx context.Context, opt *CheckOpt) error {
func (c *checkMarch) reportResults(ctx context.Context, err error) error {
if c.dstFilesMissing.Load() > 0 {
fs.Logf(c.opt.Fdst, "%d files missing", c.dstFilesMissing.Load())
fs.LogfCtx(ctx, c.opt.Fdst, "%d files missing", c.dstFilesMissing.Load())
}
if c.srcFilesMissing.Load() > 0 {
entity := "files"
if c.opt.Fsrc == nil {
entity = "hashes"
}
fs.Logf(c.opt.Fsrc, "%d %s missing", c.srcFilesMissing.Load(), entity)
fs.LogfCtx(ctx, c.opt.Fsrc, "%d %s missing", c.srcFilesMissing.Load(), entity)
}
fs.Logf(c.opt.Fdst, "%d differences found", c.differences.Load())
fs.LogfCtx(ctx, c.opt.Fdst, "%d differences found", c.differences.Load())
if errs := accounting.Stats(ctx).GetErrors(); errs > 0 {
fs.Logf(c.opt.Fdst, "%d errors while checking", errs)
fs.LogfCtx(ctx, c.opt.Fdst, "%d errors while checking", errs)
}
if c.noHashes.Load() > 0 {
fs.Logf(c.opt.Fdst, "%d hashes could not be checked", c.noHashes.Load())
fs.LogfCtx(ctx, c.opt.Fdst, "%d hashes could not be checked", c.noHashes.Load())
}
if c.matches.Load() > 0 {
fs.Logf(c.opt.Fdst, "%d matching files", c.matches.Load())
fs.LogfCtx(ctx, c.opt.Fdst, "%d matching files", c.matches.Load())
}
if err != nil {
return err
@@ -284,7 +284,7 @@ func Check(ctx context.Context, opt *CheckOpt) error {
}
if !same {
err = fmt.Errorf("%v differ", ht)
fs.Errorf(src, "%v", err)
fs.ErrorfCtx(ctx, src, "%v", err)
return true, false, nil
}
return false, false, nil
@@ -375,7 +375,7 @@ func CheckDownload(ctx context.Context, opt *CheckOpt) error {
}
if !same {
err = errors.New("contents differ")
fs.Errorf(src, "%v", err)
fs.ErrorfCtx(ctx, src, "%v", err)
return true, false, nil
}
return false, false, nil
@@ -457,7 +457,7 @@ func CheckSum(ctx context.Context, fsrc, fsum fs.Fs, sumFile string, hashType ha
}
// filesystem missed the file, sum wasn't consumed
err := fmt.Errorf("file not in %v", opt.Fdst)
fs.Errorf(filename, "%v", err)
fs.ErrorfCtx(ctx, filename, "%v", err)
_ = fs.CountError(ctx, err)
if lastErr == nil {
lastErr = err
@@ -486,7 +486,7 @@ func (c *checkMarch) checkSum(ctx context.Context, obj fs.Object, download bool,
if !sumFound {
err := errors.New("sum not found")
_ = fs.CountError(ctx, err)
fs.Errorf(obj, "%v", err)
fs.ErrorfCtx(ctx, obj, "%v", err)
c.differences.Add(1)
c.srcFilesMissing.Add(1)
c.report(obj, c.opt.MissingOnSrc, '-')
@@ -534,29 +534,29 @@ func (c *checkMarch) matchSum(ctx context.Context, sumHash, objHash string, obj
switch {
case err != nil:
_ = fs.CountError(ctx, err)
fs.Errorf(obj, "Failed to calculate hash: %v", err)
fs.ErrorfCtx(ctx, obj, "Failed to calculate hash: %v", err)
c.report(obj, c.opt.Error, '!')
case sumHash == "":
err = errors.New("duplicate file")
_ = fs.CountError(ctx, err)
fs.Errorf(obj, "%v", err)
fs.ErrorfCtx(ctx, obj, "%v", err)
c.report(obj, c.opt.Error, '!')
case objHash == "":
fs.Debugf(nil, "%v = %s (sum)", hashType, sumHash)
fs.Debugf(obj, "%v - could not check hash (%v)", hashType, c.opt.Fdst)
fs.DebugfCtx(ctx, nil, "%v = %s (sum)", hashType, sumHash)
fs.DebugfCtx(ctx, obj, "%v - could not check hash (%v)", hashType, c.opt.Fdst)
c.noHashes.Add(1)
c.matches.Add(1)
c.report(obj, c.opt.Match, '=')
case objHash == sumHash:
fs.Debugf(obj, "%v = %s OK", hashType, sumHash)
fs.DebugfCtx(ctx, obj, "%v = %s OK", hashType, sumHash)
c.matches.Add(1)
c.report(obj, c.opt.Match, '=')
default:
err = errors.New("files differ")
_ = fs.CountError(ctx, err)
fs.Debugf(nil, "%v = %s (sum)", hashType, sumHash)
fs.Debugf(obj, "%v = %s (%v)", hashType, objHash, c.opt.Fdst)
fs.Errorf(obj, "%v", err)
fs.DebugfCtx(ctx, nil, "%v = %s (sum)", hashType, sumHash)
fs.DebugfCtx(ctx, obj, "%v = %s (%v)", hashType, objHash, c.opt.Fdst)
fs.ErrorfCtx(ctx, obj, "%v", err)
c.differences.Add(1)
c.report(obj, c.opt.Differ, '*')
}
@@ -595,7 +595,7 @@ func ParseSumFile(ctx context.Context, sumFile fs.Object) (HashSums, error) {
if fields == nil {
numWarn++
if numWarn <= maxWarn {
fs.Logf(sumFile, "improperly formatted checksum line %d", lineNo)
fs.LogfCtx(ctx, sumFile, "improperly formatted checksum line %d", lineNo)
}
continue
}
@@ -604,7 +604,7 @@ func ParseSumFile(ctx context.Context, sumFile fs.Object) (HashSums, error) {
if hashes[file] != "" {
numWarn++
if numWarn <= maxWarn {
fs.Logf(sumFile, "duplicate file on checksum line %d", lineNo)
fs.LogfCtx(ctx, sumFile, "duplicate file on checksum line %d", lineNo)
}
continue
}
@@ -614,7 +614,7 @@ func ParseSumFile(ctx context.Context, sumFile fs.Object) (HashSums, error) {
}
if numWarn > maxWarn {
fs.Logf(sumFile, "%d warning(s) suppressed...", numWarn-maxWarn)
fs.LogfCtx(ctx, sumFile, "%d warning(s) suppressed...", numWarn-maxWarn)
}
if err = rd.Close(); err != nil {
return nil, err
+12 -12
View File
@@ -45,10 +45,10 @@ func (c *copy) removeFailedCopy(ctx context.Context, o fs.Object) {
if o == nil {
return
}
fs.Infof(o, "Removing failed copy")
fs.InfofCtx(ctx, o, "Removing failed copy")
err := o.Remove(ctx)
if err != nil {
fs.Infof(o, "Failed to remove failed copy: %s", err)
fs.InfofCtx(ctx, o, "Failed to remove failed copy: %s", err)
}
}
@@ -60,7 +60,7 @@ func (c *copy) removeFailedPartialCopy(ctx context.Context, f fs.Fs, remote stri
return
}
if err != nil {
fs.Infof(remote, "Failed to remove failed partial copy: %s", err)
fs.InfofCtx(ctx, remote, "Failed to remove failed partial copy: %s", err)
return
}
c.removeFailedCopy(ctx, o)
@@ -190,7 +190,7 @@ func (c *copy) rcat(ctx context.Context, in io.ReadCloser) (actionTaken string,
if c.ci.Metadata {
meta, err = fs.GetMetadata(ctx, c.src)
if err != nil {
fs.Errorf(c.src, "Failed to read metadata: %v", err)
fs.ErrorfCtx(ctx, c.src, "Failed to read metadata: %v", err)
}
}
@@ -269,7 +269,7 @@ func (c *copy) manualCopy(ctx context.Context) (actionTaken string, newDst fs.Ob
if !errors.Is(err, fs.ErrorRangeIgnored) {
return actionTaken, newDst, err
}
fs.Logf(c.src, "multi-thread copy: %v: downloading in a single stream", err)
fs.LogfCtx(ctx, c.src, "multi-thread copy: %v: downloading in a single stream", err)
c.tr.Reset(ctx)
}
@@ -336,7 +336,7 @@ func (c *copy) copy(ctx context.Context) (newDst fs.Object, err error) {
if fserrors.IsRetryError(err) || fserrors.ShouldRetry(err) {
retry = true
} else if t, ok := pacer.IsRetryAfter(err); ok && tries+1 < c.maxTries {
fs.Debugf(c.src, "Sleeping for %v (as indicated by the server) to obey Retry-After error: %v", t, err)
fs.DebugfCtx(ctx, c.src, "Sleeping for %v (as indicated by the server) to obey Retry-After error: %v", t, err)
if sleepWithContext(ctx, t) {
retry = true
} else {
@@ -344,14 +344,14 @@ func (c *copy) copy(ctx context.Context) (newDst fs.Object, err error) {
}
}
if retry {
fs.Debugf(c.src, "Received error: %v - low level retry %d/%d", err, tries+1, c.maxTries)
fs.DebugfCtx(ctx, c.src, "Received error: %v - low level retry %d/%d", err, tries+1, c.maxTries)
c.tr.Reset(ctx) // skip incomplete accounting - will be overwritten by retry
continue
}
}
if err != nil {
err = fs.CountError(ctx, err)
fs.Errorf(c.src, "Failed to copy: %v", err)
fs.ErrorfCtx(ctx, c.src, "Failed to copy: %v", err)
if !c.inplace {
c.removeFailedPartialCopy(ctx, c.f, c.remoteForCopy)
}
@@ -361,7 +361,7 @@ func (c *copy) copy(ctx context.Context) (newDst fs.Object, err error) {
// Verify the copy
err = c.verify(ctx, newDst)
if err != nil {
fs.Errorf(newDst, "%v", err)
fs.ErrorfCtx(ctx, newDst, "%v", err)
err = fs.CountError(ctx, err)
c.removeFailedCopy(ctx, newDst)
return nil, err
@@ -371,12 +371,12 @@ func (c *copy) copy(ctx context.Context) (newDst fs.Object, err error) {
if !c.inplace && c.remoteForCopy != c.remote {
movedNewDst, err := c.dstFeatures.Move(ctx, newDst, c.remote)
if err != nil {
fs.Errorf(newDst, "partial file rename failed: %v", err)
fs.ErrorfCtx(ctx, newDst, "partial file rename failed: %v", err)
err = fs.CountError(ctx, err)
c.removeFailedCopy(ctx, newDst)
return nil, err
}
fs.Debugf(newDst, "renamed to: %s", c.remote)
fs.DebugfCtx(ctx, newDst, "renamed to: %s", c.remote)
newDst = movedNewDst
}
@@ -384,7 +384,7 @@ func (c *copy) copy(ctx context.Context) (newDst fs.Object, err error) {
if newDst != nil && c.src.String() != newDst.String() {
actionTaken = fmt.Sprintf("%s to: %s", actionTaken, newDst.String())
}
fs.Infof(c.src, "%s%s", actionTaken, fs.LogValueHide("size", fs.SizeSuffix(c.src.Size())))
fs.InfofCtx(ctx, c.src, "%s%s", actionTaken, fs.LogValueHide("size", fs.SizeSuffix(c.src.Size())))
return newDst, nil
}
+14 -14
View File
@@ -43,7 +43,7 @@ outer:
for {
suffix++
if suffix > maxDedupeSuffix {
fs.Errorf(o, "Could not find an available new name")
fs.ErrorfCtx(ctx, o, "Could not find an available new name")
continue outer
}
newName = fmt.Sprintf("%s-%d%s", base, suffix, ext)
@@ -56,7 +56,7 @@ outer:
}
if err != nil {
err = fs.CountError(ctx, err)
fs.Errorf(o, "Failed to check for existing object: %v", err)
fs.ErrorfCtx(ctx, o, "Failed to check for existing object: %v", err)
continue outer
}
files[newName] = append(files[newName], existing)
@@ -65,10 +65,10 @@ outer:
newObj, err := doMove(ctx, o, newName)
if err != nil {
err = fs.CountError(ctx, err)
fs.Errorf(o, "Failed to rename: %v", err)
fs.ErrorfCtx(ctx, o, "Failed to rename: %v", err)
continue
}
fs.Infof(newObj, "renamed from: %v", o)
fs.InfofCtx(ctx, newObj, "renamed from: %v", o)
files[newName] = append(files[newName], newObj)
}
}
@@ -87,7 +87,7 @@ func dedupeDeleteAllButOne(ctx context.Context, keep int, remote string, objs []
}
}
if count > 0 {
fs.Logf(remote, "Deleted %d extra copies", count)
fs.LogfCtx(ctx, remote, "Deleted %d extra copies", count)
}
}
@@ -113,7 +113,7 @@ func dedupeDeleteIdentical(ctx context.Context, ht hash.Type, remote string, obj
if IDs[ID] <= 1 {
newObjs = append(newObjs, o)
} else {
fs.Logf(o, "Ignoring as it appears %d times in the listing and deleting would lead to data loss", IDs[ID])
fs.LogfCtx(ctx, o, "Ignoring as it appears %d times in the listing and deleting would lead to data loss", IDs[ID])
}
}
}
@@ -143,7 +143,7 @@ func dedupeDeleteIdentical(ctx context.Context, ht hash.Type, remote string, obj
for ID, dupes := range dupesByID {
remainingObjs = append(remainingObjs, dupes[0])
if len(dupes) > 1 {
fs.Logf(remote, "Deleting %d/%d identical duplicates (%s)", len(dupes)-1, len(dupes), ID)
fs.LogfCtx(ctx, remote, "Deleting %d/%d identical duplicates (%s)", len(dupes)-1, len(dupes), ID)
for _, o := range dupes[1:] {
err := DeleteFile(ctx, o)
if err != nil {
@@ -390,11 +390,11 @@ func dedupeMergeDuplicateDirs(ctx context.Context, f fs.Fs, duplicateDirs [][]*d
}
fsDirs[largestIdx], fsDirs[0] = fsDirs[0], fsDirs[largestIdx]
fs.Infof(fsDirs[0], "Merging contents of duplicate directories")
fs.InfofCtx(ctx, fsDirs[0], "Merging contents of duplicate directories")
err := mergeDirs(ctx, fsDirs)
if err != nil {
err = fs.CountError(ctx, err)
fs.Errorf(nil, "merge duplicate dirs: %v", err)
fs.ErrorfCtx(ctx, nil, "merge duplicate dirs: %v", err)
}
}
dirCacheFlush()
@@ -429,7 +429,7 @@ func Deduplicate(ctx context.Context, f fs.Fs, mode DeduplicateMode, byHash bool
}
what = ht.String() + " hashes"
}
fs.Infof(f, "Looking for duplicate %s using %v mode.", what, mode)
fs.InfofCtx(ctx, f, "Looking for duplicate %s using %v mode.", what, mode)
// Find duplicate directories first and fix them
if !byHash {
@@ -464,7 +464,7 @@ func Deduplicate(ctx context.Context, f fs.Fs, mode DeduplicateMode, byHash bool
if byHash {
remote, err = o.Hash(ctx, ht)
if err != nil {
fs.Errorf(o, "Failed to hash: %v", err)
fs.ErrorfCtx(ctx, o, "Failed to hash: %v", err)
remote = ""
}
} else {
@@ -487,11 +487,11 @@ func Deduplicate(ctx context.Context, f fs.Fs, mode DeduplicateMode, byHash bool
if len(objs) <= 1 {
continue
}
fs.Logf(remote, "Found %d files with duplicate %s", len(objs), what)
fs.LogfCtx(ctx, remote, "Found %d files with duplicate %s", len(objs), what)
if !byHash && mode != DeduplicateList {
objs = dedupeDeleteIdentical(ctx, ht, remote, objs)
if len(objs) <= 1 {
fs.Logf(remote, "All duplicates removed")
fs.LogfCtx(ctx, remote, "All duplicates removed")
continue
}
}
@@ -517,7 +517,7 @@ func Deduplicate(ctx context.Context, f fs.Fs, mode DeduplicateMode, byHash bool
sortSmallestFirst(objs)
dedupeDeleteAllButOne(ctx, 0, remote, objs)
case DeduplicateSkip:
fs.Logf(remote, "Skipping %d files with duplicate %s", len(objs), what)
fs.LogfCtx(ctx, remote, "Skipping %d files with duplicate %s", len(objs), what)
case DeduplicateList:
dedupeList(ctx, f, ht, remote, objs, byHash)
default:
+6 -6
View File
@@ -137,7 +137,7 @@ func NewDefaultLoggerFn(opt *LoggerOpt) LoggerFn {
}
if opt.Combined != nil {
SyncFprintf(opt.Combined, "%c %s\n", sigil, filename)
fs.Debugf(nil, "Sync Logger: %s: %c %s\n", sigil.String(), sigil, filename)
fs.DebugfCtx(ctx, nil, "Sync Logger: %s: %c %s\n", sigil.String(), sigil, filename)
}
if opt.DestAfter != nil {
opt.PrintDestAfter(ctx, sigil, src, dst, err)
@@ -299,7 +299,7 @@ func WinningSide(ctx context.Context, sigil Sigil, src, dst fs.DirEntry, err err
// should only make it this far if it's TransferError and both src and dst are nil
winner.Side = "none"
winner.Err = fmt.Errorf("unknown case -- can't determine winner. %v", err)
fs.Debugf(winner.Obj, "%v", winner.Err)
fs.DebugfCtx(ctx, winner.Obj, "%v", winner.Err)
return winner
}
@@ -372,13 +372,13 @@ func (opt *LoggerOpt) Init(ctx context.Context, fdst fs.Fs, cmdFlags *pflag.Flag
ci := fs.GetConfig(ctx)
if ci.NoTraverse && opt.Combined != nil {
fs.LogPrintf(fs.LogLevelWarning, nil, "--no-traverse does not list any deletes (-) in --combined output\n")
fs.LogPrintfCtx(ctx, fs.LogLevelWarning, nil, "--no-traverse does not list any deletes (-) in --combined output\n")
}
if ci.NoTraverse && opt.MissingOnSrc != nil {
fs.LogPrintf(fs.LogLevelWarning, nil, "--no-traverse makes --missing-on-src produce empty output\n")
fs.LogPrintfCtx(ctx, fs.LogLevelWarning, nil, "--no-traverse makes --missing-on-src produce empty output\n")
}
if ci.NoTraverse && opt.DestAfter != nil {
fs.LogPrintf(fs.LogLevelWarning, nil, "--no-traverse makes --dest-after produce incomplete output\n")
fs.LogPrintfCtx(ctx, fs.LogLevelWarning, nil, "--no-traverse makes --dest-after produce incomplete output\n")
}
}
@@ -441,7 +441,7 @@ func (opt *LoggerOpt) SetListFormat(ctx context.Context, cmdFlags *pflag.FlagSet
list.AddMetadata()
JSONOpt.Metadata = true
default:
fs.Errorf(nil, "unknown format character %q", char)
fs.ErrorfCtx(ctx, nil, "unknown format character %q", char)
}
}
opt.ListFormat = list
+5 -5
View File
@@ -168,7 +168,7 @@ func (lj *listJSON) entry(ctx context.Context, entry fs.DirEntry) (*ListJSONItem
return nil, nil
}
default:
fs.Errorf(nil, "Unknown type %T in listing", entry)
fs.ErrorfCtx(ctx, nil, "Unknown type %T in listing", entry)
}
item := &ListJSONItem{
@@ -192,14 +192,14 @@ func (lj *listJSON) entry(ctx context.Context, entry fs.DirEntry) (*ListJSONItem
case fs.Object:
item.EncryptedPath = lj.cipher.EncryptFileName(entry.Remote())
default:
fs.Errorf(nil, "Unknown type %T in listing", entry)
fs.ErrorfCtx(ctx, nil, "Unknown type %T in listing", entry)
}
item.Encrypted = path.Base(item.EncryptedPath)
}
if lj.opt.Metadata {
metadata, err := fs.GetMetadata(ctx, entry)
if err != nil {
fs.Errorf(entry, "Failed to read metadata: %v", err)
fs.ErrorfCtx(ctx, entry, "Failed to read metadata: %v", err)
} else if metadata != nil {
item.Metadata = metadata
}
@@ -223,7 +223,7 @@ func (lj *listJSON) entry(ctx context.Context, entry fs.DirEntry) (*ListJSONItem
for _, hashType := range lj.hashTypes {
hash, err := x.Hash(ctx, hashType)
if err != nil {
fs.Errorf(x, "Failed to read hash: %v", err)
fs.ErrorfCtx(ctx, x, "Failed to read hash: %v", err)
} else if hash != "" {
item.Hashes[hashType.String()] = hash
}
@@ -235,7 +235,7 @@ func (lj *listJSON) entry(ctx context.Context, entry fs.DirEntry) (*ListJSONItem
}
}
default:
fs.Errorf(nil, "Unknown type %T in listing in ListJSON", entry)
fs.ErrorfCtx(ctx, nil, "Unknown type %T in listing in ListJSON", entry)
}
return item, nil
}
+17 -17
View File
@@ -70,11 +70,11 @@ func (mc *multiThreadCopyState) copyChunk(ctx context.Context, chunk int, writer
fs.CheckClose(rw, &err)
}
if err != nil {
fs.Debugf(mc.src, "multi-thread copy: chunk %d/%d failed: %v", chunk+1, mc.numChunks, err)
fs.DebugfCtx(ctx, mc.src, "multi-thread copy: chunk %d/%d failed: %v", chunk+1, mc.numChunks, err)
}
}()
fs.Debugf(mc.src, "multi-thread copy: chunk %d/%d (%d-%d) size %v starting", chunk+1, mc.numChunks, start, end, fs.SizeSuffix(size))
fs.DebugfCtx(ctx, mc.src, "multi-thread copy: chunk %d/%d (%d-%d) size %v starting", chunk+1, mc.numChunks, start, end, fs.SizeSuffix(size))
rc, err := Open(ctx, mc.src, &fs.RangeOption{Start: start, End: end - 1})
if err != nil {
@@ -105,7 +105,7 @@ func (mc *multiThreadCopyState) copyChunk(ctx context.Context, chunk int, writer
return fmt.Errorf("multi-thread copy: failed to write chunk: %w", err)
}
fs.Debugf(mc.src, "multi-thread copy: chunk %d/%d (%d-%d) size %v finished", chunk+1, mc.numChunks, start, end, fs.SizeSuffix(bytesWritten))
fs.DebugfCtx(ctx, mc.src, "multi-thread copy: chunk %d/%d (%d-%d) size %v finished", chunk+1, mc.numChunks, start, end, fs.SizeSuffix(bytesWritten))
return nil
}
@@ -133,17 +133,17 @@ func multiThreadCopy(ctx context.Context, f fs.Fs, remote string, src fs.Object,
}
openChunkWriter = openChunkWriterFromOpenWriterAt(openWriterAt, int64(ci.MultiThreadChunkSize), int64(ci.MultiThreadWriteBufferSize), f)
// If we are using OpenWriterAt we don't seek the chunks so don't need to buffer
fs.Debugf(src, "multi-thread copy: disabling buffering because destination uses OpenWriterAt")
fs.DebugfCtx(ctx, src, "multi-thread copy: disabling buffering because destination uses OpenWriterAt")
noBuffering = true
usingOpenWriterAt = true
} else if src.Fs().Features().IsLocal {
// If the source fs is local we don't need to buffer
fs.Debugf(src, "multi-thread copy: disabling buffering because source is local disk")
fs.DebugfCtx(ctx, src, "multi-thread copy: disabling buffering because source is local disk")
noBuffering = true
} else if f.Features().ChunkWriterDoesntSeek {
// If the destination Fs promises not to seek its chunks
// (except for retries) then we don't need buffering.
fs.Debugf(src, "multi-thread copy: disabling buffering because destination has set ChunkWriterDoesntSeek")
fs.DebugfCtx(ctx, src, "multi-thread copy: disabling buffering because destination has set ChunkWriterDoesntSeek")
noBuffering = true
}
@@ -167,27 +167,27 @@ func multiThreadCopy(ctx context.Context, f fs.Fs, remote string, src fs.Object,
if info.LeavePartsOnError || uploadedOK {
return
}
fs.Debugf(src, "multi-thread copy: cancelling transfer on exit")
fs.DebugfCtx(ctx, src, "multi-thread copy: cancelling transfer on exit")
abortErr := chunkWriter.Abort(ctx)
if abortErr != nil {
fs.Debugf(src, "multi-thread copy: abort failed: %v", abortErr)
fs.DebugfCtx(ctx, src, "multi-thread copy: abort failed: %v", abortErr)
}
})()
if info.ChunkSize > src.Size() {
fs.Debugf(src, "multi-thread copy: chunk size %v was bigger than source file size %v", fs.SizeSuffix(info.ChunkSize), fs.SizeSuffix(src.Size()))
fs.DebugfCtx(ctx, src, "multi-thread copy: chunk size %v was bigger than source file size %v", fs.SizeSuffix(info.ChunkSize), fs.SizeSuffix(src.Size()))
info.ChunkSize = src.Size()
}
// Use the backend concurrency if it is higher than --multi-thread-streams or if --multi-thread-streams wasn't set explicitly
if !ci.MultiThreadSet || info.Concurrency > concurrency {
fs.Debugf(src, "multi-thread copy: using backend concurrency of %d instead of --multi-thread-streams %d", info.Concurrency, concurrency)
fs.DebugfCtx(ctx, src, "multi-thread copy: using backend concurrency of %d instead of --multi-thread-streams %d", info.Concurrency, concurrency)
concurrency = info.Concurrency
}
numChunks := calculateNumChunks(src.Size(), info.ChunkSize)
if concurrency > numChunks {
fs.Debugf(src, "multi-thread copy: number of streams %d was bigger than number of chunks %d", concurrency, numChunks)
fs.DebugfCtx(ctx, src, "multi-thread copy: number of streams %d was bigger than number of chunks %d", concurrency, numChunks)
concurrency = numChunks
}
@@ -210,7 +210,7 @@ func multiThreadCopy(ctx context.Context, f fs.Fs, remote string, src fs.Object,
// Make accounting
mc.acc = tr.Account(gCtx, nil)
fs.Debugf(src, "Starting multi-thread copy with %d chunks of size %v with %v parallel streams", mc.numChunks, fs.SizeSuffix(mc.partSize), concurrency)
fs.DebugfCtx(ctx, src, "Starting multi-thread copy with %d chunks of size %v with %v parallel streams", mc.numChunks, fs.SizeSuffix(mc.partSize), concurrency)
for chunk := range mc.numChunks {
// Fail fast, in case an errgroup managed function returns an error
if gCtx.Err() != nil {
@@ -271,7 +271,7 @@ func multiThreadCopy(ctx context.Context, f fs.Fs, remote string, src fs.Object,
}
setModTime = false
} else {
fs.Errorf(obj, "multi-thread copy: can't set metadata as SetMetadata isn't implemented in: %v", f)
fs.ErrorfCtx(ctx, obj, "multi-thread copy: can't set metadata as SetMetadata isn't implemented in: %v", f)
}
}
if setModTime {
@@ -284,7 +284,7 @@ func multiThreadCopy(ctx context.Context, f fs.Fs, remote string, src fs.Object,
}
}
fs.Debugf(src, "Finished multi-thread copy with %d parts of size %v", mc.numChunks, fs.SizeSuffix(mc.partSize))
fs.DebugfCtx(ctx, src, "Finished multi-thread copy with %d parts of size %v", mc.numChunks, fs.SizeSuffix(mc.partSize))
return obj, nil
}
@@ -302,7 +302,7 @@ type writerAtChunkWriter struct {
// WriteChunk writes chunkNumber from reader
func (w *writerAtChunkWriter) WriteChunk(ctx context.Context, chunkNumber int, reader io.ReadSeeker) (int64, error) {
fs.Debugf(w.remote, "writing chunk %v", chunkNumber)
fs.DebugfCtx(ctx, w.remote, "writing chunk %v", chunkNumber)
bytesToWrite := w.chunkSize
if chunkNumber == (w.chunks-1) && w.size%w.chunkSize != 0 {
@@ -344,7 +344,7 @@ func (w *writerAtChunkWriter) Close(ctx context.Context) error {
func (w *writerAtChunkWriter) Abort(ctx context.Context) error {
err := w.Close(ctx)
if err != nil {
fs.Errorf(w.remote, "multi-thread copy: failed to close file before aborting: %v", err)
fs.ErrorfCtx(ctx, w.remote, "multi-thread copy: failed to close file before aborting: %v", err)
}
obj, err := w.f.NewObject(ctx, w.remote)
if err != nil {
@@ -364,7 +364,7 @@ func openChunkWriterFromOpenWriterAt(openWriterAt fs.OpenWriterAtFn, chunkSize i
}
if writeBufferSize > 0 {
fs.Debugf(src.Remote(), "multi-thread copy: write buffer set to %v", writeBufferSize)
fs.DebugfCtx(ctx, src.Remote(), "multi-thread copy: write buffer set to %v", writeBufferSize)
}
chunkWriter := &writerAtChunkWriter{
+101 -101
View File
@@ -82,7 +82,7 @@ func checkHashes(ctx context.Context, src fs.ObjectInfo, dst fs.Object, ht hash.
return srcErr
}
if srcHash == "" {
fs.Debugf(src, "Src hash empty - aborting Dst hash check")
fs.DebugfCtx(ctx, src, "Src hash empty - aborting Dst hash check")
return errNoHash
}
return nil
@@ -93,7 +93,7 @@ func checkHashes(ctx context.Context, src fs.ObjectInfo, dst fs.Object, ht hash.
return dstErr
}
if dstHash == "" {
fs.Debugf(dst, "Dst hash empty - aborting Src hash check")
fs.DebugfCtx(ctx, dst, "Dst hash empty - aborting Src hash check")
return errNoHash
}
return nil
@@ -104,21 +104,21 @@ func checkHashes(ctx context.Context, src fs.ObjectInfo, dst fs.Object, ht hash.
}
if srcErr != nil {
err = fs.CountError(ctx, srcErr)
fs.Errorf(src, "Failed to calculate src hash: %v", err)
fs.ErrorfCtx(ctx, src, "Failed to calculate src hash: %v", err)
}
if dstErr != nil {
err = fs.CountError(ctx, dstErr)
fs.Errorf(dst, "Failed to calculate dst hash: %v", err)
fs.ErrorfCtx(ctx, dst, "Failed to calculate dst hash: %v", err)
}
if err != nil {
return false, ht, srcHash, dstHash, err
}
if srcHash != dstHash {
fs.Debugf(src, "%v = %s (%v)", ht, srcHash, src.Fs())
fs.Debugf(dst, "%v = %s (%v)", ht, dstHash, dst.Fs())
fs.DebugfCtx(ctx, src, "%v = %s (%v)", ht, srcHash, src.Fs())
fs.DebugfCtx(ctx, dst, "%v = %s (%v)", ht, dstHash, dst.Fs())
return false, ht, srcHash, dstHash, nil
}
fs.Debugf(src, "%v = %s OK", ht, srcHash)
fs.DebugfCtx(ctx, src, "%v = %s OK", ht, srcHash)
return true, ht, srcHash, dstHash, nil
}
@@ -168,11 +168,11 @@ func DirsEqual(ctx context.Context, src, dst fs.Directory, opt DirsEqualOpt) (eq
}
dt := dstModTime.Sub(srcModTime)
if dt < opt.ModifyWindow && dt > -opt.ModifyWindow {
fs.Debugf(dst, "Directory modification time the same (differ by %s, within tolerance %s)", dt, opt.ModifyWindow)
fs.DebugfCtx(ctx, dst, "Directory modification time the same (differ by %s, within tolerance %s)", dt, opt.ModifyWindow)
return true
}
if ci.UpdateOlder && dt >= opt.ModifyWindow {
fs.Debugf(dst, "Destination directory is newer than source, skipping")
fs.DebugfCtx(ctx, dst, "Destination directory is newer than source, skipping")
return true
}
return false
@@ -186,11 +186,11 @@ func sizeDiffers(ctx context.Context, src, dst fs.ObjectInfo) bool {
return false
}
if src.Size() == dst.Size() {
fs.Debugf(dst, "size = %d OK", dst.Size())
fs.DebugfCtx(ctx, dst, "size = %d OK", dst.Size())
return false
}
fs.Debugf(src, "size = %d (%v)", src.Size(), src.Fs())
fs.Debugf(dst, "size = %d (%v)", dst.Size(), dst.Fs())
fs.DebugfCtx(ctx, src, "size = %d (%v)", src.Size(), src.Fs())
fs.DebugfCtx(ctx, dst, "size = %d (%v)", dst.Size(), dst.Fs())
return true
}
@@ -248,12 +248,12 @@ func equal(ctx context.Context, src fs.ObjectInfo, dst fs.Object, opt equalOpt)
ci := fs.GetConfig(ctx)
logger, _ := GetLogger(ctx)
if sizeDiffers(ctx, src, dst) {
fs.Debug(src, "Sizes differ")
fs.DebugfCtx(ctx, src, "Sizes differ")
logger(ctx, Differ, src, dst, nil)
return false
}
if opt.sizeOnly {
fs.Debugf(src, "Sizes identical")
fs.DebugfCtx(ctx, src, "Sizes identical")
logger(ctx, Match, src, dst, nil)
return true
}
@@ -265,7 +265,7 @@ func equal(ctx context.Context, src fs.ObjectInfo, dst fs.Object, opt equalOpt)
// Check the hash
same, ht, _ := CheckHashes(ctx, src, dst)
if !same {
fs.Debugf(src, "%v differ", ht)
fs.DebugfCtx(ctx, src, "%v differ", ht)
logger(ctx, Differ, src, dst, nil)
return false
}
@@ -273,12 +273,12 @@ func equal(ctx context.Context, src fs.ObjectInfo, dst fs.Object, opt equalOpt)
common := src.Fs().Hashes().Overlap(dst.Fs().Hashes())
if common.Count() == 0 {
checksumWarning.Do(func() {
fs.Logf(dst.Fs(), "--checksum is in use but the source and destination have no hashes in common; falling back to --size-only")
fs.LogfCtx(ctx, dst.Fs(), "--checksum is in use but the source and destination have no hashes in common; falling back to --size-only")
})
}
fs.Debugf(src, "Size of src and dst objects identical")
fs.DebugfCtx(ctx, src, "Size of src and dst objects identical")
} else {
fs.Debugf(src, "Size and %v of src and dst objects identical", ht)
fs.DebugfCtx(ctx, src, "Size and %v of src and dst objects identical", ht)
}
logger(ctx, Match, src, dst, nil)
return true
@@ -289,25 +289,25 @@ func equal(ctx context.Context, src fs.ObjectInfo, dst fs.Object, opt equalOpt)
// Sizes the same so check the mtime
modifyWindow := fs.GetModifyWindow(ctx, src.Fs(), dst.Fs())
if modifyWindow == fs.ModTimeNotSupported {
fs.Debugf(src, "Sizes identical")
fs.DebugfCtx(ctx, src, "Sizes identical")
logger(ctx, Match, src, dst, nil)
return true
}
dstModTime := dst.ModTime(ctx)
dt := dstModTime.Sub(srcModTime)
if dt < modifyWindow && dt > -modifyWindow {
fs.Debugf(src, "Size and modification time the same (differ by %s, within tolerance %s)", dt, modifyWindow)
fs.DebugfCtx(ctx, src, "Size and modification time the same (differ by %s, within tolerance %s)", dt, modifyWindow)
logger(ctx, Match, src, dst, nil)
return true
}
fs.Debugf(src, "Modification times differ by %s: %v, %v", dt, srcModTime, dstModTime)
fs.DebugfCtx(ctx, src, "Modification times differ by %s: %v, %v", dt, srcModTime, dstModTime)
}
// Check if the hashes are the same
same, ht, _ := CheckHashes(ctx, src, dst)
if !same {
fs.Debugf(src, "%v differ", ht)
fs.DebugfCtx(ctx, src, "%v differ", ht)
logger(ctx, Differ, src, dst, nil)
return false
}
@@ -323,7 +323,7 @@ func equal(ctx context.Context, src fs.ObjectInfo, dst fs.Object, opt equalOpt)
// Size and hash the same but mtime different
// Error if objects are treated as immutable
if ci.Immutable {
fs.Errorf(dst, "Timestamp mismatch between immutable objects")
fs.ErrorfCtx(ctx, dst, "Timestamp mismatch between immutable objects")
logger(ctx, Differ, src, dst, nil)
return false
}
@@ -331,27 +331,27 @@ func equal(ctx context.Context, src fs.ObjectInfo, dst fs.Object, opt equalOpt)
err := dst.SetModTime(ctx, srcModTime)
if errors.Is(err, fs.ErrorCantSetModTime) {
logModTimeUpload(dst)
fs.Infof(dst, "src and dst identical but can't set mod time without re-uploading")
fs.InfofCtx(ctx, dst, "src and dst identical but can't set mod time without re-uploading")
logger(ctx, Differ, src, dst, nil)
return false
} else if errors.Is(err, fs.ErrorCantSetModTimeWithoutDelete) {
logModTimeUpload(dst)
fs.Infof(dst, "src and dst identical but can't set mod time without deleting and re-uploading")
fs.InfofCtx(ctx, dst, "src and dst identical but can't set mod time without deleting and re-uploading")
// Remove the file if BackupDir isn't set. If BackupDir is set we would rather have the old file
// put in the BackupDir than deleted which is what will happen if we don't delete it.
if ci.BackupDir == "" {
err = dst.Remove(ctx)
if err != nil {
fs.Errorf(dst, "failed to delete before re-upload: %v", err)
fs.ErrorfCtx(ctx, dst, "failed to delete before re-upload: %v", err)
}
}
logger(ctx, Differ, src, dst, nil)
return false
} else if err != nil {
err = fs.CountError(ctx, err)
fs.Errorf(dst, "Failed to set modification time: %v", err)
fs.ErrorfCtx(ctx, dst, "Failed to set modification time: %v", err)
} else {
fs.Infof(src, "Updated modification time in destination")
fs.InfofCtx(ctx, src, "Updated modification time in destination")
}
}
}
@@ -491,19 +491,19 @@ func move(ctx context.Context, fdst fs.Fs, dst fs.Object, remote string, src fs.
switch err {
case nil:
if newDst != nil && src.String() != newDst.String() {
fs.Infof(src, "Moved (server-side) to: %s", newDst.String())
fs.InfofCtx(ctx, src, "Moved (server-side) to: %s", newDst.String())
} else {
fs.Infof(src, "Moved (server-side)")
fs.InfofCtx(ctx, src, "Moved (server-side)")
}
in.ServerSideMoveEnd(newDst.Size()) // account the bytes for the server-side transfer
_ = in.Close()
return newDst, nil
case fs.ErrorCantMove:
fs.Debugf(src, "Can't move, switching to copy")
fs.DebugfCtx(ctx, src, "Can't move, switching to copy")
_ = in.Close()
default:
err = fs.CountError(ctx, err)
fs.Errorf(src, "Couldn't move: %v", err)
fs.ErrorfCtx(ctx, src, "Couldn't move: %v", err)
_ = in.Close()
return newDst, err
}
@@ -514,7 +514,7 @@ func move(ctx context.Context, fdst fs.Fs, dst fs.Object, remote string, src fs.
}
newDst, err = Copy(ctx, fdst, dst, origRemote, src)
if err != nil {
fs.Errorf(src, "Not deleting source as copy failed: %v", err)
fs.ErrorfCtx(ctx, src, "Not deleting source as copy failed: %v", err)
return newDst, err
}
// Delete src if no error on copy
@@ -576,10 +576,10 @@ func DeleteFileWithBackupDir(ctx context.Context, dst fs.Object, backupDir fs.Fs
err = dst.Remove(ctx)
}
if err != nil {
fs.Errorf(dst, "Couldn't %s: %v", action, err)
fs.ErrorfCtx(ctx, dst, "Couldn't %s: %v", action, err)
err = fs.CountError(ctx, err)
} else if !skip {
fs.Infof(dst, "%s", actioned)
fs.InfofCtx(ctx, dst, "%s", actioned)
}
return err
}
@@ -616,14 +616,14 @@ func DeleteFilesWithBackupDir(ctx context.Context, toBeDeleted fs.ObjectsChan, b
logger, _ := GetLogger(ctx)
logger(ctx, TransferError, nil, dst, err)
if fserrors.IsFatalError(err) {
fs.Errorf(dst, "Got fatal error on delete: %s", err)
fs.ErrorfCtx(ctx, dst, "Got fatal error on delete: %s", err)
fatalErrorCount.Add(1)
}
}
}
}()
}
fs.Debugf(nil, "Waiting for deletions to finish")
fs.DebugfCtx(ctx, nil, "Waiting for deletions to finish")
wg.Wait()
if errorCount.Load() > 0 {
err := fmt.Errorf("failed to delete %d files", errorCount.Load())
@@ -726,7 +726,7 @@ func filterCheck(ctx context.Context, f fs.Fs, dir string) bool {
includeDirectory := fi.IncludeDirectory(ctx, f)
include, err := includeDirectory(dir)
if err != nil {
fs.Errorf(f, "Failed to discover whether directory is included: %v", err)
fs.ErrorfCtx(ctx, f, "Failed to discover whether directory is included: %v", err)
return true
}
return include
@@ -770,13 +770,13 @@ func Retry(ctx context.Context, o any, maxTries int, fn func() error) (err error
}
// Retry if err returned a retry error
if fserrors.IsRetryError(err) || fserrors.ShouldRetry(err) {
fs.Debugf(o, "Received error: %v - low level retry %d/%d", err, tries, maxTries)
fs.DebugfCtx(ctx, o, "Received error: %v - low level retry %d/%d", err, tries, maxTries)
continue
} else if t, ok := pacer.IsRetryAfter(err); ok {
if tries >= maxTries {
break
}
fs.Debugf(o, "Sleeping for %v (as indicated by the server) to obey Retry-After error: %v", t, err)
fs.DebugfCtx(ctx, o, "Sleeping for %v (as indicated by the server) to obey Retry-After error: %v", t, err)
if !sleepWithContext(ctx, t) {
fserrors.ContextError(ctx, &err)
break
@@ -1005,7 +1005,7 @@ func HashLister(ctx context.Context, ht hash.Type, outputBase64 bool, downloadFl
}()
sum, err := HashSum(ctx, ht, outputBase64, downloadFlag, o)
if err != nil {
fs.Errorf(o, "%v", fs.CountError(ctx, err))
fs.ErrorfCtx(ctx, o, "%v", fs.CountError(ctx, err))
return
}
SyncFprintf(w, "%*s %s\n", width, sum, o.Remote())
@@ -1085,7 +1085,7 @@ func Mkdir(ctx context.Context, f fs.Fs, dir string) (err error) {
if SkipDestructive(ctx, fs.LogDirName(f, dir), "make directory") {
return nil
}
fs.Infof(fs.LogDirName(f, dir), "Making directory")
fs.InfofCtx(ctx, fs.LogDirName(f, dir), "Making directory")
err = f.Mkdir(ctx, dir)
if err != nil {
err = fs.CountError(ctx, err)
@@ -1111,7 +1111,7 @@ func MkdirMetadata(ctx context.Context, f fs.Fs, dir string, metadata fs.Metadat
if SkipDestructive(ctx, logName, "make directory") {
return nil, nil
}
fs.Debugf(fs.LogDirName(f, dir), "Making directory with metadata")
fs.DebugfCtx(ctx, fs.LogDirName(f, dir), "Making directory with metadata")
newDst, err = do(ctx, dir, metadata)
if err != nil {
err = fs.CountError(ctx, err)
@@ -1119,9 +1119,9 @@ func MkdirMetadata(ctx context.Context, f fs.Fs, dir string, metadata fs.Metadat
}
accounting.Stats(ctx).UpdatedDirs(1)
if mtime, ok := metadata["mtime"]; ok {
fs.Infof(logName, "Made directory with metadata (mtime=%s)", mtime)
fs.InfofCtx(ctx, logName, "Made directory with metadata (mtime=%s)", mtime)
} else {
fs.Infof(logName, "Made directory with metadata")
fs.InfofCtx(ctx, logName, "Made directory with metadata")
}
return newDst, err
}
@@ -1165,7 +1165,7 @@ func TryRmdir(ctx context.Context, f fs.Fs, dir string) error {
if SkipDestructive(ctx, fs.LogDirName(f, dir), "remove directory") {
return nil
}
fs.Infof(fs.LogDirName(f, dir), "Removing directory")
fs.InfofCtx(ctx, fs.LogDirName(f, dir), "Removing directory")
return f.Rmdir(ctx, dir)
}
@@ -1257,23 +1257,23 @@ func RemoveExisting(ctx context.Context, f fs.Fs, remote string, operation strin
remoteSaved = remote + suffix
}
fs.Debugf(existingObj, "%s: renaming existing object to %q before starting", operation, remoteSaved)
fs.DebugfCtx(ctx, existingObj, "%s: renaming existing object to %q before starting", operation, remoteSaved)
existingObj, err = doMove(ctx, existingObj, remoteSaved)
if err != nil {
return nil, fmt.Errorf("%s: failed to rename existing file: %w", operation, err)
}
return func(perr *error) {
if *perr == nil {
fs.Debugf(existingObj, "%s: removing renamed existing file after operation", operation)
fs.DebugfCtx(ctx, existingObj, "%s: removing renamed existing file after operation", operation)
err := existingObj.Remove(ctx)
if err != nil {
*perr = fmt.Errorf("%s: failed to remove renamed existing file: %w", operation, err)
}
} else {
fs.Debugf(existingObj, "%s: renaming existing back after failed operation", operation)
fs.DebugfCtx(ctx, existingObj, "%s: renaming existing back after failed operation", operation)
_, renameErr := doMove(ctx, existingObj, remote)
if renameErr != nil {
fs.Errorf(existingObj, "%s: failed to restore existing file after failed operation: %v", operation, renameErr)
fs.ErrorfCtx(ctx, existingObj, "%s: failed to restore existing file after failed operation: %v", operation, renameErr)
}
}
}, nil
@@ -1299,7 +1299,7 @@ func listToChan(ctx context.Context, f fs.Fs, dir string) fs.ObjectsChan {
if err != nil && err != fs.ErrorDirNotFound {
err = fmt.Errorf("failed to list: %w", err)
err = fs.CountError(ctx, err)
fs.Errorf(nil, "%v", err)
fs.ErrorfCtx(ctx, nil, "%v", err)
}
}()
return o
@@ -1338,13 +1338,13 @@ func Cat(ctx context.Context, f fs.Fs, w io.Writer, offset, count int64, sep []b
defer mu.Unlock()
err := catObject(ctx, o, w, offset, count)
if err != nil {
fs.Errorf(o, "Failed to send to output: %v", err)
fs.ErrorfCtx(ctx, o, "Failed to send to output: %v", err)
return
}
if len(sep) > 0 {
if _, err := w.Write(sep); err != nil {
err = fs.CountError(ctx, err)
fs.Errorf(o, "Failed to send separator to output: %v", err)
fs.ErrorfCtx(ctx, o, "Failed to send separator to output: %v", err)
}
}
})
@@ -1466,10 +1466,10 @@ func rcatSrc(ctx context.Context, fdst fs.Fs, dstFileName string, in io.ReadClos
if fileIsSmall || doPutStream == nil {
var rs io.ReadSeeker
if fileIsSmall {
fs.Debugf(fdst, "File to upload is small (%d bytes), uploading instead of streaming", len(buf))
fs.DebugfCtx(ctx, fdst, "File to upload is small (%d bytes), uploading instead of streaming", len(buf))
rs = bytes.NewReader(buf)
} else {
fs.Debugf(fdst, "Target remote doesn't support streaming uploads, creating temporary local FS to spool file")
fs.DebugfCtx(ctx, fdst, "Target remote doesn't support streaming uploads, creating temporary local FS to spool file")
spool, err := os.CreateTemp("", "rclone-spool")
if err != nil {
return nil, fmt.Errorf("failed to create temporary spool file: %v", err)
@@ -1478,11 +1478,11 @@ func rcatSrc(ctx context.Context, fdst fs.Fs, dstFileName string, in io.ReadClos
defer func() {
err := spool.Close()
if err != nil {
fs.Errorf(fileName, "Failed to close temporary spool file: %v", err)
fs.ErrorfCtx(ctx, fileName, "Failed to close temporary spool file: %v", err)
}
err = os.Remove(fileName)
if err != nil {
fs.Errorf(fileName, "Failed to delete temporary spool file: %v", err)
fs.ErrorfCtx(ctx, fileName, "Failed to delete temporary spool file: %v", err)
}
}()
_, err = io.Copy(spool, streamIn)
@@ -1523,7 +1523,7 @@ func rcatSrc(ctx context.Context, fdst fs.Fs, dstFileName string, in io.ReadClos
if !equal(ctx, src, dst, opt) {
err = fmt.Errorf("corrupted on transfer")
err = fs.CountError(ctx, err)
fs.Errorf(dst, "%v", err)
fs.ErrorfCtx(ctx, dst, "%v", err)
return dst, err
}
return dst, nil
@@ -1550,7 +1550,7 @@ func Rmdirs(ctx context.Context, f fs.Fs, dir string, leaveRoot bool) error {
err := walk.Walk(ctx, f, dir, false, ci.MaxDepth, func(dirPath string, entries fs.DirEntries, err error) error {
if err != nil {
err = fs.CountError(ctx, err)
fs.Errorf(f, "Failed to list %q: %v", dirPath, err)
fs.ErrorfCtx(ctx, f, "Failed to list %q: %v", dirPath, err)
return nil
}
for _, entry := range entries {
@@ -1611,7 +1611,7 @@ func Rmdirs(ctx context.Context, f fs.Fs, dir string, leaveRoot bool) error {
if len(dirs) == 0 {
continue
}
fs.Debugf(nil, "removing %d level %d directories", len(dirs), level)
fs.DebugfCtx(ctx, nil, "removing %d level %d directories", len(dirs), level)
sort.Strings(dirs)
g, gCtx := errgroup.WithContext(ctx)
g.SetLimit(ci.Checkers)
@@ -1625,7 +1625,7 @@ func Rmdirs(ctx context.Context, f fs.Fs, dir string, leaveRoot bool) error {
err := TryRmdir(gCtx, f, dir)
if err != nil {
err = fs.CountError(ctx, err)
fs.Errorf(dir, "Failed to rmdir: %v", err)
fs.ErrorfCtx(gCtx, dir, "Failed to rmdir: %v", err)
errCount.Add(err)
}
return nil // don't return errors, just count them
@@ -1672,7 +1672,7 @@ func compareDest(ctx context.Context, dst, src fs.Object, CompareDest fs.Fs) (No
opt := defaultEqualOpt(ctx)
opt.updateModTime = false
if equal(ctx, src, CompareDestFile, opt) {
fs.Debugf(src, "Destination found in --compare-dest, skipping")
fs.DebugfCtx(ctx, src, "Destination found in --compare-dest, skipping")
return true, nil
}
return false, nil
@@ -1731,16 +1731,16 @@ func copyDest(ctx context.Context, fdst fs.Fs, dst, src fs.Object, CopyDest, bac
}
_, err := Copy(ctx, fdst, dst, remote, CopyDestFile)
if err != nil {
fs.Errorf(src, "Destination found in --copy-dest, error copying")
fs.ErrorfCtx(ctx, src, "Destination found in --copy-dest, error copying")
return false, nil
}
fs.Debugf(src, "Destination found in --copy-dest, using server-side copy")
fs.DebugfCtx(ctx, src, "Destination found in --copy-dest, using server-side copy")
return true, nil
}
fs.Debugf(src, "Unchanged skipping")
fs.DebugfCtx(ctx, src, "Unchanged skipping")
return true, nil
}
fs.Debugf(src, "Destination not found in --copy-dest")
fs.DebugfCtx(ctx, src, "Destination not found in --copy-dest")
return false, nil
}
@@ -1777,19 +1777,19 @@ func NeedTransfer(ctx context.Context, dst, src fs.Object) bool {
ci := fs.GetConfig(ctx)
logger, _ := GetLogger(ctx)
if dst == nil {
fs.Debugf(src, "Need to transfer - File not found at Destination")
fs.DebugfCtx(ctx, src, "Need to transfer - File not found at Destination")
logger(ctx, MissingOnDst, src, nil, nil)
return true
}
// If we should ignore existing files, don't transfer
if ci.IgnoreExisting {
fs.Debugf(src, "Destination exists, skipping")
fs.DebugfCtx(ctx, src, "Destination exists, skipping")
logger(ctx, Match, src, dst, nil)
return false
}
// If we should upload unconditionally
if ci.IgnoreTimes {
fs.Debugf(src, "Transferring unconditionally as --ignore-times is in use")
fs.DebugfCtx(ctx, src, "Transferring unconditionally as --ignore-times is in use")
logger(ctx, Differ, src, dst, nil)
return true
}
@@ -1808,7 +1808,7 @@ func NeedTransfer(ctx context.Context, dst, src fs.Object) bool {
}
switch {
case dt >= modifyWindow:
fs.Debugf(src, "Destination is newer than source, skipping")
fs.DebugfCtx(ctx, src, "Destination is newer than source, skipping")
logger(ctx, Match, src, dst, nil)
return false
case dt <= -modifyWindow:
@@ -1816,7 +1816,7 @@ func NeedTransfer(ctx context.Context, dst, src fs.Object) bool {
opt := defaultEqualOpt(ctx)
opt.forceModTimeMatch = true
if equal(ctx, src, dst, opt) {
fs.Debugf(src, "Unchanged skipping")
fs.DebugfCtx(ctx, src, "Unchanged skipping")
return false
}
default:
@@ -1824,10 +1824,10 @@ func NeedTransfer(ctx context.Context, dst, src fs.Object) bool {
opt := defaultEqualOpt(ctx)
opt.sizeOnly = !ci.CheckSum
if equal(ctx, src, dst, opt) {
fs.Debugf(src, "Destination mod time is within %v of source and files identical, skipping", modifyWindow)
fs.DebugfCtx(ctx, src, "Destination mod time is within %v of source and files identical, skipping", modifyWindow)
return false
}
fs.Debugf(src, "Destination mod time is within %v of source but files differ, transferring", modifyWindow)
fs.DebugfCtx(ctx, src, "Destination mod time is within %v of source but files differ, transferring", modifyWindow)
}
} else {
// Check to see if changed or not
@@ -1836,7 +1836,7 @@ func NeedTransfer(ctx context.Context, dst, src fs.Object) bool {
return !equalFn(ctx, src, dst)
}
if Equal(ctx, src, dst) && !SameObject(src, dst) {
fs.Debugf(src, "Unchanged skipping")
fs.DebugfCtx(ctx, src, "Unchanged skipping")
return false
}
}
@@ -1883,7 +1883,7 @@ func RcatSize(ctx context.Context, fdst fs.Fs, dstFileName string, in io.ReadClo
info := object.NewStaticObjectInfo(dstFileName, modTime, size, true, nil, fdst).WithMetadata(meta)
obj, err = fdst.Put(ctx, in, info, options...)
if err != nil {
fs.Errorf(dstFileName, "Post request put error: %v", err)
fs.ErrorfCtx(ctx, dstFileName, "Post request put error: %v", err)
return nil, err
}
@@ -1901,10 +1901,10 @@ func RcatSize(ctx context.Context, fdst fs.Fs, dstFileName string, in io.ReadClo
}
if err != nil {
err = fs.CountError(ctx, err)
fs.Errorf(obj, "%v", err)
fs.Infof(obj, "Removing failed copy")
fs.ErrorfCtx(ctx, obj, "%v", err)
fs.InfofCtx(ctx, obj, "Removing failed copy")
if removeErr := obj.Remove(ctx); removeErr != nil {
fs.Infof(obj, "Failed to remove failed copy: %s", removeErr)
fs.InfofCtx(ctx, obj, "Failed to remove failed copy: %s", removeErr)
}
return nil, err
}
@@ -1912,7 +1912,7 @@ func RcatSize(ctx context.Context, fdst fs.Fs, dstFileName string, in io.ReadClo
// Size unknown use Rcat
obj, err = Rcat(ctx, fdst, dstFileName, in, modTime, meta)
if err != nil {
fs.Errorf(dstFileName, "Post request rcat error: %v", err)
fs.ErrorfCtx(ctx, dstFileName, "Post request rcat error: %v", err)
return nil, err
}
@@ -1953,7 +1953,7 @@ func copyURLFn(ctx context.Context, dstFileName string, url string, autoFilename
if err != nil || headerFilename == "" {
return fmt.Errorf("CopyURL failed: filename not found in the Content-Disposition header")
}
fs.Debugf(headerFilename, "filename found in Content-Disposition header.")
fs.DebugfCtx(ctx, headerFilename, "filename found in Content-Disposition header.")
return fn(ctx, headerFilename, resp.Body, resp.ContentLength, modTime)
}
@@ -1961,7 +1961,7 @@ func copyURLFn(ctx context.Context, dstFileName string, url string, autoFilename
if dstFileName == "." || dstFileName == "/" {
return fmt.Errorf("CopyURL failed: file name wasn't found in url")
}
fs.Debugf(dstFileName, "File name found in url")
fs.DebugfCtx(ctx, dstFileName, "File name found in url")
}
return fn(ctx, dstFileName, resp.Body, resp.ContentLength, modTime)
}
@@ -2075,13 +2075,13 @@ func MoveCaseInsensitive(ctx context.Context, fdst fs.Fs, fsrc fs.Fs, dstFileNam
logger(ctx, TransferError, nil, tmpObjFail, err)
return nil, fmt.Errorf("error while attempting to move file to a temporary location: %w", err)
}
fs.Debugf(srcObj, "moving to %v", tmpObjName)
fs.DebugfCtx(ctx, srcObj, "moving to %v", tmpObjName)
tmpObj, err := Op(ctx, fdst, nil, tmpObjName, srcObj)
if err != nil {
logger(ctx, TransferError, srcObj, tmpObj, err)
return nil, fmt.Errorf("error while moving file to temporary location: %w", err)
}
fs.Debugf(srcObj, "moving to %v", dstFileName)
fs.DebugfCtx(ctx, srcObj, "moving to %v", dstFileName)
newDst, err = Op(ctx, fdst, nil, dstFileName, tmpObj)
logger(ctx, MissingOnDst, tmpObj, nil, err)
return newDst, err
@@ -2094,7 +2094,7 @@ func moveOrCopyFile(ctx context.Context, fdst fs.Fs, fsrc fs.Fs, dstFileName str
dstFilePath := path.Join(fdst.Root(), dstFileName)
srcFilePath := path.Join(fsrc.Root(), srcFileName)
if fdst.Name() == fsrc.Name() && dstFilePath == srcFilePath && !allowOverlap {
fs.Debugf(fdst, "don't need to copy/move %s, it is already at target location", dstFileName)
fs.DebugfCtx(ctx, fdst, "don't need to copy/move %s, it is already at target location", dstFileName)
if usingLogger {
srcObj, _ := fsrc.NewObject(ctx, srcFileName)
dstObj, _ := fsrc.NewObject(ctx, dstFileName)
@@ -2186,7 +2186,7 @@ func moveOrCopyFile(ctx context.Context, fdst fs.Fs, fsrc fs.Fs, dstFileName str
_, err = Op(ctx, fdst, dstObj, dstFileName, srcObj)
} else if !cp {
if ci.IgnoreExisting {
fs.Debugf(srcObj, "Not removing source file as destination file exists and --ignore-existing is set")
fs.DebugfCtx(ctx, srcObj, "Not removing source file as destination file exists and --ignore-existing is set")
logger(ctx, Match, srcObj, dstObj, nil)
} else if !SameObject(srcObj, dstObj) {
err = DeleteFile(ctx, srcObj)
@@ -2215,12 +2215,12 @@ func SetTier(ctx context.Context, fsrc fs.Fs, tier string) error {
return ListFn(ctx, fsrc, func(o fs.Object) {
objImpl, ok := o.(fs.SetTierer)
if !ok {
fs.Errorf(fsrc, "Remote object does not implement SetTier")
fs.ErrorfCtx(ctx, fsrc, "Remote object does not implement SetTier")
return
}
err := objImpl.SetTier(tier)
if err != nil {
fs.Errorf(fsrc, "Failed to do SetTier, %v", err)
fs.ErrorfCtx(ctx, fsrc, "Failed to do SetTier, %v", err)
}
})
}
@@ -2233,7 +2233,7 @@ func SetTierFile(ctx context.Context, o fs.Object, tier string) error {
}
err := do.SetTier(tier)
if err != nil {
fs.Errorf(o, "Failed to do SetTier, %v", err)
fs.ErrorfCtx(ctx, o, "Failed to do SetTier, %v", err)
return err
}
return nil
@@ -2248,12 +2248,12 @@ func TouchDir(ctx context.Context, f fs.Fs, remote string, t time.Time, recursiv
entries.ForObject(func(o fs.Object) {
if !SkipDestructive(ctx, o, "touch") {
g.Go(func() error {
fs.Debugf(f, "Touching %q", o.Remote())
fs.DebugfCtx(ctx, f, "Touching %q", o.Remote())
err := o.SetModTime(gCtx, t)
if err != nil {
err = fmt.Errorf("failed to touch: %w", err)
err = fs.CountError(gCtx, err)
fs.Errorf(o, "%v", err)
fs.ErrorfCtx(ctx, o, "%v", err)
}
return nil
})
@@ -2487,7 +2487,7 @@ func DirMove(ctx context.Context, f fs.Fs, srcRemote, dstRemote string) (err err
if err != fs.ErrorCantDirMove && err != fs.ErrorDirExists {
return err
}
fs.Infof(f, "Can't DirMove - falling back to file moves: %v", err)
fs.InfofCtx(ctx, f, "Can't DirMove - falling back to file moves: %v", err)
}
// Load the directory tree into memory
@@ -2653,18 +2653,18 @@ func skipDestructiveChoose(ctx context.Context, subject any, action string) (ski
case 's':
skip = true
skipped[action] = true
fs.Logf(nil, "Skipping all %s operations from now on without asking", action)
fs.LogfCtx(ctx, nil, "Skipping all %s operations from now on without asking", action)
case '!':
skip = false
skipped[action] = false
fs.Logf(nil, "Doing all %s operations from now on without asking", action)
fs.LogfCtx(ctx, nil, "Doing all %s operations from now on without asking", action)
case 'q':
fs.Logf(nil, "Quitting rclone now")
fs.LogfCtx(ctx, nil, "Quitting rclone now")
atexit.Run()
os.Exit(0)
default:
skip = true
fs.Errorf(nil, "Bad choice %c", i)
fs.ErrorfCtx(ctx, nil, "Bad choice %c", i)
}
return skip
}
@@ -2704,9 +2704,9 @@ func SkipDestructive(ctx context.Context, subject any, action string) (skip bool
size = do.Size()
}
if size >= 0 {
fs.Logf(subject, "Skipped %s as %s is set (size %v)", fs.LogValue("skipped", action), flag, fs.LogValue("size", fs.SizeSuffix(size)))
fs.LogfCtx(ctx, subject, "Skipped %s as %s is set (size %v)", fs.LogValue("skipped", action), flag, fs.LogValue("size", fs.SizeSuffix(size)))
} else {
fs.Logf(subject, "Skipped %s as %s is set", fs.LogValue("skipped", action), flag)
fs.LogfCtx(ctx, subject, "Skipped %s as %s is set", fs.LogValue("skipped", action), flag)
}
}
return skip
@@ -2794,7 +2794,7 @@ func CopyDirMetadata(ctx context.Context, f fs.Fs, dst fs.Directory, dir string,
return nil, err
}
accounting.Stats(ctx).UpdatedDirs(1)
fs.Infof(logName, "Updated directory metadata")
fs.InfofCtx(ctx, logName, "Updated directory metadata")
return newDst, nil
}
@@ -2810,7 +2810,7 @@ func SetDirModTime(ctx context.Context, f fs.Fs, dst fs.Directory, dir string, m
logName := dirName(f, dst, dir)
ci := fs.GetConfig(ctx)
if ci.NoUpdateDirModTime {
fs.Debugf(logName, "Skipping set directory modification time as --no-update-dir-modtime is set")
fs.DebugfCtx(ctx, logName, "Skipping set directory modification time as --no-update-dir-modtime is set")
return nil, nil
}
tr := accounting.Stats(ctx).NewCheckingTransferNoHistory(dirTransferEntry(f, dst, dir), "setting modtime")
@@ -2837,7 +2837,7 @@ func SetDirModTime(ctx context.Context, f fs.Fs, dst fs.Directory, dir string, m
return dst, err
} else {
accounting.Stats(ctx).UpdatedDirs(1)
fs.Infof(logName, "Set directory modification time (using SetModTime)")
fs.InfofCtx(ctx, logName, "Set directory modification time (using SetModTime)")
return dst, nil
}
}
@@ -2850,7 +2850,7 @@ func SetDirModTime(ctx context.Context, f fs.Fs, dst fs.Directory, dir string, m
return dst, err
}
accounting.Stats(ctx).UpdatedDirs(1)
fs.Infof(logName, "Set directory modification time (using DirSetModTime)")
fs.InfofCtx(ctx, logName, "Set directory modification time (using DirSetModTime)")
return dst, nil
}
+1 -1
View File
@@ -314,7 +314,7 @@ func rcSingleCommand(ctx context.Context, in rc.Params, name string, noRemote bo
if err != nil {
return nil, err
}
fs.Debugf(obj, "Upload Succeeded")
fs.DebugfCtx(ctx, obj, "Upload Succeeded")
}
}
}
+3 -3
View File
@@ -149,7 +149,7 @@ func (h *ReOpen) open() error {
}
if h.err != nil {
if h.tries > 1 {
fs.Debugf(h.src, "Reopen failed after offset %d bytes read: %v", h.offset, h.err)
fs.DebugfCtx(h.ctx, h.src, "Reopen failed after offset %d bytes read: %v", h.offset, h.err)
}
return h.err
}
@@ -194,7 +194,7 @@ func (h *ReOpen) Read(p []byte) (n int, err error) {
// re-open if seek needed
if h.newOffset >= 0 {
if h.offset != h.newOffset {
fs.Debugf(h.src, "Seek from %d to %d", h.offset, h.newOffset)
fs.DebugfCtx(h.ctx, h.src, "Seek from %d to %d", h.offset, h.newOffset)
h.offset = h.newOffset
err = h.reopen()
if err != nil {
@@ -214,7 +214,7 @@ func (h *ReOpen) Read(p []byte) (n int, err error) {
if err != nil && err != io.EOF {
h.err = err
if !fserrors.IsNoLowLevelRetryError(err) {
fs.Debugf(h.src, "Reopening on read failure after offset %d bytes: retry %d/%d: %v", h.offset, h.tries, h.maxTries, err)
fs.DebugfCtx(h.ctx, h.src, "Reopening on read failure after offset %d bytes: retry %d/%d: %v", h.offset, h.tries, h.maxTries, err)
if h.reopen() == nil {
err = nil
}
+176 -5
View File
@@ -7,6 +7,8 @@ import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"math"
"net/http"
"runtime/debug"
"slices"
@@ -19,6 +21,7 @@ import (
"github.com/rclone/rclone/fs"
"github.com/rclone/rclone/fs/accounting"
"github.com/rclone/rclone/fs/cache"
"github.com/rclone/rclone/fs/log"
"github.com/rclone/rclone/fs/rc"
"golang.org/x/sync/errgroup"
)
@@ -27,6 +30,9 @@ import (
func init() {
cache.JobOnFinish = OnFinish
cache.JobGetJobID = GetJobID
// Attribute logs to the rc job which made them
fs.JobIDFromContext = GetJobID
}
// Job describes an asynchronous task started via the rc package
@@ -49,12 +55,52 @@ type Job struct {
// the real error to the upper application layers while still printing the
// string error message.
realErr error
logsOpt logsOpt // how the logs were asked for with _logs
logStart int64 // sequence number in log.Recent when the job started
logEnd int64 // sequence number in log.Recent when the job finished
}
// logs returns the logs made while the job was running so far or nil
// if they weren't asked for with _logs.
//
// It returns the logs from sequence number since, or from the start
// of the job if since is before that, and at most limit entries if
// limit > 0.
//
// This returns the logs attributed to the job and the logs which
// aren't attributed to anything. As not all logs are attributed, the
// latter may include logs from anything else which was running at the
// same time.
//
// Call with job.mu held.
func (job *Job) logs(since int64, limit int) rc.Params {
if !job.logsOpt.wanted {
return nil
}
// Never read from before the job started, so since 0 means
// the start of the job and lost counts what the job made.
since = max(since, job.logStart)
logEnd := int64(math.MaxInt64)
if job.Finished {
logEnd = job.logEnd
}
// Return the job's own logs, and any which aren't attributed
// unless they were turned off with _logs unattributed
filter := log.Filter{Level: job.logsOpt.level, JobID: job.ID, Unattributed: job.logsOpt.unattributed}
entries, next, lost := log.Recent.Get(since, logEnd, filter, limit)
return rc.Params{
"entries": entries,
"next": next,
"lost": lost,
}
}
// mark the job as finished
func (job *Job) finish(out rc.Params, err error) {
job.mu.Lock()
job.EndTime = time.Now()
job.logEnd = log.Recent.Seq()
if out == nil {
out = make(rc.Params)
}
@@ -108,11 +154,14 @@ func (job *Job) OnFinish(fn func()) func() {
func (job *Job) run(ctx context.Context, fn rc.Func, in rc.Params) {
defer func() {
if r := recover(); r != nil {
stack := debug.Stack()
// Finish the job before logging the stack trace so the
// log is outside the window returned by _logs.
job.finish(nil, fmt.Errorf("panic received: %v", r))
// Log the full stack trace server-side only - it must not
// be returned to the rc caller as it leaks internal paths,
// dependency versions and memory addresses.
fs.Errorf(nil, "rc: job %d panic: %v\n%s", job.ID, r, string(debug.Stack()))
job.finish(nil, fmt.Errorf("panic received: %v", r))
fs.Errorf(nil, "rc: job %d panic: %v\n%s", job.ID, r, string(stack))
}
}()
job.finish(fn(ctx, in))
@@ -250,8 +299,79 @@ type jobKeyType struct{}
// Key for adding jobs to ctx
var jobKey = jobKeyType{}
// LogsRequested returns true if in has _logs set to anything other
// than false.
//
// This doesn't check the value of _logs is valid.
func LogsRequested(in rc.Params) bool {
wantLogs, err := in.GetBool("_logs")
if rc.IsErrParamNotFound(err) {
return false
}
return err != nil || wantLogs
}
// logsOpt is how the logs of a job were asked for with _logs.
type logsOpt struct {
wanted bool
level slog.Level
unattributed bool // return the logs which aren't attributed to a job too
}
// logsOptJSON is the object form of the _logs parameter.
type logsOptJSON struct {
Level string `json:"level"`
Unattributed *bool `json:"unattributed"`
}
// See if _logs is set returning how the logs were asked for.
//
// _logs can be a boolean, a log level, e.g. "INFO", or an object,
// e.g. {"level": "INFO", "unattributed": false}.
func getLogs(in rc.Params) (opt logsOpt, err error) {
opt = logsOpt{
wanted: LogsRequested(in),
level: slog.LevelDebug,
unattributed: true,
}
_, boolErr := in.GetBool("_logs")
levelString, levelErr := in.GetString("_logs")
var optJSON logsOptJSON
structErr := in.GetStruct("_logs", &optJSON)
delete(in, "_logs") // remove the logs parameter after parsing
if !opt.wanted {
return logsOpt{}, nil
}
if !log.Recent.Enabled() {
return logsOpt{}, log.ErrBufferDisabled
}
// _logs is true, a level or an object here
if boolErr != nil {
if structErr == nil && (optJSON.Level != "" || optJSON.Unattributed != nil) {
levelString = optJSON.Level
if optJSON.Unattributed != nil {
opt.unattributed = *optJSON.Unattributed
}
if levelString == "" {
return opt, nil
}
} else if levelErr != nil {
return logsOpt{}, levelErr
}
opt.level, err = log.ParseLevel(levelString)
if err != nil {
return logsOpt{}, rc.NewErrParamInvalid(fmt.Errorf("_logs must be a boolean, a log level or an object: %w", err))
}
if opt.level >= fs.SlogLevelOff {
return logsOpt{}, rc.NewErrParamInvalid(fmt.Errorf("_logs level can't be %q", levelString))
}
}
return opt, nil
}
// NewJob creates a Job and executes it, possibly in the background if _async is set
func (jobs *Jobs) NewJob(ctx context.Context, fn rc.Func, in rc.Params) (job *Job, out rc.Params, err error) {
logStart := log.Recent.Seq() // before anything is logged for this job
id := jobID.Add(1)
in = in.Copy() // copy input so we can change it
@@ -275,6 +395,11 @@ func (jobs *Jobs) NewJob(ctx context.Context, fn rc.Func, in rc.Params) (job *Jo
return nil, nil, err
}
logsOpt, err := getLogs(in)
if err != nil {
return nil, nil, err
}
ctx, cancel := context.WithCancel(ctx)
stop := func() {
cancel()
@@ -287,6 +412,8 @@ func (jobs *Jobs) NewJob(ctx context.Context, fn rc.Func, in rc.Params) (job *Jo
Group: group,
StartTime: time.Now(),
Stop: stop,
logsOpt: logsOpt,
logStart: logStart,
}
jobs.mu.Lock()
@@ -309,6 +436,18 @@ func (jobs *Jobs) NewJob(ctx context.Context, fn rc.Func, in rc.Params) (job *Jo
job.run(ctx, fn, in)
out = job.Output
err = job.realErr
if logsOpt.wanted {
job.mu.Lock()
logs := job.logs(-1, 0)
job.mu.Unlock()
if err != nil {
err = rc.NewErrorWithLogs(err, logs)
} else {
// Don't add the logs to job.Output
out = out.Copy()
out["_logs"] = logs
}
}
}
return job, out, err
}
@@ -352,6 +491,10 @@ func init() {
Help: `Parameters:
- jobid - id of the job (integer).
- since - return logs with sequence numbers starting from this - optional
- Defaults to the start of the job, and is clamped to it, so the
logs made before the job started are never returned.
- limit - maximum number of log entries to return - optional, defaults to 1000, 0 for no limit
Results:
@@ -365,6 +508,12 @@ Results:
- startTime - time the job started (e.g. "2018-10-26T18:50:20.528336039+01:00")
- success - boolean - true for success false otherwise
- output - output of the job as would have been returned if called synchronously
- _logs - the logs of the job if it was started with _logs
- entries - array of log entries, oldest first - the job's own logs, and
the logs made while it was running which aren't attributed to a job
unless they were turned off with the _logs unattributed parameter
- next - pass this as since to carry on reading the logs from where this call finished
- lost - the number of log entries which had already been dropped from the log buffer
- progress - output of the progress related to the underlying job
`,
})
@@ -387,6 +536,28 @@ func rcJobStatus(ctx context.Context, in rc.Params) (out rc.Params, err error) {
if err != nil {
return nil, fmt.Errorf("reshape failed in job status: %w", err)
}
if job.logsOpt.wanted {
since, err := in.GetInt64("since")
if rc.IsErrParamNotFound(err) {
since = -1
} else if err != nil {
return nil, err
}
limit, err := in.GetInt64("limit")
if rc.IsErrParamNotFound(err) {
limit = 1000
} else if err != nil {
return nil, err
}
// Put the logs in the output so it is the same as would
// have been returned if called synchronously
output, ok := out["output"].(map[string]any)
if !ok {
output = map[string]any{}
}
output["_logs"] = job.logs(since, int(min(limit, math.MaxInt32)))
out["output"] = output
}
return out, nil
}
@@ -518,7 +689,7 @@ func NewJobFromParams(ctx context.Context, in rc.Params) (out rc.Params) {
}
}
fs.Debugf(nil, "rc: %q: with parameters %+v", path, in)
rc.LogCall("rc: %q: with parameters %+v", path, in)
_, out, err = NewJob(ctx, call.Fn, in)
if err != nil {
return rcError(err, http.StatusInternalServerError)
@@ -527,7 +698,7 @@ func NewJobFromParams(ctx context.Context, in rc.Params) (out rc.Params) {
out = make(rc.Params)
}
fs.Debugf(nil, "rc: %q: reply %+v: %v", path, out, err)
rc.LogCall("rc: %q: reply %+v: %v", path, out, err)
return out
}
@@ -576,7 +747,7 @@ This takes the following parameters:
}
|||
The inputs may use |_async|, |_group|, |_config| and |_filter| as normal when using the rc.
The inputs may use |_async|, |_group|, |_config|, |_filter| and |_logs| as normal when using the rc.
Returns:
+330
View File
@@ -4,6 +4,8 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"runtime"
"testing"
"time"
@@ -11,6 +13,7 @@ import (
"github.com/rclone/rclone/fs"
"github.com/rclone/rclone/fs/accounting"
"github.com/rclone/rclone/fs/filter"
"github.com/rclone/rclone/fs/log"
"github.com/rclone/rclone/fs/rc"
"github.com/rclone/rclone/fstest/testy"
"github.com/stretchr/testify/assert"
@@ -1089,3 +1092,330 @@ func TestJobsBatchConcurrent(t *testing.T) {
}
}
// setLogBuffer sets the size of the log buffer for the duration of the test.
func setLogBuffer(t *testing.T, size fs.SizeSuffix) {
setSize := func(size fs.SizeSuffix) {
call := rc.Calls.Get("options/set")
require.NotNil(t, call)
_, err := call.Fn(context.Background(), rc.Params{"log": rc.Params{"BufferSize": size.String()}})
require.NoError(t, err)
}
oldSize := log.Opt.BufferSize
setSize(size)
t.Cleanup(func() { setSize(oldSize) })
}
// logMessages returns the msg fields of the log entries in logs.
func logMessages(t *testing.T, logs any) (msgs []string) {
t.Helper()
require.NotNil(t, logs)
// Reshape so this works on direct output and output via JSON
var decoded struct {
Entries []struct {
Msg string `json:"msg"`
} `json:"entries"`
Next *int64 `json:"next"`
Lost *int64 `json:"lost"`
}
require.NoError(t, rc.Reshape(&decoded, logs))
require.NotNil(t, decoded.Lost)
assert.Equal(t, int64(0), *decoded.Lost)
require.NotNil(t, decoded.Next)
for _, entry := range decoded.Entries {
msgs = append(msgs, entry.Msg)
}
return msgs
}
func TestExecuteJobWithLogs(t *testing.T) {
ctx := context.Background()
jobID.Store(0)
var got rc.Params
jobFn := func(ctx context.Context, in rc.Params) (rc.Params, error) {
got = in
fs.Logf(nil, "notice from job")
fs.Errorf(nil, "error from job")
return rc.Params{"result": "OK"}, nil
}
// Fails if the log buffer isn't enabled
setLogBuffer(t, 0)
_, _, err := NewJob(ctx, jobFn, rc.Params{"_logs": true})
assert.Equal(t, log.ErrBufferDisabled, err)
assert.True(t, rc.IsErrParamInvalid(err), err)
setLogBuffer(t, 1*fs.Mebi)
fs.Logf(nil, "before job")
// No logs unless asked for
_, out, err := NewJob(ctx, jobFn, rc.Params{})
require.NoError(t, err)
assert.NotContains(t, out, "_logs")
_, out, err = NewJob(ctx, jobFn, rc.Params{"_logs": false})
require.NoError(t, err)
assert.NotContains(t, out, "_logs")
// All the logs
job, out, err := NewJob(ctx, jobFn, rc.Params{"_logs": true})
require.NoError(t, err)
assert.Equal(t, "OK", out["result"])
assert.Equal(t, []string{"notice from job", "error from job"}, logMessages(t, out["_logs"]))
assert.NotContains(t, got, "_logs", "_logs should have been removed from in")
assert.NotContains(t, job.Output, "_logs", "_logs should not be in the job output")
// As passed by rclone rc _logs=true
_, out, err = NewJob(ctx, jobFn, rc.Params{"_logs": "true"})
require.NoError(t, err)
assert.Equal(t, []string{"notice from job", "error from job"}, logMessages(t, out["_logs"]))
// Logs filtered by level
_, out, err = NewJob(ctx, jobFn, rc.Params{"_logs": "ERROR"})
require.NoError(t, err)
assert.Equal(t, []string{"error from job"}, logMessages(t, out["_logs"]))
// Bad level
_, _, err = NewJob(ctx, jobFn, rc.Params{"_logs": "potato"})
require.Error(t, err)
assert.True(t, rc.IsErrParamInvalid(err), err)
// OFF isn't a valid level for _logs
_, _, err = NewJob(ctx, jobFn, rc.Params{"_logs": "OFF"})
require.Error(t, err)
assert.True(t, rc.IsErrParamInvalid(err), err)
// The object form gives control over what is returned
_, out, err = NewJob(ctx, jobFn, rc.Params{"_logs": rc.Params{"level": "ERROR"}})
require.NoError(t, err)
assert.Equal(t, []string{"error from job"}, logMessages(t, out["_logs"]))
// Unattributed logs can be left out
unattributedFn := func(ctx context.Context, in rc.Params) (rc.Params, error) {
fs.LogfCtx(ctx, nil, "attributed")
fs.Logf(nil, "unattributed")
return nil, nil
}
_, out, err = NewJob(ctx, unattributedFn, rc.Params{"_logs": true})
require.NoError(t, err)
assert.Equal(t, []string{"attributed", "unattributed"}, logMessages(t, out["_logs"]))
_, out, err = NewJob(ctx, unattributedFn, rc.Params{"_logs": rc.Params{"unattributed": false}})
require.NoError(t, err)
assert.Equal(t, []string{"attributed"}, logMessages(t, out["_logs"]))
_, out, err = NewJob(ctx, unattributedFn, rc.Params{"_logs": rc.Params{"level": "NOTICE", "unattributed": true}})
require.NoError(t, err)
assert.Equal(t, []string{"attributed", "unattributed"}, logMessages(t, out["_logs"]))
// A bad level in the object form is an error
_, _, err = NewJob(ctx, jobFn, rc.Params{"_logs": rc.Params{"level": "potato"}})
require.Error(t, err)
assert.True(t, rc.IsErrParamInvalid(err), err)
// Logs made while parsing the request are included
_, out, err = NewJob(ctx, jobFn, rc.Params{
"_logs": "ERROR",
"_filter": rc.Params{"IncludeRule": []string{"*.jpg"}, "ExcludeRule": []string{"*.png"}},
})
require.NoError(t, err)
msgs := logMessages(t, out["_logs"])
require.Len(t, msgs, 2)
assert.Contains(t, msgs[0], "Using --filter is recommended")
assert.Equal(t, "error from job", msgs[1])
}
func TestLogsRequested(t *testing.T) {
for _, test := range []struct {
in rc.Params
want bool
}{
{rc.Params{}, false},
{rc.Params{"_logs": false}, false},
{rc.Params{"_logs": "false"}, false},
{rc.Params{"_logs": true}, true},
{rc.Params{"_logs": "true"}, true},
{rc.Params{"_logs": "INFO"}, true},
{rc.Params{"_logs": "potato"}, true},
{rc.Params{"_logs": 42}, true},
} {
assert.Equal(t, test.want, LogsRequested(test.in), fmt.Sprintf("%v", test.in))
}
}
func TestExecuteJobWithLogsError(t *testing.T) {
ctx := context.Background()
jobID.Store(0)
setLogBuffer(t, 1*fs.Mebi)
testErr := rc.NewErrParamInvalid(errors.New("test error"))
errorFn := func(ctx context.Context, in rc.Params) (out rc.Params, err error) {
fs.Errorf(nil, "error from job")
return nil, testErr
}
in := rc.Params{"_logs": true}
_, _, err := NewJob(ctx, errorFn, in)
require.Error(t, err)
assert.True(t, errors.Is(err, testErr))
assert.Equal(t, "test error", err.Error())
// Check the logs end up in the error response with the status intact
out, status := rc.Error("test/path", in, err, http.StatusInternalServerError)
assert.Equal(t, http.StatusBadRequest, status)
assert.Equal(t, "test error", out["error"])
assert.Equal(t, []string{"error from job"}, logMessages(t, out["_logs"]))
// and via NewJobFromParams as used by job/batch and librclone
call := rc.Calls.Get("rc/error")
require.NotNil(t, call)
out = NewJobFromParams(ctx, rc.Params{"_path": "rc/error", "_logs": true})
assert.Contains(t, out, "error")
assert.Contains(t, out, "_logs")
}
// The stack trace from a panic must not be returned to the caller
func TestExecuteJobWithLogsPanic(t *testing.T) {
ctx := context.Background()
jobID.Store(0)
setLogBuffer(t, 1*fs.Mebi)
panicFn := func(ctx context.Context, in rc.Params) (out rc.Params, err error) {
fs.Errorf(nil, "before panic")
panic("boom")
}
_, _, err := NewJob(ctx, panicFn, rc.Params{"_logs": true})
require.Error(t, err)
assert.Contains(t, err.Error(), "panic received: boom")
out, _ := rc.Error("test/path", rc.Params{}, err, http.StatusInternalServerError)
assert.Equal(t, []string{"before panic"}, logMessages(t, out["_logs"]))
}
func TestRcJobStatusWithLogs(t *testing.T) {
ctx := context.Background()
jobID.Store(0)
setLogBuffer(t, 1*fs.Mebi)
started := make(chan struct{})
finish := make(chan struct{})
jobFn := func(ctx context.Context, in rc.Params) (rc.Params, error) {
fs.Logf(nil, "job started")
close(started)
<-finish
fs.Logf(nil, "job finished")
return nil, nil
}
// Logs made before the job starts must never be returned by it
fs.Logf(nil, "before the job")
job, out, err := NewJob(ctx, jobFn, rc.Params{"_async": true, "_logs": true})
require.NoError(t, err)
assert.NotContains(t, out, "_logs")
<-started
call := rc.Calls.Get("job/status")
require.NotNil(t, call)
status := func(extra ...any) rc.Params {
in := rc.Params{"jobid": job.ID}
for i := 0; i < len(extra); i += 2 {
in[extra[i].(string)] = extra[i+1]
}
out, err := call.Fn(ctx, in)
require.NoError(t, err)
return out
}
// logs returns the _logs from the output of the status
logs := func(out rc.Params) rc.Params {
output, ok := out["output"].(map[string]any)
require.True(t, ok, "output should be a map")
return output["_logs"].(rc.Params)
}
// Logs so far while the job is running
out = status()
assert.Equal(t, false, out["finished"])
assert.Equal(t, []string{"job started"}, logMessages(t, logs(out)))
// since before the job starts at the job, so the logs made
// before it aren't returned and aren't counted as lost
out = status("since", 0)
assert.Equal(t, []string{"job started"}, logMessages(t, logs(out)))
// Carry on from next which should return nothing new
next := logs(out)["next"].(int64)
out = status("since", next)
assert.Empty(t, logMessages(t, logs(out)))
assert.Equal(t, next, logs(out)["next"])
// Check limit
fs.Logf(nil, "job continuing")
out = status("limit", 1)
assert.Equal(t, []string{"job started"}, logMessages(t, logs(out)))
out = status("since", logs(out)["next"])
assert.Equal(t, []string{"job continuing"}, logMessages(t, logs(out)))
finished := make(chan struct{})
job.OnFinish(func() { close(finished) })
close(finish)
<-finished
fs.Logf(nil, "after job")
// Only the logs made while the job was running once finished
out = status()
assert.Equal(t, true, out["finished"])
assert.Equal(t, []string{"job started", "job continuing", "job finished"}, logMessages(t, logs(out)))
// No logs in the status if not asked for
job, _, err = NewJob(ctx, func(ctx context.Context, in rc.Params) (rc.Params, error) {
return nil, nil
}, rc.Params{"_async": true})
require.NoError(t, err)
out = status()
output, _ := out["output"].(map[string]any)
assert.NotContains(t, output, "_logs")
}
// Logs attributed to other jobs running at the same time aren't returned
func TestExecuteJobWithLogsAttributed(t *testing.T) {
ctx := context.Background()
jobID.Store(0)
setLogBuffer(t, 1*fs.Mebi)
aStarted := make(chan struct{})
bLogged := make(chan struct{})
aLogged := make(chan struct{})
aFn := func(ctx context.Context, in rc.Params) (rc.Params, error) {
fs.LogfCtx(ctx, nil, "A1")
close(aStarted)
<-bLogged
fs.LogfCtx(ctx, nil, "A2")
close(aLogged)
return nil, nil
}
bFn := func(ctx context.Context, in rc.Params) (rc.Params, error) {
fs.LogfCtx(ctx, nil, "B1")
fs.Logf(nil, "unattributed")
close(bLogged)
<-aLogged
fs.LogfCtx(ctx, nil, "B2")
return nil, nil
}
// Both jobs are in the same stats group so this checks the
// logs are attributed to the job, not just the group
jobA, _, err := NewJob(ctx, aFn, rc.Params{"_async": true, "_logs": true, "_group": "shared"})
require.NoError(t, err)
finished := make(chan struct{})
jobA.OnFinish(func() { close(finished) })
<-aStarted
// Job B runs while job A is running
_, out, err := NewJob(ctx, bFn, rc.Params{"_logs": true, "_group": "shared"})
require.NoError(t, err)
assert.Equal(t, []string{"B1", "unattributed", "B2"}, logMessages(t, out["_logs"]))
<-finished
call := rc.Calls.Get("job/status")
require.NotNil(t, call)
out, err = call.Fn(ctx, rc.Params{"jobid": jobA.ID})
require.NoError(t, err)
output, ok := out["output"].(map[string]any)
require.True(t, ok)
assert.Equal(t, []string{"A1", "unattributed", "A2"}, logMessages(t, output["_logs"]))
}
+28
View File
@@ -66,6 +66,26 @@ func IsErrParamInvalid(err error) bool {
return isInvalid
}
// ErrorWithLogs is returned from a job started with the _logs
// parameter which failed.
//
// Error will add Logs to the error response as _logs provided this
// is the outermost error.
type ErrorWithLogs struct {
error
Logs Params // logs made while the job was running
}
// NewErrorWithLogs returns a new ErrorWithLogs from err and logs
func NewErrorWithLogs(err error, logs Params) *ErrorWithLogs {
return &ErrorWithLogs{error: err, Logs: logs}
}
// Unwrap returns the underlying error
func (e *ErrorWithLogs) Unwrap() error {
return e.error
}
// Reshape reshapes one blob of data into another via json serialization
//
// out should be a pointer type
@@ -311,6 +331,11 @@ func (p Params) GetFsDuration(key string) (fs.Duration, error) {
//
// It returns a Params and an updated status code
func Error(path string, in Params, err error, status int) (Params, int) {
var logs Params
if logsErr, ok := err.(*ErrorWithLogs); ok {
logs = logsErr.Logs
err = logsErr.error
}
// Adjust the status code for some well known errors
switch {
case errors.Is(err, fs.ErrorDirNotFound) || errors.Is(err, fs.ErrorObjectNotFound):
@@ -324,5 +349,8 @@ func Error(path string, in Params, err error, status int) (Params, int) {
"input": in,
"path": path,
}
if logs != nil {
result["_logs"] = logs
}
return result, status
}
+29 -1
View File
@@ -8,9 +8,11 @@
package rc
import (
"context"
"encoding/json"
"io"
_ "net/http/pprof" // install the pprof http handlers
"sync/atomic"
"time"
"github.com/rclone/rclone/fs"
@@ -43,6 +45,11 @@ var OptionsInfo = fs.Options{{
Default: false,
Help: "Don't require auth for certain methods",
Groups: "RC",
}, {
Name: "rc_log_calls",
Default: true,
Help: "Log each rc call and its reply at DEBUG level",
Groups: "RC",
}, {
Name: "rc_web_gui",
Default: false,
@@ -99,7 +106,27 @@ var OptionsInfo = fs.Options{{
SetDefault("metrics_addr", []string{})
func init() {
fs.RegisterGlobalOptions(fs.OptionsInfo{Name: "rc", Opt: &Opt, Options: OptionsInfo})
fs.RegisterGlobalOptions(fs.OptionsInfo{Name: "rc", Opt: &Opt, Options: OptionsInfo, Reload: optReload})
}
// logCalls is Opt.LogCalls, kept here so it can be read without
// racing with options/set changing the options.
var logCalls atomic.Bool
// optReload makes the options which can be changed at runtime live.
func optReload(ctx context.Context) error {
logCalls.Store(Opt.LogCalls)
return nil
}
// LogCall logs an rc call at DEBUG level unless --rc-log-calls=false.
//
// This is used for the logs of each call and its reply which are
// noise for a client reading the logs over the rc.
func LogCall(text string, args ...any) {
if logCalls.Load() {
fs.Debugf(nil, text, args...)
}
}
// Options contains options for the remote control server
@@ -112,6 +139,7 @@ type Options struct {
Serve bool `config:"rc_serve"` // set to serve files from remotes
ServeNoModTime bool `config:"rc_serve_no_modtime"` // don't read the modification time
NoAuth bool `config:"rc_no_auth"` // set to disable auth checks on methods which require it
LogCalls bool `config:"rc_log_calls"` // set to log each rc call and its reply at DEBUG level
WebUI bool `config:"rc_web_gui"` // set to launch the web ui **DEPRECATED**
WebGUIUpdate bool `config:"rc_web_gui_update"` // set to check new update **DEPRECATED**
WebGUIForceUpdate bool `config:"rc_web_gui_force_update"` // set to force download new update **DEPRECATED**
+41
View File
@@ -2,8 +2,13 @@ package rc
import (
"bytes"
"context"
"io"
"log/slog"
"testing"
"github.com/rclone/rclone/fs"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -21,3 +26,39 @@ func TestWriteJSON(t *testing.T) {
}
`, buf.String())
}
// Check --rc-log-calls can be turned off and on again with options/set
func TestLogCall(t *testing.T) {
ctx := context.Background()
var buf bytes.Buffer
oldLevel := fs.GetConfig(ctx).LogLevel
fs.GetConfig(ctx).LogLevel = fs.LogLevelDebug
fs.SetLogger(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
defer func() {
fs.GetConfig(ctx).LogLevel = oldLevel
fs.SetLogger(slog.NewTextHandler(io.Discard, nil))
}()
set := func(logCalls bool) {
call := Calls.Get("options/set")
require.NotNil(t, call)
_, err := call.Fn(ctx, Params{"rc": Params{"LogCalls": logCalls}})
require.NoError(t, err)
}
defer set(true)
// Logged by default
LogCall("rc: %s: called", "core/stats")
assert.Contains(t, buf.String(), "rc: core/stats: called")
// Not logged when turned off
set(false)
buf.Reset()
LogCall("rc: %s: called", "core/stats")
assert.Equal(t, "", buf.String())
// and logged again when turned back on
set(true)
LogCall("rc: %s: called", "core/stats")
assert.Contains(t, buf.String(), "rc: core/stats: called")
}
+25 -2
View File
@@ -254,6 +254,24 @@ func (s *Server) handlePost(w http.ResponseWriter, r *http.Request, path string)
return
}
// _logs returns the logs which aren't attributed to a job as well as
// the job's own, so it can show the logs of anything else running.
// Don't let unauthenticated users read them via the calls which
// don't need authentication.
if jobs.LogsRequested(in) && !s.noAuth && !s.server.UsingAuth() {
writeError(path, in, w, errors.New("authentication must be set up on the rc server to use _logs or the --rc-no-auth flag must be in use"), http.StatusForbidden)
return
}
// A call which writes the response itself can't be run in the
// background as the response is gone by the time it runs.
if call.WritesResponse {
if async, err := in.GetBool("_async"); err == nil && async {
writeError(path, in, w, fmt.Errorf("can't use _async with %q", path), http.StatusBadRequest)
return
}
}
inOrig := in.Copy()
if call.NeedsRequest {
@@ -265,7 +283,7 @@ func (s *Server) handlePost(w http.ResponseWriter, r *http.Request, path string)
in["_response"] = w
}
fs.Debugf(nil, "rc: %q: with parameters %+v", path, in)
rc.LogCall("rc: %q: with parameters %+v", path, in)
job, out, err := jobs.NewJob(ctx, call.Fn, in)
if job != nil {
w.Header().Add("x-rclone-jobid", fmt.Sprintf("%d", job.ID))
@@ -278,7 +296,12 @@ func (s *Server) handlePost(w http.ResponseWriter, r *http.Request, path string)
out = make(rc.Params)
}
fs.Debugf(nil, "rc: %q: reply %+v: %v", path, out, err)
if call.WritesResponse {
// The call has written the response itself
return
}
rc.LogCall("rc: %q: reply %+v: %v", path, out, err)
w.Header().Set("Content-Type", "application/json")
if preferAsync {
w.Header().Set("Preference-Applied", "respond-async")
+70
View File
@@ -910,6 +910,50 @@ func TestAuthRequired(t *testing.T) {
testServer(t, tests, &opt)
}
// _logs returns the logs which aren't attributed to a job as well as the
// job's own, so it mustn't be usable without authentication on the calls
// which don't need authentication.
func TestLogsAuthRequired(t *testing.T) {
tests := []testRun{{
Name: "logs",
URL: "rc/noop",
Method: "POST",
Body: `{"_logs":true}`,
ContentType: "application/json",
Status: http.StatusForbidden,
Expected: `{
"error": "authentication must be set up on the rc server to use _logs or the --rc-no-auth flag must be in use",
"input": {
"_logs": true
},
"path": "rc/noop",
"status": 403
}
`,
}, {
Name: "no-logs",
URL: "rc/noop",
Method: "POST",
Body: `{}`,
ContentType: "application/json",
Status: http.StatusOK,
Expected: "{}\n",
}, {
Name: "logs-false",
URL: "rc/noop",
Method: "POST",
Body: `{"_logs":false}`,
ContentType: "application/json",
Status: http.StatusOK,
Expected: "{}\n",
}}
opt := newTestOpt()
opt.Serve = false
opt.Files = ""
opt.NoAuth = false
testServer(t, tests, &opt)
}
// job/status returns the output of any job so it must need
// authentication, e.g. so an unauthenticated user can't read the
// output of config/dump started with _async by an authenticated one.
@@ -1205,3 +1249,29 @@ func normalizeJSON(t *testing.T, jsonStr string) string {
require.NoError(t, err, "JSON marshalling failed")
return string(normalizedJSON)
}
// A call which writes the response itself can't be run in the background
func TestWritesResponseAsync(t *testing.T) {
tests := []testRun{{
Name: "async",
URL: "core/events",
Method: "POST",
Body: `{"_async":true}`,
ContentType: "application/json",
Status: http.StatusBadRequest,
Expected: `{
"error": "can't use _async with \"core/events\"",
"input": {
"_async": true
},
"path": "core/events",
"status": 400
}
`,
}}
opt := newTestOpt()
opt.Serve = false
opt.Files = ""
opt.NoAuth = true
testServer(t, tests, &opt)
}
+3
View File
@@ -22,6 +22,9 @@ type Call struct {
Help string // multi-line markdown formatted help
NeedsRequest bool // if set then this call will be passed the original request object as _request
NeedsResponse bool // if set then this call will be passed the original response object as _response
// if set then this call writes the HTTP response itself, so the
// server doesn't write the usual JSON reply after it
WritesResponse bool
}
// Registry holds the list of all the registered remote control functions
+49 -49
View File
@@ -177,7 +177,7 @@ func newSyncCopyMove(ctx context.Context, fdst, fsrc fs.Fs, deleteMode fs.Delete
backlog := ci.MaxBacklog
if s.checkFirst {
fs.Infof(s.fdst, "Running all checks before starting transfers")
fs.InfofCtx(ctx, s.fdst, "Running all checks before starting transfers")
backlog = -1
}
var err error
@@ -195,7 +195,7 @@ func newSyncCopyMove(ctx context.Context, fdst, fsrc fs.Fs, deleteMode fs.Delete
}
if ci.MaxDuration > 0 {
s.maxDurationEndTime = time.Now().Add(time.Duration(ci.MaxDuration))
fs.Infof(s.fdst, "Transfer session %v deadline: %s", ci.CutoffMode, s.maxDurationEndTime.Format("2006/01/02 15:04:05"))
fs.InfofCtx(ctx, s.fdst, "Transfer session %v deadline: %s", ci.CutoffMode, s.maxDurationEndTime.Format("2006/01/02 15:04:05"))
}
// If a max session duration has been defined add a deadline
// to the main context if cutoff mode is hard. This will cut
@@ -218,7 +218,7 @@ func newSyncCopyMove(ctx context.Context, fdst, fsrc fs.Fs, deleteMode fs.Delete
}
if s.noTraverse && s.deleteMode != fs.DeleteModeOff {
if !fi.HaveFilesFrom() {
fs.Errorf(nil, "Ignoring --no-traverse with sync")
fs.ErrorfCtx(ctx, nil, "Ignoring --no-traverse with sync")
}
s.noTraverse = false
}
@@ -240,21 +240,21 @@ func newSyncCopyMove(ctx context.Context, fdst, fsrc fs.Fs, deleteMode fs.Delete
if s.trackRenames {
// Don't track renames for remotes without server-side move support.
if !operations.CanServerSideMove(fdst) {
fs.Errorf(fdst, "Ignoring --track-renames as the destination does not support server-side move or copy")
fs.ErrorfCtx(ctx, fdst, "Ignoring --track-renames as the destination does not support server-side move or copy")
s.trackRenames = false
}
if s.trackRenamesStrategy.hash() && s.commonHash == hash.None {
fs.Errorf(fdst, "Ignoring --track-renames as the source and destination do not have a common hash")
fs.ErrorfCtx(ctx, fdst, "Ignoring --track-renames as the source and destination do not have a common hash")
s.trackRenames = false
}
if s.trackRenamesStrategy.modTime() && s.modifyWindow == fs.ModTimeNotSupported {
fs.Errorf(fdst, "Ignoring --track-renames as either the source or destination do not support modtime")
fs.ErrorfCtx(ctx, fdst, "Ignoring --track-renames as either the source or destination do not support modtime")
s.trackRenames = false
}
if s.deleteMode == fs.DeleteModeOff {
fs.Errorf(fdst, "Ignoring --track-renames as it doesn't work with copy or move, only sync")
fs.ErrorfCtx(ctx, fdst, "Ignoring --track-renames as it doesn't work with copy or move, only sync")
s.trackRenames = false
}
}
@@ -264,7 +264,7 @@ func newSyncCopyMove(ctx context.Context, fdst, fsrc fs.Fs, deleteMode fs.Delete
s.deleteMode = fs.DeleteModeAfter
}
if s.noTraverse {
fs.Errorf(nil, "Ignoring --no-traverse with --track-renames")
fs.ErrorfCtx(ctx, nil, "Ignoring --no-traverse with --track-renames")
s.noTraverse = false
}
}
@@ -324,7 +324,7 @@ func (s *syncCopyMove) processError(err error) {
err = fserrors.NoRetryError(err)
} else if err == accounting.ErrorMaxTransferLimitReachedGraceful {
if s.inCtx.Err() == nil {
fs.Logf(nil, "%v - stopping transfers", err)
fs.LogfCtx(s.ctx, nil, "%v - stopping transfers", err)
// Cancel the march and stop the pipes
s.inCancel()
}
@@ -337,7 +337,7 @@ func (s *syncCopyMove) processError(err error) {
switch {
case fserrors.IsFatalError(err):
if !s.aborting() {
fs.Errorf(nil, "Cancelling sync due to fatal error: %v", err)
fs.ErrorfCtx(s.ctx, nil, "Cancelling sync due to fatal error: %v", err)
s.cancel()
}
s.fatalErr = err
@@ -400,16 +400,16 @@ func (s *syncCopyMove) pairChecker(in *pipe, out *pipe, fraction int, wg *sync.W
// src.Remote() (the correctly-cased name).
if needTransfer {
if _, statErr := s.fdst.NewObject(s.ctx, pair.Dst.Remote()); errors.Is(statErr, fs.ErrorObjectNotFound) {
fs.Debugf(pair.Dst, "Skipping fix-case rename: destination removed for re-upload, will recreate at %s", src.Remote())
fs.DebugfCtx(s.ctx, pair.Dst, "Skipping fix-case rename: destination removed for re-upload, will recreate at %s", src.Remote())
pair.Dst = nil
}
}
if pair.Dst != nil {
if newDst, err := operations.Move(s.ctx, s.fdst, nil, src.Remote(), pair.Dst); err != nil {
fs.Errorf(pair.Dst, "Error while attempting to rename to %s: %v", src.Remote(), err)
fs.ErrorfCtx(s.ctx, pair.Dst, "Error while attempting to rename to %s: %v", src.Remote(), err)
s.processError(err)
} else {
fs.Infof(pair.Dst, "Fixed case by renaming to: %s", src.Remote())
fs.InfofCtx(s.ctx, pair.Dst, "Fixed case by renaming to: %s", src.Remote())
pair.Dst = newDst
}
}
@@ -418,7 +418,7 @@ func (s *syncCopyMove) pairChecker(in *pipe, out *pipe, fraction int, wg *sync.W
// If files are treated as immutable, fail if destination exists and does not match
if s.ci.Immutable && pair.Dst != nil {
err := fs.CountError(s.ctx, fserrors.NoRetryError(fs.ErrorImmutableModified))
fs.Errorf(pair.Dst, "Source and destination exist but do not match: %v", err)
fs.ErrorfCtx(s.ctx, pair.Dst, "Source and destination exist but do not match: %v", err)
s.processError(err)
} else {
if pair.Dst != nil {
@@ -452,9 +452,9 @@ func (s *syncCopyMove) pairChecker(in *pipe, out *pipe, fraction int, wg *sync.W
if s.DoMove {
// Delete src if no error on copy
if operations.SameObject(src, pair.Dst) {
fs.Logf(src, "Not removing source file as it is the same file as the destination")
fs.LogfCtx(s.ctx, src, "Not removing source file as it is the same file as the destination")
} else if s.ci.IgnoreExisting {
fs.Debugf(src, "Not removing source file as destination file exists and --ignore-existing is set")
fs.DebugfCtx(s.ctx, src, "Not removing source file as destination file exists and --ignore-existing is set")
} else if s.checkFirst && s.ci.OrderBy != "" {
// If we want perfect ordering then use the transfers to delete the file
//
@@ -487,7 +487,7 @@ func (s *syncCopyMove) pairRenamer(in *pipe, out *pipe, fraction int, wg *sync.W
src := pair.Src
if !s.tryRename(src) {
// pass on if not renamed
fs.Debugf(src, "Need to transfer - No matching file found at Destination")
fs.DebugfCtx(s.ctx, src, "Need to transfer - No matching file found at Destination")
ok = out.Put(s.inCtx, pair)
if !ok {
return
@@ -536,7 +536,7 @@ func (s *syncCopyMove) startCheckers() {
// This stops the background checkers
func (s *syncCopyMove) stopCheckers() {
s.toBeChecked.Close()
fs.Debugf(s.fdst, "Waiting for checks to finish")
fs.DebugfCtx(s.ctx, s.fdst, "Waiting for checks to finish")
s.checkerWg.Wait()
}
@@ -552,7 +552,7 @@ func (s *syncCopyMove) startTransfers() {
// This stops the background transfers
func (s *syncCopyMove) stopTransfers() {
s.toBeUploaded.Close()
fs.Debugf(s.fdst, "Waiting for transfers to finish")
fs.DebugfCtx(s.ctx, s.fdst, "Waiting for transfers to finish")
s.transfersWg.Wait()
}
@@ -574,7 +574,7 @@ func (s *syncCopyMove) stopRenamers() {
return
}
s.toBeRenamed.Close()
fs.Debugf(s.fdst, "Waiting for renames to finish")
fs.DebugfCtx(s.ctx, s.fdst, "Waiting for renames to finish")
s.renamerWg.Wait()
}
@@ -626,7 +626,7 @@ func (s *syncCopyMove) stopDeleters() {
// have been found have been removed from dstFiles already.
func (s *syncCopyMove) deleteFiles(checkSrcMap bool) error {
if accounting.Stats(s.ctx).Errored() && !s.ci.IgnoreErrors {
fs.Errorf(s.fdst, "%v", fs.ErrorNotDeleting)
fs.ErrorfCtx(s.ctx, s.fdst, "%v", fs.ErrorNotDeleting)
// log all deletes as errors
for remote, o := range s.dstFiles {
if checkSrcMap {
@@ -672,7 +672,7 @@ func (s *syncCopyMove) deleteEmptyDirectories(ctx context.Context, f fs.Fs, entr
return nil
}
if accounting.Stats(ctx).Errored() && !s.ci.IgnoreErrors {
fs.Errorf(f, "%v", fs.ErrorNotDeletingDirs)
fs.ErrorfCtx(ctx, f, "%v", fs.ErrorNotDeletingDirs)
return fs.ErrorNotDeletingDirs
}
@@ -690,20 +690,20 @@ func (s *syncCopyMove) deleteEmptyDirectories(ctx context.Context, f fs.Fs, entr
// TryRmdir only deletes empty directories
err := operations.TryRmdir(ctx, f, dir.Remote())
if err != nil {
fs.Debugf(fs.LogDirName(f, dir.Remote()), "Failed to Rmdir: %v", err)
fs.DebugfCtx(ctx, fs.LogDirName(f, dir.Remote()), "Failed to Rmdir: %v", err)
errorCount++
} else {
okCount++
}
} else {
fs.Errorf(f, "Not a directory: %v", entry)
fs.ErrorfCtx(ctx, f, "Not a directory: %v", entry)
}
}
if errorCount > 0 {
fs.Debugf(f, "failed to delete %d directories", errorCount)
fs.DebugfCtx(ctx, f, "failed to delete %d directories", errorCount)
}
if okCount > 0 {
fs.Debugf(f, "deleted %d directories", okCount)
fs.DebugfCtx(ctx, f, "deleted %d directories", okCount)
}
return nil
}
@@ -780,7 +780,7 @@ func (s *syncCopyMove) renameID(obj fs.Object, renamesStrategy trackRenamesStrat
var err error
hash, err := obj.Hash(s.ctx, s.commonHash)
if err != nil {
fs.Debugf(obj, "Hash failed: %v", err)
fs.DebugfCtx(s.ctx, obj, "Hash failed: %v", err)
return ""
}
if hash == "" {
@@ -852,7 +852,7 @@ func (s *syncCopyMove) popRenameMap(hash string, src fs.Object) (dst fs.Object)
// makeRenameMap builds a map of the destination files by hash that
// match sizes in the slice of objects in s.renameCheck
func (s *syncCopyMove) makeRenameMap() {
fs.Infof(s.fdst, "Making map for --track-renames")
fs.InfofCtx(s.ctx, s.fdst, "Making map for --track-renames")
// first make a map of possible sizes we need to check
possibleSizes := map[int64]struct{}{}
@@ -887,7 +887,7 @@ func (s *syncCopyMove) makeRenameMap() {
}()
}
wg.Wait()
fs.Infof(s.fdst, "Finished making map for --track-renames")
fs.InfofCtx(s.ctx, s.fdst, "Finished making map for --track-renames")
}
// tryRename renames an src object when doing track renames if
@@ -912,7 +912,7 @@ func (s *syncCopyMove) tryRename(src fs.Object) bool {
// Rename dst to have name src.Remote()
_, err := operations.Move(s.ctx, s.fdst, dstOverwritten, src.Remote(), dst)
if err != nil {
fs.Debugf(src, "Failed to rename to %q: %v", dst.Remote(), err)
fs.DebugfCtx(s.ctx, src, "Failed to rename to %q: %v", dst.Remote(), err)
return false
}
@@ -921,7 +921,7 @@ func (s *syncCopyMove) tryRename(src fs.Object) bool {
delete(s.dstFiles, dst.Remote())
s.dstFilesMu.Unlock()
fs.Infof(src, "Renamed from %q", dst.Remote())
fs.InfofCtx(s.ctx, src, "Renamed from %q", dst.Remote())
return true
}
@@ -934,7 +934,7 @@ func (s *syncCopyMove) tryRename(src fs.Object) bool {
// dir is the start directory, "" for root
func (s *syncCopyMove) run() error {
if operations.Same(s.fdst, s.fsrc) && !s.allowOverlap {
fs.Errorf(s.fdst, "Nothing to do as source and destination are the same")
fs.ErrorfCtx(s.ctx, s.fdst, "Nothing to do as source and destination are the same")
return nil
}
@@ -980,7 +980,7 @@ func (s *syncCopyMove) run() error {
// Stop background checking and transferring pipeline
s.stopCheckers()
if s.checkFirst {
fs.Infof(s.fdst, "Checks finished, now starting transfers")
fs.InfofCtx(s.ctx, s.fdst, "Checks finished, now starting transfers")
s.startTransfers()
}
s.stopRenamers()
@@ -990,7 +990,7 @@ func (s *syncCopyMove) run() error {
// Delete files after
if s.deleteMode == fs.DeleteModeAfter {
if s.currentError() != nil && !s.ci.IgnoreErrors {
fs.Errorf(s.fdst, "%v", fs.ErrorNotDeleting)
fs.ErrorfCtx(s.ctx, s.fdst, "%v", fs.ErrorNotDeleting)
} else {
s.processError(s.deleteFiles(false))
}
@@ -1004,7 +1004,7 @@ func (s *syncCopyMove) run() error {
// Prune empty directories
if s.deleteMode != fs.DeleteModeOff {
if s.currentError() != nil && !s.ci.IgnoreErrors {
fs.Errorf(s.fdst, "%v", fs.ErrorNotDeletingDirs)
fs.ErrorfCtx(s.ctx, s.fdst, "%v", fs.ErrorNotDeletingDirs)
} else {
s.processError(s.deleteEmptyDirectories(s.ctx, s.fdst, s.dstEmptyDirs))
}
@@ -1023,13 +1023,13 @@ func (s *syncCopyMove) run() error {
// If the duration was exceeded then add a Fatal Error so we don't retry
if !s.maxDurationEndTime.IsZero() && time.Since(s.maxDurationEndTime) > 0 {
fs.Errorf(s.fdst, "%v", ErrorMaxDurationReachedFatal)
fs.ErrorfCtx(s.ctx, s.fdst, "%v", ErrorMaxDurationReachedFatal)
s.processError(ErrorMaxDurationReachedFatal)
}
// Print nothing to transfer message if there were no transfers and no errors
if s.deleteMode != fs.DeleteModeOnly && accounting.Stats(s.ctx).GetTransfers() == 0 && s.currentError() == nil {
fs.Infof(nil, "There was nothing to transfer")
fs.InfofCtx(s.ctx, nil, "There was nothing to transfer")
}
// cancel the contexts to free resources
@@ -1176,7 +1176,7 @@ func (s *syncCopyMove) copyDirMetadata(ctx context.Context, f fs.Fs, dst fs.Dire
}
s.setDirModTimes = append(s.setDirModTimes, set)
s.setDirModTimeMu.Unlock()
fs.Debugf(nil, "Added delayed dir = %q, newDst=%v", dir, newDst)
fs.DebugfCtx(ctx, nil, "Added delayed dir = %q, newDst=%v", dir, newDst)
}
s.processError(err)
if err != nil {
@@ -1231,7 +1231,7 @@ func (s *syncCopyMove) setDelayedDirModTimes(ctx context.Context) error {
_, err = operations.SetDirModTime(gCtx, s.fdst, item.dst, item.dir, item.modTime)
}
if err != nil {
fs.Errorf(item.dir, "Failed to update directory timestamp or metadata: %v", err)
fs.ErrorfCtx(gCtx, item.dir, "Failed to update directory timestamp or metadata: %v", err)
errCount.Add(err)
}
return nil // don't return errors, just count them
@@ -1271,7 +1271,7 @@ func (s *syncCopyMove) SrcOnly(src fs.DirEntry) (recurse bool) {
}
if !NoNeedTransfer {
// No need to check since doesn't exist
fs.Debugf(src, "Need to transfer - File not found at Destination")
fs.DebugfCtx(s.ctx, src, "Need to transfer - File not found at Destination")
s.markDirModifiedObject(x)
ok := s.toBeUploaded.Put(s.inCtx, fs.ObjectPair{Src: x, Dst: nil})
if !ok {
@@ -1313,7 +1313,7 @@ func (s *syncCopyMove) Match(ctx context.Context, dst, src fs.DirEntry) (recurse
} else {
// FIXME src is file, dst is directory
err := errors.New("can't overwrite directory with file")
fs.Errorf(dst, "%v", err)
fs.ErrorfCtx(ctx, dst, "%v", err)
s.processError(err)
s.logger(ctx, operations.TransferError, srcX, dstX, err)
}
@@ -1335,10 +1335,10 @@ func (s *syncCopyMove) Match(ctx context.Context, dst, src fs.DirEntry) (recurse
// Fix each dir before recursing into subdirs and files
err := operations.DirMoveCaseInsensitive(s.ctx, s.fdst, dst.Remote(), src.Remote())
if err != nil {
fs.Errorf(dst, "Error while attempting to rename to %s: %v", src.Remote(), err)
fs.ErrorfCtx(ctx, dst, "Error while attempting to rename to %s: %v", src.Remote(), err)
s.processError(err)
} else {
fs.Infof(dst, "Fixed case by renaming to: %s", src.Remote())
fs.InfofCtx(ctx, dst, "Fixed case by renaming to: %s", src.Remote())
}
}
@@ -1346,7 +1346,7 @@ func (s *syncCopyMove) Match(ctx context.Context, dst, src fs.DirEntry) (recurse
}
// FIXME src is dir, dst is file
err := errors.New("can't overwrite file with directory")
fs.Errorf(dst, "%v", err)
fs.ErrorfCtx(ctx, dst, "%v", err)
s.processError(err)
s.logger(ctx, operations.TransferError, src.(fs.ObjectInfo), dst.(fs.ObjectInfo), err)
default:
@@ -1416,7 +1416,7 @@ func Transform(ctx context.Context, fdst fs.Fs, deleteEmptySrcDirs bool, copyEmp
func MoveDir(ctx context.Context, fdst, fsrc fs.Fs, deleteEmptySrcDirs bool, copyEmptySrcDirs bool) error {
fi := filter.GetConfig(ctx)
if operations.Same(fdst, fsrc) {
fs.Errorf(fdst, "Nothing to do as source and destination are the same")
fs.ErrorfCtx(ctx, fdst, "Nothing to do as source and destination are the same")
return nil
}
@@ -1425,17 +1425,17 @@ func MoveDir(ctx context.Context, fdst, fsrc fs.Fs, deleteEmptySrcDirs bool, cop
if operations.SkipDestructive(ctx, fdst, "server-side directory move") {
return nil
}
fs.Debugf(fdst, "Using server-side directory move")
fs.DebugfCtx(ctx, fdst, "Using server-side directory move")
err := fdstDirMove(ctx, fsrc, "", "")
switch err {
case fs.ErrorCantDirMove, fs.ErrorDirExists:
fs.Infof(fdst, "Server side directory move failed - fallback to file moves: %v", err)
fs.InfofCtx(ctx, fdst, "Server side directory move failed - fallback to file moves: %v", err)
case nil:
fs.Infof(fdst, "Server side directory move succeeded")
fs.InfofCtx(ctx, fdst, "Server side directory move succeeded")
return nil
default:
err = fs.CountError(ctx, err)
fs.Errorf(fdst, "Server side directory move failed: %v", err)
fs.ErrorfCtx(ctx, fdst, "Server side directory move failed: %v", err)
return err
}
}
+46
View File
@@ -5,9 +5,11 @@ package sync
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"os"
"os/exec"
"runtime"
@@ -26,6 +28,7 @@ import (
"github.com/rclone/rclone/fs/filter"
"github.com/rclone/rclone/fs/fserrors"
"github.com/rclone/rclone/fs/hash"
"github.com/rclone/rclone/fs/log"
"github.com/rclone/rclone/fs/operations"
"github.com/rclone/rclone/fstest"
"github.com/rclone/rclone/lib/transform"
@@ -64,6 +67,49 @@ func TestCopyWithDryRun(t *testing.T) {
r.CheckRemoteItems(t)
}
// Check the per file logs from a copy are attributed to the stats group
func TestCopyLogsAttributedToStatsGroup(t *testing.T) {
ctx := context.Background()
r := fstest.NewRun(t)
r.WriteFile("sub dir/hello world", "hello world", t1)
r.Mkdir(ctx, r.Fremote)
// Log at INFO level so "Copied (new)" is logged
ci := fs.GetConfig(ctx)
oldLogLevel := ci.LogLevel
ci.LogLevel = fs.LogLevelInfo
oldSlogLevel := log.Handler.SetLevel(slog.LevelInfo)
defer func() {
ci.LogLevel = oldLogLevel
log.Handler.SetLevel(oldSlogLevel)
}()
// Capture the JSON logs
var mu mutex.Mutex
var entries []map[string]any
remove := log.Handler.AddOutput(true, func(level slog.Level, text string) {
var entry map[string]any
assert.NoError(t, json.Unmarshal([]byte(text), &entry), text)
mu.Lock()
entries = append(entries, entry)
mu.Unlock()
})
defer remove()
err := CopyDir(accounting.WithStatsGroup(ctx, "test-group"), r.Fremote, r.Flocal, false)
require.NoError(t, err)
remove()
found := false
for _, entry := range entries {
if entry["msg"] == "Copied (new)" && entry["object"] == "sub dir/hello world" {
assert.Equal(t, "test-group", entry["group"])
found = true
}
}
assert.True(t, found, "didn't find Copied log in %v", entries)
}
// Now without dry run
func TestCopy(t *testing.T) {
ctx := context.Background()
+2 -2
View File
@@ -172,7 +172,7 @@ func listRwalk(ctx context.Context, f fs.Fs, path string, includeAll bool, maxLe
if err != nil {
listErr = err
err = fs.CountError(ctx, err)
fs.Errorf(path, "error listing: %v", err)
fs.ErrorfCtx(ctx, path, "error listing: %v", err)
return nil
}
listType.Filter(&entries)
@@ -424,7 +424,7 @@ func walk(ctx context.Context, f fs.Fs, path string, includeAll bool, maxLevel i
if err != nil && err != ErrorSkipDir {
traversing.Done()
err = fs.CountError(ctx, err)
fs.Errorf(job.remote, "error listing: %v", err)
fs.ErrorfCtx(ctx, job.remote, "error listing: %v", err)
closeQuit()
// Send error to error channel if space
select {
+2 -2
View File
@@ -112,7 +112,7 @@ func RPC(method string, input string) (output string, status int) {
//in["_response"] = w
}
fs.Debugf(nil, "rc: %q: with parameters %+v", method, in)
rc.LogCall("rc: %q: with parameters %+v", method, in)
_, out, err := jobs.NewJob(context.Background(), call.Fn, in)
if err != nil {
@@ -122,7 +122,7 @@ func RPC(method string, input string) (output string, status int) {
out = make(rc.Params)
}
fs.Debugf(nil, "rc: %q: reply %+v: %v", method, out, err)
rc.LogCall("rc: %q: reply %+v: %v", method, out, err)
var w strings.Builder
err = rc.WriteJSON(&w, out)