From 0fbbe6a054b5418fe7ef795ac019fe2d2aa7ccb1 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 7 Apr 2025 21:45:52 -0700 Subject: [PATCH] llm logs backup command, closes #879 --- docs/help.md | 12 ++++++++++++ docs/logging.md | 11 +++++++++++ llm/cli.py | 16 ++++++++++++++++ tests/test_llm_logs.py | 17 +++++++++++++++++ 4 files changed, 56 insertions(+) diff --git a/docs/help.md b/docs/help.md index cced702..7bd0ac7 100644 --- a/docs/help.md +++ b/docs/help.md @@ -250,6 +250,7 @@ Options: Commands: list* Show logged prompts and their responses + backup Backup your logs database to this file off Turn off logging for all prompts on Turn on logging for all prompts path Output the path to the logs.db file @@ -278,6 +279,17 @@ Options: --help Show this message and exit. ``` +(help-logs-backup)= +#### llm logs backup --help +``` +Usage: llm logs backup [OPTIONS] PATH + + Backup your logs database to this file + +Options: + --help Show this message and exit. +``` + (help-logs-on)= #### llm logs on --help ``` diff --git a/docs/logging.md b/docs/logging.md index f81f00b..17d1433 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -234,6 +234,17 @@ You can also use [Datasette](https://datasette.io/) to browse your logs like thi datasette "$(llm logs path)" ``` +(logging-backup)= + +### Backing up your database + +You can backup your logs to another file using the `llm logs backup` command: + +```bash +llm logs backup /tmp/backup.db +``` +This uses SQLite [VACCUM INTO](https://sqlite.org/lang_vacuum.html#vacuum_with_an_into_clause) under the hood. + (logging-sql-schema)= ## SQL schema diff --git a/llm/cli.py b/llm/cli.py index 5cd9108..ebc982a 100644 --- a/llm/cli.py +++ b/llm/cli.py @@ -1085,6 +1085,22 @@ def logs_status(): ) +@logs.command(name="backup") +@click.argument("path", type=click.Path(dir_okay=True, writable=True)) +def backup(path): + "Backup your logs database to this file" + logs_path = logs_db_path() + path = pathlib.Path(path) + db = sqlite_utils.Database(logs_path) + try: + db.execute("vacuum into ?", [str(path)]) + except Exception as ex: + raise click.ClickException(str(ex)) + click.echo( + "Backed up {} to {}".format(_human_readable_size(path.stat().st_size), path) + ) + + @logs.command(name="on") def logs_turn_on(): "Turn on logging for all prompts" diff --git a/tests/test_llm_logs.py b/tests/test_llm_logs.py index 0358816..e7b5173 100644 --- a/tests/test_llm_logs.py +++ b/tests/test_llm_logs.py @@ -5,6 +5,7 @@ from llm import Fragment from ulid import ULID import datetime import json +import pathlib import pytest import re import sqlite_utils @@ -842,3 +843,19 @@ def test_expand_fragment_markdown(fragments_fixture): expected_prefix = f"### Prompt fragments\n\n
{hash}\nThis is fragment 5" assert interesting_bit.startswith(expected_prefix) assert interesting_bit.endswith("
") + + +def test_logs_backup(logs_db): + assert not logs_db.tables + runner = CliRunner() + with runner.isolated_filesystem(): + runner.invoke(cli, ["-m", "echo", "simple prompt"]) + assert logs_db.tables + expected_path = pathlib.Path("backup.db") + assert not expected_path.exists() + # Now back it up + result = runner.invoke(cli, ["logs", "backup", "backup.db"]) + assert result.exit_code == 0 + assert result.output.startswith("Backed up ") + assert result.output.endswith("to backup.db\n") + assert expected_path.exists()