Skip to content
Merged
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -126,3 +126,6 @@ lock.py

# config ini files
*/configs/*.ini

#logging config
/log_config
142 changes: 142 additions & 0 deletions OS_instructions/setup_promtail.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
#!/bin/bash
# This script moves old log files to an archive folder,
# creates a Promtail configuration file,
# downloads and installs the promtail binary,
# and sets up promtail as a systemd service.
#
# Usage:
# ./setup_promtail.sh -p <LOKI_PASSWORD> -n <HOSTNAME>

usage() {
echo "Usage: $0 -p <LOKI_PASSWORD> -n <HOSTNAME>"
exit 1
}

# Parse command line options
while getopts "p:n:" opt; do
case ${opt} in
p)
PASSWORD=${OPTARG}
;;
n)
HOST=${OPTARG}
;;
*)
usage
;;
esac
done

# Ensure both variables are provided
if [ -z "${PASSWORD}" ] || [ -z "${HOST}" ]; then
echo "Error: Both PASSWORD and HOST must be provided."
usage
fi

# --- Step 1: Move old log files to archive ---
LOG_DIR="/media/pi/SamsungSSD/logs"
ARCHIVE_DIR="/media/pi/SamsungSSD/archive"

if [ -d "$LOG_DIR" ]; then
echo "Creating archive directory and moving old log files from $LOG_DIR to $ARCHIVE_DIR..."
mkdir -p "$ARCHIVE_DIR"
mv "$LOG_DIR"/*.log "$ARCHIVE_DIR"/ 2>/dev/null
else
echo "Log directory $LOG_DIR does not exist. Skipping file archival."
fi

# --- Step 2: Setup Promtail configuration ---
GRAFANA_DIR=/home/pi/Documents/ulc-malaria-scope/log_config
mkdir -p "$GRAFANA_DIR"

CONFIG_FILE="$GRAFANA_DIR/promtail-config.yaml"
cat << 'EOF' > "$CONFIG_FILE"
server:
disable: true # Disables Promtail's internal HTTP server
positions:
filename: /home/pi/Documents/ulc-malaria-scope/log_config/positions.yaml # Stores file tracking state
sync_period: 10s
clients:
- url: https://api-grafana.sf.czbiohub.org/loki/push
basic_auth:
username: bioe
password: ${LOKI_PASSWORD}
scrape_configs:
- job_name: remoscope-logs
static_configs:
- targets:
- localhost
labels:
app: Remoscope
host: ${HOSTNAME}
__path__: /media/pi/SamsungSSD/logs/*.log # Watches all logs in this directory
pipeline_stages:
- labeldrop:
- filename
- match:
selector: '{app="Remoscope"}'
stages:
- multiline:
firstline: '^\\d{4}-\\d{2}-\\d{2}-\\d{6} .*'
max_wait_time: 5s
- regex:
expression: '^\\d{4}-\\d{2}-\\d{2}-\\d{6} - (?P<severity>\\w+) - .*'
- labels:
severity:
EOF

echo "Promtail configuration file created at $CONFIG_FILE"

# --- Step 3: Download and install Promtail ---
echo "Downloading promtail..."
wget https://github.com/grafana/loki/releases/download/v3.3.2/promtail-linux-arm.zip -O /tmp/promtail-linux-arm.zip

echo "Unzipping promtail..."
unzip -o /tmp/promtail-linux-arm.zip -d /tmp/

echo "Making promtail executable and moving it to /usr/local/bin..."
chmod +x /tmp/promtail-linux-arm
sudo mv /tmp/promtail-linux-arm /usr/local/bin/promtail

# --- Step 4: Create systemd service for Promtail using sed for environment variable substitution ---
echo "Creating systemd service file for promtail..."
sudo tee /etc/systemd/system/promtail.service > /dev/null <<'EOF'
[Unit]
Description=Promtail Service
After=network.target

[Service]
Type=simple
ExecStart=/usr/local/bin/promtail --config.file=/home/pi/Documents/ulc-malaria-scope/log_config/promtail-config.yaml --config.expand-env=true
Restart=on-failure
User=pi
Environment="HOSTNAME=HOST_PLACEHOLDER"
Environment="LOKI_PASSWORD=PASSWORD_PLACEHOLDER"

[Install]
WantedBy=multi-user.target
EOF

echo "Updating systemd service file with provided HOST and PASSWORD..."
sudo sed -i "s/Environment=\"HOSTNAME=HOST_PLACEHOLDER\"/Environment=\"HOSTNAME=${HOST}\"/" /etc/systemd/system/promtail.service
sudo sed -i "s/Environment=\"LOKI_PASSWORD=PASSWORD_PLACEHOLDER\"/Environment=\"LOKI_PASSWORD=${PASSWORD}\"/" /etc/systemd/system/promtail.service

echo "Adding LOKI_PASSWORD to as environment variable for interactive sessions..."
sudo sed -i '/^LOKI_PASSWORD=/d' /etc/environment
echo "LOKI_PASSWORD=${PASSWORD}" | sudo tee -a /etc/environment

sed -i '/^export LOKI_PASSWORD=/d' ~/.bashrc
echo "export LOKI_PASSWORD=${PASSWORD}" >> ~/.bashrc
echo "source ~/.bashrc"

echo "Adding permissions to log_config folder"
sudo chmod -R 777 /home/pi/Documents/ulc-malaria-scope/log_config

echo "Reloading systemd daemon..."
sudo systemctl daemon-reload
echo "Enabling promtail service..."
sudo systemctl enable promtail.service
echo "Starting promtail service..."
sudo systemctl start promtail.service

echo "Promtail setup complete."
2 changes: 1 addition & 1 deletion ulc_mm_package/QtGUI/acquisition.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ def get_img(self):
self.img, self.img_timestamp = next(self.img_gen)
self.update_scopeop.emit(self.img, self.img_timestamp)
except PyCamerasError as e:
self.logger.exception(e)
self.logger.error(f"Failed to grab image: {e}.")

def send_img(self):
self.update_liveview.emit(self.img)
Expand Down
53 changes: 50 additions & 3 deletions ulc_mm_package/QtGUI/oracle.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from transitions.core import MachineError
from time import sleep
from logging.config import fileConfig
from logging import LogRecord
from datetime import datetime
from pathlib import Path

Expand Down Expand Up @@ -109,6 +110,16 @@ def closeEvent(self, event):
event.accept()


class DateTimeFilter(logging.Filter):
def __init__(self, datetime_str):
super().__init__()
self.datetime_str = datetime_str

def filter(self, record: LogRecord):
record.datetime_str = self.datetime_str
return True


class Oracle(Machine):
def __init__(self):
self.shutoff_done = False
Expand Down Expand Up @@ -137,6 +148,7 @@ def __init__(self):
},
)
self.logger = logging.root
self._init_log_format()
self.logger.info("STARTING ORACLE.")

# Instantiate GUI windows
Expand All @@ -159,6 +171,16 @@ def __init__(self):
def _init_tcp(self):
self.liveview_window.update_tcp("unavailable")

def _init_log_format(self):
old_factory = logging.getLogRecordFactory()

def record_factory(*args, **kwargs):
record = old_factory(*args, **kwargs)
record.datetime_str = self.datetime_str # Inject dynamically
return record

logging.setLogRecordFactory(record_factory)

def _check_lock(self):
if path.isfile(LOCKFILE):
message_result = self.display_message(
Expand Down Expand Up @@ -346,7 +368,7 @@ def _init_ssd(self):
sys.exit(1)

def ssd_full_msg_and_exit(self):
print(
self.logger.error(
"Couldn't find any folders in /media/pi with sufficient storage. Please eject and replace the SSD with a new one. Thank you!"
)
self.display_message(
Expand Down Expand Up @@ -656,7 +678,7 @@ def save_form(self):
.strip()
)
except subprocess.CalledProcessError:
self.logger.info("No Git branch or tag found.")
self.logger.error("No Git branch or tag found.")

self.scopeop.mscope.data_storage.createNewExperiment(
self.ext_dir,
Expand All @@ -667,6 +689,9 @@ def save_form(self):
)

sample_type = self.experiment_metadata["sample_type"]

self.logger.info(f"Experiment Metadata: {self.experiment_metadata}")

clinical = sample_type == CLINICAL_SAMPLE
skip = not clinical and not sample_type == CULTURED_SAMPLE
if skip:
Comment on lines 696 to 697
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NBD but definitely more confusing that necessary. Could be:
if sample_type not in (CLINICAL_SAMPLE, CULTURED_SAMPLE):

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agre, I will change that in another pull request but I want to keep this specific to logging.

Expand Down Expand Up @@ -769,6 +794,7 @@ def _start_intermission(self, msg=None, parasitemia_vis_path=""):
if message_result == QMessageBox.No:
self.shutoff()
elif message_result == QMessageBox.Yes:
self._start_new_log()
self.logger.info("Starting new experiment.")
if not DataStorage.is_there_sufficient_storage(self.ext_dir):
self.ssd_full_msg_and_exit()
Expand All @@ -780,6 +806,27 @@ def _start_intermission(self, msg=None, parasitemia_vis_path=""):
self.scopeop.to_intermission()
self.scopeop.rerun()

def _start_new_log(self):
"""
Closes the current log file and creates new timestamped log file.
"""

logging.shutdown()
log_dir = path.join(self.ext_dir, "logs")
logger_config_path = Path(__file__).resolve().parent.parent / "logger.config"
self.datetime_str = datetime.now().strftime(DATETIME_FORMAT)
fileConfig(
fname=str(logger_config_path),
defaults={
"filename": path.join(log_dir, f"{self.datetime_str}.log"),
"fileHandlerLevel": "DEBUG" if VERBOSE else "INFO",
},
disable_existing_loggers=False,
)
self.logger = logging.root
self.logger.addFilter(DateTimeFilter(self.datetime_str))
self.logger.info("CREATED LOG.")

def shutoff(self):
self.logger.info("Starting oracle shut off.")

Expand Down Expand Up @@ -856,7 +903,7 @@ def main():

def shutoff_excepthook(type, value, tb):
tb_string = "".join(traceback.format_exception(type, value, tb))
oracle.logger.warning(f"Oracle shutoff due to exception - {tb_string}")
oracle.logger.error(f"Oracle shutoff due to exception - {tb_string}")
try:
app.shutoff.emit()
# Pause before shutting off hardware to ensure there are no calls to camera post-shutoff
Expand Down
38 changes: 32 additions & 6 deletions ulc_mm_package/QtGUI/scope_op.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@
VERBOSE,
ACQUISITION_PERIOD,
LIVEVIEW_PERIOD,
FRAME_LOG_INTERVAL,
PERIODIC_METADATA_KEYS,
)


Expand Down Expand Up @@ -248,6 +250,8 @@ def _set_exp_variables(self):

self.parasitemia_vis_path = ""

self.periodic_log_values = {key: None for key in PERIODIC_METADATA_KEYS}

self.update_img_count.emit(0)
self.update_msg.emit("Starting new experiment")

Expand Down Expand Up @@ -338,6 +342,7 @@ def setup(self):

def lid_open_pause_handler(self, *args):
self.lid_opened = True
self.logger.info("Lid opened.")
if self.mscope.led._isOn:
self.lid_open_pause.emit()

Expand Down Expand Up @@ -424,7 +429,7 @@ def _check_pressure_seal(self, *args):
QR.NONE.value,
)
except PressureLeak as e:
self.logger.error(str(e))
self.logger.error(f"Pressure leak detected: {e}")
self.default_error.emit(
"Calibration failed",
str(e),
Expand Down Expand Up @@ -557,6 +562,16 @@ def _end_experiment(self, *args):
# Turn camera back on
self.mscope.camera.startAcquisition()

class_counts_str = ", ".join(
f"{class_name}={count}"
for class_name, count in zip(YOGO_CLASS_LIST, self.raw_cell_count)
)

self.logger.info(
f"Finished experiment. "
f"Processed {self.frame_count} frames. "
f"Final class counts: {class_counts_str}"
)
self.finishing_experiment.emit(100)

def _start_intermission(self, msg):
Expand All @@ -578,7 +593,7 @@ def run_autobrightness(self, img, _timestamp):
try:
self.img_signal.disconnect(self.run_autobrightness)
except TypeError:
self.logger.info(
self.logger.warning(
"run_autobrightness: img_signal already disconnected, no signal/slot changes were made."
)

Expand Down Expand Up @@ -966,7 +981,7 @@ def run_experiment(self, img, timestamp) -> None:
try:
self.flowrate, _ = self.flowcontrol_routine.send((img_ds_10x, timestamp))
except Exception as e:
self.logger.warning(f"Unexpected flow control exception - {e}")
self.logger.error(f"Unexpected flow control exception - {e}")
self.flowrate = -1
self.flowcontrol_routine = self.routines.flow_control_routine(
self.mscope, self.target_flowrate
Expand All @@ -981,7 +996,7 @@ def run_experiment(self, img, timestamp) -> None:
curr_mean_pixel_val = self.periodic_autobrightness_routine.send(resized_img)

# ------------------------------------
# Update remaining metadata in per-image csv
# Update remaining metadata in per-image csv and log
# ------------------------------------
t0 = perf_counter()
self.img_metadata["motor_pos"] = self.mscope.motor.getCurrentPosition()
Expand All @@ -993,7 +1008,7 @@ def run_experiment(self, img, timestamp) -> None:
) = (pressure, status)
except PressureSensorStaleValue as e:
## TODO???
self.logger.info(f"Stale pressure sensor value - {e}")
self.logger.error(f"Stale pressure sensor value - {e}")

self.img_metadata["led_pwm_val"] = self.mscope.led.pwm_duty_cycle
self.img_metadata[
Expand All @@ -1020,7 +1035,7 @@ def run_experiment(self, img, timestamp) -> None:
except Exception as e:
# some error has occurred, but the TH sensor isn't critical, so just warn
# and move on
self.logger.warning(
self.logger.error(
f"exception occurred while retrieving temperature and humidity: {e}"
)
self.img_metadata["humidity"] = None
Expand Down Expand Up @@ -1048,5 +1063,16 @@ def run_experiment(self, img, timestamp) -> None:
t1 = perf_counter()
self._update_metadata_if_verbose("datastorage.writeData", t1 - t0)

for key in PERIODIC_METADATA_KEYS:
val = self.img_metadata.get(key, None)
if val is not None:
self.periodic_log_values[key] = val

if self.frame_count % FRAME_LOG_INTERVAL == 0:
# Log full periodic metadata
self.logger.info(
f"[Frame {self.frame_count}] Full periodic metadata: {self.periodic_log_values}"
)

if self.running:
self.img_signal.connect(self.run_experiment)
Loading