Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,11 @@ MAIL_FROM_NAME=LibrisLog
MAIL_STARTTLS=True
MAIL_SSL_TLS=False
PUBLIC_APP_URL=http://localhost:5173
PASSWORD_RESET_TOKEN_MAX_AGE=3600
PASSWORD_RESET_TOKEN_MAX_AGE=3600

# Anonymous installation telemetry (see docs: guide/telemetry)
# Sends a minimal, anonymous installation census heartbeat (install id, version, os, architecture, runtime).
# Set TELEMETRY_DISABLED=true to opt out completely.
TELEMETRY_DISABLED=false
TELEMETRY_ENDPOINT=https://metrics.librislog.app/api/telemetry
TELEMETRY_TIMEOUT_SECONDS=10
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ Open **http://localhost:8001** and create your account.

## Why LibrisLog?

- **Your data, your rules.** Fully self-hosted under MIT license — no ads, no tracking, no vendor lock-in. A single SQLite file you can back up anytime.
- **Your data, your rules.** Fully self-hosted under MIT license — no ads, no user tracking, no vendor lock-in. A single SQLite file you can back up anytime.
- **No API keys required.** Works with Open Library out of the box. Add Google Books or Hardcover.app tokens optionally for richer search results.
- **Rich insights from day one.** Calendar heatmap, language/status/page distribution charts, books finished per month/year, top authors — all on your hardware.
- **Multi-user from the start.** User roles (admin/user), optional OIDC SSO, per-user libraries. One instance works for your whole household or small group.
Expand Down Expand Up @@ -105,6 +105,19 @@ uv run uvicorn app.main:app --reload

---

## Telemetry

LibrisLog sends a minimal, anonymous installation census (installation id, version, OS, CPU architecture, runtime) to understand how many installations exist and on which platforms they run. No user, book, reading, host, or personal data is ever collected, and it can be disabled with `TELEMETRY_DISABLED=true`.

Both sides are fully open and verifiable:

- **Telemetry server source:** [github.com/codebude/librislog-telemetry](https://github.com/codebude/librislog-telemetry)
- **Live aggregated results:** [metrics.librislog.app](https://metrics.librislog.app/)

See [docs.librislog.app/guide/telemetry](https://docs.librislog.app/guide/telemetry) for exactly what is and is not collected.

---

## Stack

| Layer | Technology |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""add installation_info table for anonymous telemetry id

Revision ID: c3d4e5f6a7b8
Revises: a1b2c3d4e5f7
Create Date: 2026-09-01 12:00:00.000000

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa
from sqlalchemy.engine.reflection import Inspector


# revision identifiers, used by Alembic.
revision: str = "c3d4e5f6a7b8"
down_revision: Union[str, Sequence[str], None] = "a1b2c3d4e5f7"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
"""Create the installation_info singleton table."""
conn = op.get_bind()
inspector = sa.inspect(conn)
tables = inspector.get_table_names()
if "installation_info" not in tables:
op.create_table(
"installation_info",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("installation_id", sa.String(length=64), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)


def downgrade() -> None:
"""Drop the installation_info table."""
op.drop_table("installation_info")
5 changes: 4 additions & 1 deletion backend/app/config.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from typing import List

from pydantic import field_validator
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict


Expand Down Expand Up @@ -53,6 +53,9 @@ class Settings(BaseSettings):
password_reset_token_max_age: int = 3600
public_app_url: str = "http://localhost:5173"
forwarded_allow_ips: str = "*"
telemetry_disabled: bool = False
telemetry_endpoint: str = "https://metrics.librislog.app/api/telemetry"
telemetry_timeout_seconds: float = Field(default=10.0, gt=0)

@field_validator("api_key_encryption_key")
@classmethod
Expand Down
26 changes: 26 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
from app.routers import admin, auth, books, config, cover_candidates, covers, data, docs, embed, health, hygiene, import_, oidc, profile, progress, statistics, users
from app.services.cover_storage import cleanup_orphan_covers
from app.services.data_import import cleanup_temp_files
from app.services.telemetry import send_telemetry_once

_TELEMETRY_INTERVAL_SECONDS = 24 * 3600

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -59,6 +62,20 @@ async def _periodic_maintenance(interval_hours: int = 1) -> None:
logger.warning("Periodic maintenance failed (%d): %s", failures, exc)


async def _telemetry_heartbeat() -> None:
"""Send one telemetry heartbeat at startup, then every 24 hours.

Telemetry is an installation census, not event tracking. ``send_telemetry_once``
never raises, so failures cannot interrupt the loop or the application.
"""
while True:
try:
await send_telemetry_once()
except Exception as exc: # noqa: BLE001 — telemetry must never interfere
logger.debug("Telemetry heartbeat failed: %s", exc)
await asyncio.sleep(_TELEMETRY_INTERVAL_SECONDS)


@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan: create required directories and start background tasks."""
Expand All @@ -73,12 +90,21 @@ async def lifespan(app: FastAPI):
)

maintenance_task = asyncio.create_task(_periodic_maintenance())
telemetry_task = (
asyncio.create_task(_telemetry_heartbeat()) if not settings.telemetry_disabled else None
)
yield
maintenance_task.cancel()
try:
await maintenance_task
except asyncio.CancelledError:
pass
if telemetry_task is not None:
telemetry_task.cancel()
try:
await telemetry_task
except asyncio.CancelledError:
pass


if __git_sha__ != "unknown" and __version__.find(__git_sha__[:7]) == -1:
Expand Down
22 changes: 22 additions & 0 deletions backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -303,3 +303,25 @@ class ImportMapping(SQLModel, table=True):
default_factory=utcnow,
sa_column=Column(UtcDateTime, default=utcnow)
)


class InstallationInfo(SQLModel, table=True):
"""Singleton row storing the anonymous installation id for telemetry.

The id is a random UUIDv4 generated on first startup and persisted in the
database so it stays stable across container restarts and updates. It is
never derived from any machine-specific identifier.
"""

__tablename__: str = "installation_info"

id: int = Field(default=1, primary_key=True)
installation_id: str = Field(max_length=64)
created_at: datetime = Field(
default_factory=utcnow,
sa_column=Column(UtcDateTime, default=utcnow)
)
updated_at: datetime = Field(
default_factory=utcnow,
sa_column=Column(UtcDateTime, default=utcnow)
)
206 changes: 206 additions & 0 deletions backend/app/services/telemetry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
"""Anonymous, privacy-focused installation telemetry (best-effort).

Sends a minimal heartbeat — installation id, version, OS, CPU architecture
and runtime — to the LibrisLog telemetry API so that the project can track
how many installations exist, which versions are in use and on which
platforms they run.

This is an installation census, not user-behavior tracking. No user, book,
reading, host, network or configuration data is ever collected. The payload
is built strictly from the allow-list in :data:`TELEMETRY_FIELDS` and is
validated against it before sending.

Telemetry is best-effort and must never interfere with LibrisLog: network
failures are swallowed and only logged at debug level.
"""

import asyncio
import logging
import os
import platform
from datetime import datetime, timezone
from typing import Optional
from uuid import uuid4

import httpx
from sqlalchemy.exc import IntegrityError
from sqlmodel import Session

from app._build_info import __version__
from app.config import settings
from app.database import engine
from app.models import InstallationInfo

logger = logging.getLogger(__name__)

# Message version of the telemetry API schema (TelemetryInV1).
MESSAGE_VERSION = 1

# Fixed, generic user-agent so the transport carries no client-identifying
# details (httpx would otherwise send ``python-httpx/<version>``).
_USER_AGENT = "librislog-telemetry/1"

# Strict allow-list: the only fields a telemetry payload may ever contain.
TELEMETRY_FIELDS = frozenset(
{
"message_version",
"installation_id",
"version",
"os",
"architecture",
"runtime",
"client_ts",
}
)

# Marker files indicating a containerized runtime. Kept generic on purpose:
# LibrisLog may run under Docker, containerd, Kubernetes, Podman, etc.
_CONTAINER_MARKERS = ("/.dockerenv", "/.containerenv")

# Runtime substrings looked up in /proc/1/cgroup to detect containerized
# environments that do not leave a marker file (e.g. containerd/Kubernetes).
_CONTAINER_CGROUP_MARKERS = ("docker", "containerd", "kubepods", "libpod", "lxc")


def normalize_os(system: str | None) -> str:
"""Normalize ``platform.system()`` output to lowercase."""
return (system or "").strip().lower()


def normalize_architecture(machine: str | None) -> str:
"""Map common CPU architecture names to canonical values.

``x86_64``/``AMD64`` -> ``amd64``, ``aarch64``/``arm64`` -> ``arm64``.
Anything else is reported as ``unknown``.
"""
arch = (machine or "").strip().lower()
if arch in {"x86_64", "amd64", "x64"}:
return "amd64"
if arch in {"aarch64", "arm64"}:
return "arm64"
return "unknown"


def _container_marker_present() -> bool:
"""Return True when a well-known container marker file exists."""
return any(os.path.exists(marker) for marker in _CONTAINER_MARKERS)


def _cgroup_container_signal() -> bool | None:
"""Derive a container signal from ``/proc/1/cgroup`` without capturing it.

Returns True when a container runtime path is present, False when the file
is readable but contains no runtime marker, and None when it cannot be
read. Only a boolean is derived — the file content (including any cgroup or
container IDs) is never stored, logged, or sent.
"""
try:
with open("/proc/1/cgroup", "r", encoding="utf-8", errors="ignore") as fh:
content = fh.read()
except OSError:
return None
return any(marker in content for marker in _CONTAINER_CGROUP_MARKERS)


def detect_runtime() -> str:
"""Detect whether LibrisLog runs inside a container or directly on a host.

Returns ``container`` when a marker file is present or a container runtime
appears in the cgroup paths, ``baremetal`` when the environment is readable
and shows no container, and ``unknown`` when it cannot be determined.
Container IDs and other environment-specific identifiers are never
inspected, collected, or reported.
"""
if _container_marker_present():
return "container"
signal = _cgroup_container_signal()
if signal is True:
return "container"
if signal is False:
return "baremetal"
return "unknown"


def collect_system_info() -> dict[str, str]:
"""Collect the anonymous system attributes reported in the payload."""
return {
"os": normalize_os(platform.system()),
"architecture": normalize_architecture(platform.machine()),
"runtime": detect_runtime(),
}


def get_or_create_installation_id(session: Session) -> str:
"""Return the persisted installation id, creating a random UUIDv4 once.

The id is generated with ``uuid.uuid4()`` — a cryptographically random
UUID completely independent of the host. It is stored in the database so
it remains stable across restarts and updates. Deleting the database and
reinstalling produces a fresh id.

If two processes race on first boot and both try to insert the singleton
row, the loser of the unique-primary-key race rolls back and re-reads the
winner's row.
"""
info = session.get(InstallationInfo, 1)
if info is None:
try:
info = InstallationInfo(id=1, installation_id=str(uuid4()))
session.add(info)
session.commit()
session.refresh(info)
except IntegrityError:
session.rollback()
info = session.get(InstallationInfo, 1)
if info is None:
raise
return info.installation_id


def build_payload(installation_id: str, client_ts: Optional[datetime] = None) -> dict[str, object]:
"""Build the telemetry payload using only allow-listed fields."""
system = collect_system_info()
payload = {
"message_version": MESSAGE_VERSION,
"installation_id": installation_id,
"version": __version__,
"os": system["os"],
"architecture": system["architecture"],
"runtime": system["runtime"],
"client_ts": (client_ts or datetime.now(timezone.utc)).isoformat(),
}
if set(payload) != TELEMETRY_FIELDS:
raise RuntimeError("telemetry payload contains fields outside the allow-list")
return payload


def _load_installation_id() -> str:
"""Load (creating on first run) the installation id in its own session.

The session is created, used and closed entirely inside the executor
thread, so the event loop thread never touches a live SQLModel session.
"""
with Session(engine) as session:
return get_or_create_installation_id(session)


async def send_telemetry_once() -> None:
"""Send one best-effort telemetry heartbeat.

Never raises: startup and the heartbeat loop must not be affected by
telemetry problems. Failures are logged at debug level only.
"""
if settings.telemetry_disabled:
return
loop = asyncio.get_running_loop()
try:
installation_id = await loop.run_in_executor(None, _load_installation_id)
payload = build_payload(installation_id)
async with httpx.AsyncClient(
timeout=settings.telemetry_timeout_seconds, headers={"User-Agent": _USER_AGENT}
) as client:
response = await client.post(settings.telemetry_endpoint, json=payload)
response.raise_for_status()
logger.debug("Telemetry heartbeat sent")
except Exception as exc: # noqa: BLE001 — telemetry must never interfere
logger.debug("Telemetry heartbeat failed: %s", exc)
2 changes: 2 additions & 0 deletions backend/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
from sqlmodel.pool import StaticPool

os.environ.setdefault("API_KEY_ENCRYPTION_KEY", "test-api-key-encryption-secret")
# Telemetry must never fire during the test suite (no network, no dev-DB writes).
os.environ["TELEMETRY_DISABLED"] = "true"

from app.auth import encrypt_api_key, generate_api_key, get_api_key_prefix, get_password_hash, hash_api_key
from app.models import ApiKey, User, UserRole, UserSettings
Expand Down
Loading
Loading