diff --git a/oscar/SleepLib/deviceconnection.cpp b/oscar/SleepLib/deviceconnection.cpp
index 06da68bc..ab5cd39c 100644
--- a/oscar/SleepLib/deviceconnection.cpp
+++ b/oscar/SleepLib/deviceconnection.cpp
@@ -92,7 +92,7 @@ void DeviceConnectionManager::replay(QFile* file)
 
 
 // Return singleton instance of DeviceConnectionManager, creating it if necessary.
-inline DeviceConnectionManager & DeviceConnectionManager::getInstance()
+DeviceConnectionManager & DeviceConnectionManager::getInstance()
 {
     static DeviceConnectionManager instance;
     return instance;
diff --git a/oscar/SleepLib/deviceconnection.h b/oscar/SleepLib/deviceconnection.h
index f56bc031..352aab29 100644
--- a/oscar/SleepLib/deviceconnection.h
+++ b/oscar/SleepLib/deviceconnection.h
@@ -13,7 +13,6 @@
 // connections to devices. For now it just supports serial ports.
 
 #include <QtSerialPort/QSerialPort>
-#include <QXmlStreamWriter>
 #include <QHash>
 #include <QVariant>
 
@@ -263,6 +262,9 @@ public:
  * supports Bluetooth and BLE as well as serial. Such a class might then be
  * used instead of port "name" between DeviceConnectionManager and clients.
  */
+class QXmlStreamWriter;
+class QXmlStreamReader;
+
 class SerialPortInfo
 {
 public:
diff --git a/oscar/SleepLib/xmlreplay.cpp b/oscar/SleepLib/xmlreplay.cpp
index 6782d018..cd121dcd 100644
--- a/oscar/SleepLib/xmlreplay.cpp
+++ b/oscar/SleepLib/xmlreplay.cpp
@@ -116,6 +116,14 @@ void XmlRecorder::epilogue()
     m_xml->writeEndElement();  // close enclosing tag
 }
 
+void XmlRecorder::flush()
+{
+    if (m_file) {
+        if (!m_file->flush()) {
+            qWarning().noquote() << "Unable to flush XML to" << m_file->fileName();
+        }
+    }
+}
 
 XmlReplay::XmlReplay(QFile* file, const QString & tag)
     : m_tag(tag), m_file(file), m_pendingSignal(nullptr), m_parent(nullptr)
@@ -416,6 +424,7 @@ void XmlReplayEvent::record(XmlRecorder* writer) const
     if (writer != nullptr) {
         writer->lock();
         writer->xml() << *this;
+        writer->flush();
         writer->unlock();
     }
 }
diff --git a/oscar/SleepLib/xmlreplay.h b/oscar/SleepLib/xmlreplay.h
index 81fd12a9..5c784cd9 100644
--- a/oscar/SleepLib/xmlreplay.h
+++ b/oscar/SleepLib/xmlreplay.h
@@ -54,6 +54,7 @@ public:
     inline QXmlStreamWriter & xml() { return *m_xml; }
     inline void lock() { m_mutex.lock(); }
     inline void unlock() { m_mutex.unlock(); }
+    void flush();
 
 protected:
     XmlRecorder(XmlRecorder* parent, const QString & id, const QString & tag);  // constructor used by substreams
diff --git a/oscar/logger.cpp b/oscar/logger.cpp
index 986b5c40..8c1ade84 100644
--- a/oscar/logger.cpp
+++ b/oscar/logger.cpp
@@ -8,8 +8,9 @@
  * for more details. */
 
 #include "logger.h"
-
-#define ASSERTS_SUCK
+#include "SleepLib/preferences.h"
+#include "version.h"
+#include <QDir>
 
 QThreadPool * otherThreadPool = NULL;
 
@@ -47,12 +48,9 @@ void MyOutputHandler(QtMsgType type, const QMessageLogContext &context, const QS
 
     if (logger && logger->isRunning()) {
         logger->append(msg);
+    } else {
+        fprintf(stderr, "%s\n", msg.toLocal8Bit().constData());
     }
-#ifdef ASSERTS_SUCK
-//    else {
-//        fprintf(stderr, "%s\n", msg.toLocal8Bit().data());
-//    }
-#endif
 
     if (type == QtFatalMsg) {
         abort();
@@ -84,15 +82,65 @@ void LogThread::connectionReady()
 {
     strlock.lock();
     connected = true;
+    logTrigger.wakeAll();
     strlock.unlock();
     qDebug() << "Logging UI initialized";
 }
 
+void LogThread::logToFile()
+{
+    if (m_logStream) {
+        qWarning().noquote() << "Already logging to" << m_logFile->fileName();
+        return;
+    }
+
+    QString debugLog = GetLogDir() + "/debug.txt";
+    rotateLogs(debugLog);  // keep a limited set of previous logs
+    
+    strlock.lock();
+    m_logFile = new QFile(debugLog);
+    Q_ASSERT(m_logFile);
+    if (m_logFile->open(QFile::ReadWrite | QFile::Text)) {
+        m_logStream = new QTextStream(m_logFile);
+    }
+    logTrigger.wakeAll();
+    strlock.unlock();
+
+    if (m_logStream) {
+        qDebug().noquote() << "Logging to" << debugLog;
+    } else {
+        qWarning().noquote() << "Unable to open" << debugLog;
+    }
+}
+
+LogThread::~LogThread()
+{
+    QMutexLocker lock(&strlock);
+    
+    Q_ASSERT(running == false);
+    if (m_logStream) {
+        delete m_logStream;
+        m_logStream = nullptr;
+        Q_ASSERT(m_logFile);
+        delete m_logFile;
+        m_logFile = nullptr;
+    }
+}
+
+QString LogThread::logFileName()
+{
+    if (!m_logFile) {
+        return "";
+    }
+    return m_logFile->fileName();
+}
+
 void shutdownLogger()
 {
     if (logger) {
         logger->quit();
-        otherThreadPool->waitForDone(-1);
+        // The thread is automatically destroyed when its run() method exits.
+        otherThreadPool->waitForDone(-1);  // wait until that happens
         logger = NULL;
     }
     delete otherThreadPool;
@@ -103,47 +151,132 @@ LogThread * logger = NULL;
 void LogThread::append(QString msg)
 {
     QString tmp = QString("%1: %2").arg(logtime.elapsed(), 5, 10, QChar('0')).arg(msg);
-    //QStringList appears not to be threadsafe
-    strlock.lock();
-    buffer.append(tmp);
-    strlock.unlock();
+    appendClean(tmp);
 }
 
 void LogThread::appendClean(QString msg)
 {
+    fprintf(stderr, "%s\n", msg.toLocal8Bit().constData());
     strlock.lock();
     buffer.append(msg);
+    logTrigger.wakeAll();
     strlock.unlock();
 }
 
 
 void LogThread::quit() {
     qDebug() << "Shutting down logging thread";
-    running = false;
+    qInstallMessageHandler(0);  // Remove our logger.
+    
     strlock.lock();
-    while (!buffer.isEmpty()) {
-        QString msg = buffer.takeFirst();
-        fprintf(stderr, "%s\n", msg.toLocal8Bit().constData());
-    }
-    strlock.unlock();
+    running = false;       // Force the thread to exit after its next iteration.
+    logTrigger.wakeAll();  // Trigger the final flush.
+    strlock.unlock();      // Release the lock so that the thread can complete.
 }
 
 
 void LogThread::run()
 {
+    QMutexLocker lock(&strlock);
+
     running = true;
     s_LoggerRunning.unlock();  // unlock as soon as the thread begins to run
     do {
-        strlock.lock();
-        //int r = receivers(SIGNAL(outputLog(QString())));
-        while (connected && !buffer.isEmpty()) {
+        logTrigger.wait(&strlock);  // releases strlock while it waits
+        while (connected && m_logFile && !buffer.isEmpty()) {
             QString msg = buffer.takeFirst();
-                fprintf(stderr, "%s\n", msg.toLocal8Bit().data());
-                emit outputLog(msg);
+            if (m_logStream) {
+                *m_logStream << msg << endl;
+            }
+            emit outputLog(msg);
         }
-        strlock.unlock();
-        QThread::msleep(1000);
     } while (running);
+
+    // strlock will be released when lock goes out of scope
 }
 
 
+QString GetLogDir()
+{
+    static const QString LOG_DIR_NAME = "logs";
+
+    Q_ASSERT(!GetAppData().isEmpty());  // If GetLogDir gets called before GetAppData() is valid, this would point at root.
+    QDir oscarData(GetAppData());
+    Q_ASSERT(oscarData.exists());
+    if (!oscarData.exists(LOG_DIR_NAME)) {
+        oscarData.mkdir(LOG_DIR_NAME);
+    }
+    QDir logDir(oscarData.canonicalPath() + "/" + LOG_DIR_NAME);
+    if (!logDir.exists()) {
+        qWarning() << "Unable to create" << logDir.absolutePath() << "reverting to" << oscarData.canonicalPath();
+        logDir = oscarData;
+    }
+    Q_ASSERT(logDir.exists());
+
+    return logDir.canonicalPath();
+}
+
+void rotateLogs(const QString & filePath, int maxPrevious)
+{
+    if (maxPrevious < 0) {
+        if (getVersion().IsReleaseVersion()) {
+            maxPrevious = 1;
+        } else {
+            // keep more in testing builds
+            maxPrevious = 4;
+        }
+    }
+
+    // Build the list of rotated logs for this filePath.
+    QFileInfo info(filePath);
+    QString path = QDir(info.absolutePath()).canonicalPath();
+    QString base = info.baseName();
+    QString ext = info.completeSuffix();
+    if (!ext.isEmpty()) {
+        ext = "." + ext;
+    }
+    if (path.isEmpty()) {
+        qWarning() << "Skipping log rotation, directory does not exist:" << info.absoluteFilePath();
+        return;
+    }
+
+    QStringList logs;
+    logs.append(filePath);
+    for (int i = 0; i < maxPrevious; i++) {
+        logs.append(QString("%1/%2.%3%4").arg(path).arg(base).arg(i).arg(ext));
+    }
+
+    // Remove the expired log.
+    QFileInfo expired(logs[maxPrevious]);
+    if (expired.exists()) {
+        if (expired.isDir()) {
+            QDir dir(expired.canonicalFilePath());
+            //qDebug() << "Removing expired log directory" << dir.canonicalPath();
+            if (!dir.removeRecursively()) {
+                qWarning() << "Unable to delete expired log directory" << dir.canonicalPath();
+            }
+        } else {
+            QFile file(expired.canonicalFilePath());
+            //qDebug() << "Removing expired log file" << file.fileName();
+            if (!file.remove()) {
+                qWarning() << "Unable to delete expired log file" << file.fileName();
+            }
+        }
+    }
+
+    // Rotate the remaining logs.
+    for (int i = maxPrevious; i > 0; i--) {
+        QFileInfo from(logs[i-1]);
+        QFileInfo to(logs[i]);
+        if (from.exists()) {
+            if (to.exists()) {
+                qWarning() << "Unable to rotate log:" << to.absoluteFilePath() << "exists";
+                continue;
+            }
+            //qDebug() << "Renaming" << from.absoluteFilePath() << "to" << to.absoluteFilePath();
+            if (!QFile::rename(from.absoluteFilePath(), to.absoluteFilePath())) {
+                qWarning() << "Unable to rename" << from.absoluteFilePath() << "to" << to.absoluteFilePath();
+            }
+        }
+    }
+}
diff --git a/oscar/logger.h b/oscar/logger.h
index 6868c25c..2f4a1803 100644
--- a/oscar/logger.h
+++ b/oscar/logger.h
@@ -5,25 +5,32 @@
 #include <QRunnable>
 #include <QThreadPool>
 #include <QMutex>
+#include <QWaitCondition>
 #include <QTime>
 
 void initializeLogger();
 void shutdownLogger();
 
+QString GetLogDir();
+void rotateLogs(const QString & filePath, int maxPrevious=-1);
+
+
 void MyOutputHandler(QtMsgType type, const QMessageLogContext &context, const QString &msgtxt);
 
 class LogThread:public QObject, public QRunnable
 {
     Q_OBJECT
 public:
-    explicit LogThread() : QRunnable() { running = false; logtime.start(); connected = false; }
-    virtual ~LogThread() {}
+    explicit LogThread() : QRunnable() { running = false; logtime.start(); connected = false; m_logFile = nullptr; m_logStream = nullptr; }
+    virtual ~LogThread();
 
     void run();
     void append(QString msg);
     void appendClean(QString msg);
     bool isRunning() { return running; }
     void connectionReady();
+    void logToFile();
+    QString logFileName();
 
     void quit();
 
@@ -36,6 +43,9 @@ protected:
     volatile bool running;
     QTime logtime;
     bool connected;
+    class QFile* m_logFile;
+    class QTextStream* m_logStream;
+    QWaitCondition logTrigger;
 };
 
 extern LogThread * logger;
diff --git a/oscar/main.cpp b/oscar/main.cpp
index 23b96666..cc19464d 100644
--- a/oscar/main.cpp
+++ b/oscar/main.cpp
@@ -28,6 +28,7 @@
 #include "SleepLib/profiles.h"
 #include "translation.h"
 #include "SleepLib/common.h"
+#include "SleepLib/deviceconnection.h"
 
 #include <ctime>
 #include <chrono>
@@ -544,6 +545,16 @@ int main(int argc, char *argv[]) {
         }
     }
 
+    // Make sure the data directory exists.
+    if (!newDir.mkpath(".")) {
+        QMessageBox::warning(nullptr, QObject::tr("Exiting"),
+                             QObject::tr("Unable to create the OSCAR data folder at")+"\n"+
+                             GetAppData());
+        return 0;
+    }
+
+    // Begin logging to file now that there's a data folder.
+    logger->logToFile();
 
     ///////////////////////////////////////////////////////////////////////////////////////////
     // Initialize preferences system (Don't use p_pref before this point!)
@@ -636,6 +647,21 @@ int main(int argc, char *argv[]) {
     MD300W1Loader::Register();
     ViatomLoader::Register();
 
+    // Begin logging device connection activity.
+    QString connectionsLogDir = GetLogDir() + "/connections";
+    rotateLogs(connectionsLogDir);  // keep a limited set of previous logs
+    if (!QDir(connectionsLogDir).mkpath(".")) {
+        qWarning().noquote() << "Unable to create directory" << connectionsLogDir;
+    }
+
+    QFile deviceLog(connectionsLogDir + "/devices.xml");
+    if (deviceLog.open(QFile::ReadWrite)) {
+        qDebug().noquote() << "Logging device connections to" << deviceLog.fileName();
+        DeviceConnectionManager::getInstance().record(&deviceLog);
+    } else {
+        qWarning().noquote() << "Unable to start device connection logging to" << deviceLog.fileName();
+    }
+
     schema::setOrders(); // could be called in init...
 
     // Scan for user profiles
@@ -653,7 +679,11 @@ int main(int argc, char *argv[]) {
     mainwin->SetupGUI();
     mainwin->show();
 
-    return a.exec();
+    int result = a.exec();
+    
+    DeviceConnectionManager::getInstance().record(nullptr);
+    
+    return result;
 }
 
 #endif // !UNITTEST_MODE
diff --git a/oscar/mainwindow.cpp b/oscar/mainwindow.cpp
index e7890dfe..c330304d 100644
--- a/oscar/mainwindow.cpp
+++ b/oscar/mainwindow.cpp
@@ -2682,7 +2682,7 @@ void MainWindow::on_actionCreate_Card_zip_triggered()
                 infostr = datacard.loader->loaderName();
             }
             prefix = infostr;
-            folder += QDir::separator() + infostr + ".zip";
+            folder += "/" + infostr + ".zip";
 
             filename = QFileDialog::getSaveFileName(this, tr("Choose where to save zip"), folder, tr("ZIP files (*.zip)"));
 
@@ -2727,6 +2727,56 @@ void MainWindow::on_actionCreate_Card_zip_triggered()
     }
 }
 
+
+void MainWindow::on_actionCreate_Log_zip_triggered()
+{
+    QString folder;
+
+    // Note: macOS ignores this and points to OSCAR's most recently used directory for saving.
+    folder = QStandardPaths::writableLocation(QStandardPaths::DocumentsLocation);
+    folder += "/OSCAR-logs.zip";
+    QString filename = QFileDialog::getSaveFileName(this, tr("Choose where to save zip"), folder, tr("ZIP files (*.zip)"));
+    if (filename.isEmpty()) {
+        return;  // aborted
+    }
+    if (!filename.toLower().endsWith(".zip")) {
+        filename += ".zip";
+    }
+    
+    qDebug() << "Create zip of OSCAR diagnostic logs:" << filename;
+
+    ZipFile z;
+    bool ok = z.Open(filename);
+    if (ok) {
+        ProgressDialog * prog = new ProgressDialog(this);
+        prog->setMessage(tr("Creating zip..."));
+
+        // Build the list of files.
+        FileQueue files;
+        files.AddDirectory(GetLogDir(), "logs");
+
+        // Defer the current debug log to the end.
+        QString debugLog = logger->logFileName();
+        QString debugLogZipName;
+        int exists = files.Remove(debugLog, &debugLogZipName);
+        if (exists) {
+            files.AddFile(debugLog, debugLogZipName);
+        }
+
+        // Create the zip.
+        ok = z.AddFiles(files, prog);
+        z.Close();
+    } else {
+        qWarning() << "Unable to open" << filename;
+    }
+    if (!ok) {
+        QMessageBox::warning(nullptr, STR_MessageBox_Error,
+            QObject::tr("Unable to create zip!"),
+            QMessageBox::Ok);
+    }
+}
+
+
 void MainWindow::on_actionCreate_OSCAR_Data_zip_triggered()
 {
     QString folder;
@@ -2734,7 +2784,7 @@ void MainWindow::on_actionCreate_OSCAR_Data_zip_triggered()
     // Note: macOS ignores this and points to OSCAR's most recently used directory for saving.
     folder = QStandardPaths::writableLocation(QStandardPaths::DocumentsLocation);
 
-    folder += QDir::separator() + STR_AppData + ".zip";
+    folder += "/" + STR_AppData + ".zip";
 
     QString filename = QFileDialog::getSaveFileName(this, tr("Choose where to save zip"), folder, tr("ZIP files (*.zip)"));
 
@@ -2749,7 +2799,6 @@ void MainWindow::on_actionCreate_OSCAR_Data_zip_triggered()
     qDebug() << "Create zip of OSCAR data folder:" << filename;
 
     QDir oscarData(GetAppData());
-    QFile debugLog(oscarData.canonicalPath() + QDir::separator() + "debuglog.txt");
 
     ZipFile z;
     bool ok = z.Open(filename);
@@ -2759,29 +2808,22 @@ void MainWindow::on_actionCreate_OSCAR_Data_zip_triggered()
         prog->setWindowModality(Qt::ApplicationModal);
         prog->open();
 
-        // Build the list of files and exclude any existing debug log.
+        // Build the list of files.
         FileQueue files;
         files.AddDirectory(oscarData.canonicalPath(), oscarData.dirName());
-        files.Remove(debugLog.fileName());
+
+        // Defer the current debug log to the end.
+        QString debugLog = logger->logFileName();
+        QString debugLogZipName;
+        int exists = files.Remove(debugLog, &debugLogZipName);
+        if (exists) {
+            files.AddFile(debugLog, debugLogZipName);
+        }
 
         prog->setMessage(tr("Creating zip..."));
 
         // Create the zip.
         ok = z.AddFiles(files, prog);
-        if (ok && z.aborted() == false) {
-            // Update the debug log and add it last.
-            ok = debugLog.open(QIODevice::WriteOnly);
-            if (ok) {
-                debugLog.write(ui->logText->toPlainText().toLocal8Bit().data());
-                debugLog.close();
-                QString debugLogName = oscarData.dirName() + "/" + QFileInfo(debugLog).fileName();
-                ok = z.AddFile(debugLog.fileName(), debugLogName);
-                if (!ok) {
-                    qWarning() << "Unable to add debug log to zip!";
-                }
-            }
-        }
-
         z.Close();
     } else {
         qWarning() << "Unable to open" << filename;
diff --git a/oscar/mainwindow.h b/oscar/mainwindow.h
index 0f389833..93834d6b 100644
--- a/oscar/mainwindow.h
+++ b/oscar/mainwindow.h
@@ -344,6 +344,8 @@ class MainWindow : public QMainWindow
 
     void on_actionCreate_Card_zip_triggered();
 
+    void on_actionCreate_Log_zip_triggered();
+
     void on_actionCreate_OSCAR_Data_zip_triggered();
 
     void on_actionReport_a_Bug_triggered();
diff --git a/oscar/mainwindow.ui b/oscar/mainwindow.ui
index df56f053..9b797bae 100644
--- a/oscar/mainwindow.ui
+++ b/oscar/mainwindow.ui
@@ -2866,6 +2866,7 @@ p, li { white-space: pre-wrap; }
      <addaction name="actionShow_Performance_Counters"/>
      <addaction name="separator"/>
      <addaction name="actionCreate_Card_zip"/>
+     <addaction name="actionCreate_Log_zip"/>
      <addaction name="actionCreate_OSCAR_Data_zip"/>
      <addaction name="actionReport_a_Bug"/>
      <addaction name="separator"/>
@@ -3232,6 +3233,11 @@ p, li { white-space: pre-wrap; }
     <string>Create zip of CPAP data card</string>
    </property>
   </action>
+  <action name="actionCreate_Log_zip">
+   <property name="text">
+    <string>Create zip of OSCAR diagnostic logs</string>
+   </property>
+  </action>
   <action name="actionCreate_OSCAR_Data_zip">
    <property name="text">
     <string>Create zip of all OSCAR data</string>
diff --git a/oscar/oscar.pro b/oscar/oscar.pro
index 37aa096f..5becfa8d 100644
--- a/oscar/oscar.pro
+++ b/oscar/oscar.pro
@@ -500,6 +500,7 @@ lessThan(QT_MAJOR_VERSION,5)|lessThan(QT_MINOR_VERSION,9) {
 
 # Create a debug GUI build by adding "CONFIG+=memdebug" to your qmake command
 memdebug {
+    CONFIG += debug
     !win32 {  # add memory checking on Linux and macOS debug builds
         QMAKE_CFLAGS += -g -Werror -fsanitize=address -fno-omit-frame-pointer -fno-common -fsanitize-address-use-after-scope
         lessThan(QT_MAJOR_VERSION,5)|lessThan(QT_MINOR_VERSION,9) {
diff --git a/oscar/zip.cpp b/oscar/zip.cpp
index 60a439c8..48c51fa8 100644
--- a/oscar/zip.cpp
+++ b/oscar/zip.cpp
@@ -225,7 +225,7 @@ bool FileQueue::AddFile(const QString & path, const QString & prefix)
     return true;
 }
 
-int FileQueue::Remove(const QString & path)
+int FileQueue::Remove(const QString & path, QString* outName)
 {
     QFileInfo fi(path);
     QString canonicalPath = fi.canonicalFilePath();
@@ -235,6 +235,13 @@ int FileQueue::Remove(const QString & path)
     while (i.hasNext()) {
         Entry & entry = i.next();
         if (entry.path == canonicalPath) {
+            if (outName) {
+                // If the caller cares about the name, it will most likely be re-added later rather than skipped.
+                *outName = entry.name;
+            } else {
+                qDebug().noquote() << "skipping file:" << path;
+            }
+
             if (fi.isDir()) {
                 m_dir_count--;
             } else {
@@ -243,10 +250,12 @@ int FileQueue::Remove(const QString & path)
             }
             i.remove();
             removed++;
-            qDebug().noquote() << "skipping file:" << path;
         }
     }
     
+    if (removed > 1) {
+        qWarning().noquote() << removed << "copies found in zip queue:" << path;
+    }
     return removed;
 }
 
diff --git a/oscar/zip.h b/oscar/zip.h
index d5d2fd8e..c7488f53 100644
--- a/oscar/zip.h
+++ b/oscar/zip.h
@@ -63,7 +63,7 @@ public:
     ~FileQueue() = default;
 
     //!brief Remove a file from the queue, return the number of instances removed.
-    int Remove(const QString & path);
+    int Remove(const QString & path, QString* outName=nullptr);
     
     //!brief Recursively add a directory and its contents to the queue along with the prefix to be used in an archive.
     bool AddDirectory(const QString & path, const QString & prefix="");