Skip to content

Commit

Permalink
Add option for Prometheus monitoring (#49)
Browse files Browse the repository at this point in the history
* Add option for Prometheus monitoring

* Work around issue parsing commas
  • Loading branch information
endotronic authored Jun 27, 2023
1 parent 1687e96 commit a5215bf
Show file tree
Hide file tree
Showing 4 changed files with 276 additions and 1 deletion.
7 changes: 6 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ RUN apt-get update \
libglapi-mesa libxext-dev libxdamage-dev libxshmfence-dev libxxf86vm-dev \
libxcb-glx0 libxcb-dri2-0 libxcb-dri3-0 libxcb-present-dev \
ca-certificates gosu tzdata libc6 libxdamage1 libxcb-present0 \
libxcb-sync1 libxshmfence1 libxxf86vm1 python3-gpg
libxcb-sync1 libxshmfence1 libxxf86vm1 python3-gpg python3-pip

# Create user and group
RUN mkdir -p /opt/dropbox \
Expand Down Expand Up @@ -53,10 +53,15 @@ LABEL org.label-schema.vcs-ref "${VCS_REF}"
ENV POLLING_INTERVAL=5
# Possibility to skip permission check
ENV SKIP_SET_PERMISSIONS=false
# Possibility to enable Prometheus monitoring
ENV ENABLE_MONITORING=false

# Install init script and dropbox command line wrapper
COPY docker-entrypoint.sh /

# Install monitoring script
COPY monitoring.py /

# Set entrypoint and command
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["/opt/dropbox/bin/dropboxd"]
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,13 @@ If this is set to `true`, the container skips setting the permissions on all fil
in order to prevent long startup times. _Note:_ please make sure to have correct permissions on all files before
you do this! Implemented for [#25](https://github.com/otherguy/docker-dropbox/issues/25).

- `ENABLE_MONITORING`
If this is set to `true`, an endpoint for Prometheus monitoring is enabled on port 8000. This provides the metrics
`dropbox_status`, `dropbox_num_syncing`, `dropbox_num_downloading`, and `dropbox_num_uploading`, which may be
useful for setting alerts to ensure that Dropbox is syncing properly and keeps itself up to date. Note this is
still experimental and off by default.


### Volumes

- `/opt/dropbox`
Expand Down
7 changes: 7 additions & 0 deletions docker-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,13 @@ echo "Starting dropboxd ($(cat /opt/dropbox/bin/VERSION))..."
gosu dropbox "$@" & export DROPBOX_PID="$!"
trap "/bin/kill -SIGQUIT ${DROPBOX_PID}" INT

# Optionally start monitoring script
if [[ $(echo "${ENABLE_MONITORING:-false}" | tr '[:upper:]' '[:lower:]' | tr -d " ") == "true" ]]; then
echo "Starting monitoring script..."
python3 -m pip install prometheus_client
gosu dropbox python3 /monitoring.py -i ${POLLING_INTERVAL} &
fi

# Wait a few seconds for the Dropbox daemon to start
sleep 5

Expand Down
256 changes: 256 additions & 0 deletions monitoring.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
from argparse import ArgumentParser
from enum import Enum
from functools import partial
import logging
import re
import signal
import subprocess
from threading import Event
from time import time
from typing import Optional

from prometheus_client import start_http_server, Enum as EnumMetric, Gauge # type: ignore


class Metric(Enum):
NUM_SYNCING = "num_syncing"
NUM_DOWNLOADING = "num_downloading"
NUM_UPLOADING = "num_uploading"


class State(Enum):
STARTING = "starting"
SYNCING = "syncing"
INDEXING = "indexing"
UP_TO_DATE = "up to date"
SYNC_ERROR = "sync_error"
NOT_RUNNING = "not running"
UNKNOWN = "unknown"


class DropboxInterface:
"""
This can be mocked for testing as needed
"""

def __init__(self, logger: logging.Logger) -> None:
self.logger = logger

def query_status(self) -> Optional[str]:
try:
result = subprocess.run(
["dropbox", "status"], capture_output=True, text=True
)
if result.stderr:
self.logger.warning("Dropbox status returned error: %s", result.stderr)
return None
elif not result.stdout:
self.logger.warning("Dropbox status did not produce results")
return None
else:
self.logger.debug("Got result from Dropbox: %s", result.stdout)
return result.stdout
except:
self.logger.exception("Failed to invoke Dropbox")
return None


class DropboxMonitor:
def __init__(
self,
dropbox: DropboxInterface,
min_poll_interval_sec: int,
logger: logging.Logger,
prom_port: int,
) -> None:
self.dropbox = dropbox
self.min_poll_interval_sec = min_poll_interval_sec
self.logger = logger
self.prom_port = prom_port
self.status_matcher = re.compile(
"(Syncing|Downloading|Uploading|Indexing) (\\d+) files"
)
self.status_matcher_with_file = re.compile(
'(Syncing|Downloading|Uploading|Indexing) ".+"'
)

self.last_query_time = 0
self.num_syncing = None # type: Optional[int]
self.num_downloading = None # type: Optional[int]
self.num_uploading = None # type: Optional[int]
self.state = State.STARTING

self.num_syncing_gauge = Gauge(
"dropbox_num_syncing",
"Number of files currently syncing",
)

self.num_downloading_gauge = Gauge(
"dropbox_num_downloading",
"Number of files currently downloading",
)

self.num_uploading_gauge = Gauge(
"dropbox_num_uploading",
"Number of files currently uploading",
)

self.status_enum = EnumMetric(
"dropbox_status",
"Status reported by Dropbox client",
states=[state.value for state in State.__members__.values()],
)

def start(self) -> None:
self.status_enum.state(State.STARTING.value)
self.num_syncing_gauge.set_function(
partial(self.get_status, Metric.NUM_SYNCING)
)
self.num_downloading_gauge.set_function(
partial(self.get_status, Metric.NUM_DOWNLOADING)
)
self.num_uploading_gauge.set_function(
partial(self.get_status, Metric.NUM_UPLOADING)
)

start_http_server(self.prom_port)
self.logger.info("Started Prometheus server on port %d", self.prom_port)

def get_status(self, metric: Metric) -> int:
now = time()
if now - self.last_query_time > self.min_poll_interval_sec:
dropbox_result = self.dropbox.query_status()
if dropbox_result:
self.parse_output(dropbox_result)
else:
self.status_enum.state(State.UNKNOWN.value)
self.num_syncing = None
self.num_downloading = None
self.num_uploading = None

if metric == Metric.NUM_SYNCING:
return self.num_syncing or 0
elif metric == Metric.NUM_DOWNLOADING:
return self.num_downloading or 0
elif metric == Metric.NUM_UPLOADING:
return self.num_uploading or 0
else:
raise ValueError(metric)

def parse_output(self, results: str) -> None:
"""
Observed messages from `dropbox status`
Up to date
Syncing...
Indexing...
Syncing 176 files • 6 secs
Downloading 176 files (6 secs)
Dropbox isn't running!
Indexing 1 file...
Can't sync "monitoring.txt" (access denied)
Syncing "none" • 1 sec
Downloading 82 files (2,457 KB/sec, 2 secs)
"""
state = State.UNKNOWN
num_syncing = None # type: Optional[int]
num_downloading = None # type: Optional[int]
num_uploading = None # type: Optional[int]

for line in results.splitlines():
try:
if line.startswith("Up to date"):
state = State.UP_TO_DATE
self.num_syncing = 0
self.num_downloading = 0
self.num_uploading = 0
if line == "Dropbox isn't running!":
state = State.NOT_RUNNING
else:
# Hack: remove commas; simplifies the regex
line = line.replace(',', '')

status_match = self.status_matcher.match(line)
status_match_with_file = self.status_matcher_with_file.match(line)
if status_match:
state = State.SYNCING
action, num_files_str = status_match.groups()
num_files = int(num_files_str)
if action == "Syncing":
num_syncing = num_files
if action == "Downloading":
num_downloading = num_files
if action == "Uploading":
num_uploading = num_files
elif status_match_with_file:
state = State.SYNCING
action = status_match_with_file.groups()[0]
if action == "Syncing":
num_syncing = 1
if action == "Downloading":
num_downloading = 1
if action == "Uploading":
num_uploading = 1
elif line.startswith("Starting"):
state = State.STARTING
elif line.startswith("Syncing"):
state = State.SYNCING
elif line.startswith("Indexing"):
state = State.INDEXING
elif line.startswith("Can't sync"):
state = State.SYNC_ERROR
else:
self.logger.debug("Ignoring line '%s'", line)
except:
self.logger.exception("Failed to parse status line '%s'", line)

self.status_enum.state(state.value)
if state == State.SYNCING:
self.num_syncing = num_syncing
self.num_downloading = num_downloading
self.num_uploading = num_uploading
else:
self.num_syncing = None
self.num_downloading = None
self.num_uploading = None


if __name__ == "__main__":
parser = ArgumentParser(
description="Runs a webserver for Prometheus that reports Dropbox status"
)
parser.add_argument(
"-i",
"--min_poll_interval_sec",
help="minimum interval for polling Dropbox (in seconds)",
default=5,
)
parser.add_argument("-p", "--port", help="Prometheus port", default=8000)
parser.add_argument("--log_level", default="INFO")
parser.add_argument("--global_log_level", default="INFO")
args = parser.parse_args()

log_level = logging.getLevelName(args.log_level)
global_log_level = logging.getLevelName(args.global_log_level)
logging.basicConfig(
format="[MONITORING %(levelname)s]: %(message)s", level=global_log_level
)
logger = logging.getLogger("dropbox_monitor")
logger.setLevel(log_level)

dropbox = DropboxInterface(logger)
monitor = DropboxMonitor(
dropbox=dropbox,
min_poll_interval_sec=int(args.min_poll_interval_sec),
logger=logger,
prom_port=args.port,
)
monitor.start()

exit_event = Event()
signal.signal(signal.SIGHUP, lambda _s, _f: exit_event.set())
signal.signal(signal.SIGINT, lambda _s, _f: exit_event.set())
signal.signal(signal.SIGTERM, lambda _s, _f: exit_event.set())

exit_event.wait()
logger.info("Stopped gracefully")

0 comments on commit a5215bf

Please sign in to comment.