README.md

<p align="center">
  <picture>
    <source media="(prefers-color-scheme: dark)" srcset="docs/logo-dark.svg">
    <source media="(prefers-color-scheme: light)" srcset="docs/logo-light.svg">
    <img src="docs/logo-light.svg" width="300" alt="Timeless">
  </picture>
</p>

<h3 align="center">Embedded Log Compression & Indexing for Elixir</h3>

<p align="center">
  <a href="https://hex.pm/packages/timeless_logs"><img src="https://img.shields.io/hexpm/v/timeless_logs.svg" alt="Hex.pm"></a>
  <a href="https://hexdocs.pm/timeless_logs"><img src="https://img.shields.io/badge/docs-hexdocs-blue.svg" alt="Docs"></a>
  <a href="LICENSE"><img src="https://img.shields.io/hexpm/l/timeless_logs.svg" alt="License"></a>
</p>

---

> "I found it ironic that the first thing you do to time series data is squash the timestamp. That's how the name Timeless was born." --Mark Cotner

Embedded log compression and indexing for Elixir applications. Add one dependency, configure a data directory, and your app gets compressed, searchable logs with zero external infrastructure.

Logs are written to raw blocks, automatically compacted with OpenZL (~12.8x compression ratio), and indexed in SQLite for crash-safe persistence. The index keeps level terms plus a curated set of low-cardinality metadata terms, while message substring search still scans message text and metadata values inside matching blocks. Includes optional real-time subscriptions and a VictoriaLogs-compatible HTTP API.

## Documentation

- [Getting Started](docs/getting_started.md)
- [Configuration Reference](docs/configuration.md)
- [Architecture](docs/architecture.md)
- [Querying](docs/querying.md)
- [HTTP API](docs/http_api.md)
- [Real-Time Subscriptions](docs/subscriptions.md)
- [Storage & Compression](docs/storage.md)
- [Operations](docs/operations.md)
- [Telemetry](docs/telemetry.md)

## Installation

```elixir
def deps do
  [
    {:timeless_logs, "~> 1.0"}
  ]
end
```

## Setup

```elixir
# config/config.exs
config :timeless_logs,
  data_dir: "priv/timeless_logs"
```

That's it. TimelessLogs installs itself as a `:logger` handler on application start. All `Logger` calls are automatically captured, compressed, and indexed.

## Querying

```elixir
# Recent errors
TimelessLogs.query(level: :error, since: DateTime.add(DateTime.utc_now(), -3600))

# Search by indexed metadata
TimelessLogs.query(level: :info, metadata: %{service: "payments"})

# Substring match on message
TimelessLogs.query(message: "timeout")

# Pagination
TimelessLogs.query(level: :warning, limit: 50, offset: 100, order: :asc)
```

Returns a `TimelessLogs.Result` struct:

```elixir
{:ok, %TimelessLogs.Result{
  entries: [%TimelessLogs.Entry{timestamp: ..., level: :error, message: "...", metadata: %{}}],
  total: 42,
  limit: 100,
  offset: 0
}}
```

### Query Filters

| Filter | Type | Description |
|--------|------|-------------|
| `:level` | atom | `:debug`, `:info`, `:warning`, or `:error` |
| `:message` | string | Case-insensitive substring match on message and metadata values |
| `:since` | DateTime or integer | Lower time bound (integers are unix timestamps) |
| `:until` | DateTime or integer | Upper time bound |
| `:metadata` | map | Exact match on indexed key/value pairs |
| `:limit` | integer | Max entries to return (default 100) |
| `:offset` | integer | Skip N entries (default 0) |
| `:order` | atom | `:asc` (oldest first) or `:desc` (newest first, default) |

## Streaming

For memory-efficient access to large result sets, use `stream/1`. Blocks are decompressed on demand as the stream is consumed:

```elixir
TimelessLogs.stream(level: :error)
|> Enum.take(10)

TimelessLogs.stream(since: DateTime.add(DateTime.utc_now(), -86400))
|> Stream.filter(fn entry -> String.contains?(entry.message, "timeout") end)
|> Enum.to_list()
```

## Real-Time Subscriptions

Subscribe to log entries as they arrive:

```elixir
TimelessLogs.subscribe(level: :error)

# Entries arrive as messages
receive do
  {:timeless_logs, :entry, %TimelessLogs.Entry{} = entry} ->
    IO.puts("Got error: #{entry.message}")
end

# Stop subscribing
TimelessLogs.unsubscribe()
```

You can filter subscriptions by level and metadata:

```elixir
TimelessLogs.subscribe(level: :warning, metadata: %{service: "payments"})
```

## Statistics

Get aggregate storage statistics without reading blocks:

```elixir
{:ok, stats} = TimelessLogs.stats()

# %TimelessLogs.Stats{
#   total_blocks: 48,
#   total_entries: 125_000,
#   total_bytes: 24_000_000,
#   disk_size: 24_000_000,
#   index_size: 3_200_000,
#   oldest_timestamp: 1700000000000000,
#   newest_timestamp: 1700086400000000,
#   raw_blocks: 2,
#   raw_bytes: 50_000,
#   raw_entries: 500,
#   openzl_blocks: 46,
#   openzl_bytes: 23_950_000,
#   openzl_entries: 124_500
# }
```

## Backup

Create a consistent online backup without stopping the application:

```elixir
{:ok, result} = TimelessLogs.backup("/tmp/logs_backup")

# %{path: "/tmp/logs_backup", files: [...], total_bytes: 24_000_000}
```

Creates a consistent SQLite backup (VACUUM INTO) and copies block files.

## Retention

Configure automatic cleanup to prevent unbounded disk growth:

```elixir
config :timeless_logs,
  data_dir: "priv/timeless_logs",
  retention_max_age: 7 * 24 * 3600,       # Delete logs older than 7 days
  retention_max_size: 512 * 1024 * 1024,   # Keep total blocks under 512 MB
  retention_check_interval: 300_000         # Check every 5 minutes (default)
```

You can also trigger cleanup manually:

```elixir
TimelessLogs.Retention.run_now()
```

## Compaction

New log entries are first written as uncompressed raw blocks for low-latency ingestion. A background compactor periodically merges raw blocks into compressed blocks:

```elixir
config :timeless_logs,
  compaction_threshold: 500,       # Min raw entries to trigger compaction
  compaction_interval: 30_000,     # Check every 30 seconds
  compaction_max_raw_age: 60,      # Force compact raw blocks older than 60s
  compaction_format: :openzl,      # :openzl (default) or :zstd
  openzl_compression_level: 9      # OpenZL level 1-22 (default 9)
```

Trigger manually:

```elixir
TimelessLogs.Compactor.compact_now()
```

## HTTP API

TimelessLogs includes an optional HTTP API compatible with VictoriaLogs. Enable it in config:

```elixir
config :timeless_logs,
  http: [port: 9428, bearer_token: "secret"]
```

Or simply `http: true` to use defaults (port 9428, no auth).

### Endpoints

**Health check** (always accessible, no auth required):

```
GET /health
→ {"status": "ok", "blocks": 48, "entries": 125000, "disk_size": 24000000}
```

**Ingest** (NDJSON, one JSON object per line):

```
POST /insert/jsonline?_msg_field=_msg&_time_field=_time

{"_msg": "Request completed", "_time": "2024-01-15T10:30:00Z", "level": "info", "request_id": "abc123"}
{"_msg": "Connection timeout", "level": "error", "service": "api"}
```

**Query**:

```
GET /select/logsql/query?level=error&start=2024-01-15T00:00:00Z&limit=50
→ NDJSON response, one entry per line
```

**Stats**:

```
GET /select/logsql/stats
→ {"total_blocks": 48, "total_entries": 125000, ...}
```

**Flush buffer**:

```
GET /api/v1/flush
→ {"status": "ok"}
```

**Backup**:

```
POST /api/v1/backup
Content-Type: application/json
{"path": "/tmp/backup"}

→ {"status": "ok", "path": "/tmp/backup", "files": [...], "total_bytes": 24000000}
```

### Authentication

When `bearer_token` is configured, all endpoints except `/health` require either:

- Header: `Authorization: Bearer <token>`
- Query param: `?token=<token>`

## Reducing Overhead

The biggest source of logging overhead in most Elixir apps is stdout/console
output, not the log capture itself. For production or embedded use, disable
the default console handler and let TimelessLogs be the sole destination:

```elixir
# config/prod.exs (or config/config.exs for all environments)
config :logger,
  backends: [],
  handle_otp_reports: true,
  handle_sasl_reports: false

# Remove the default handler on boot
config :logger, :default_handler, false
```

This eliminates the cost of formatting and writing every log line to stdout
while TimelessLogs captures everything at the level you choose:

```elixir
# Only capture :info and above (skip :debug in production)
config :logger, level: :info
```

If you still want console output during development:

```elixir
# config/dev.exs
config :logger, :default_handler, %{level: :debug}
```

## Configuration

| Option | Default | Description |
|--------|---------|-------------|
| `data_dir` | `"priv/log_stream"` | Root directory for blocks and index |
| `storage` | `:disk` | Storage backend (`:disk` or `:memory`) |
| `flush_interval` | `1_000` | Buffer flush interval in ms |
| `max_buffer_size` | `1_000` | Max entries before auto-flush |
| `query_timeout` | `30_000` | Query timeout in ms |
| `compaction_format` | `:openzl` | Compression format (`:openzl` or `:zstd`) |
| `openzl_compression_level` | `9` | OpenZL compression level (1-22) |
| `zstd_compression_level` | `3` | Zstd compression level (1-22) |
| `compaction_threshold` | `500` | Min raw entries to trigger compaction |
| `compaction_interval` | `30_000` | Compaction check interval in ms |
| `compaction_max_raw_age` | `60` | Force compact raw blocks older than this (seconds) |
| `retention_max_age` | `7 * 86_400` | Max log age in seconds (`nil` = keep forever) |
| `retention_max_size` | `512 * 1_048_576` | Max block storage in bytes (`nil` = unlimited) |
| `retention_check_interval` | `300_000` | Retention check interval in ms |
| `http` | `false` | Enable HTTP API (`true`, or keyword list with `:port` and `:bearer_token`) |

## Telemetry

TimelessLogs emits telemetry events for monitoring:

| Event | Measurements | Metadata |
|-------|-------------|----------|
| `[:timeless_logs, :flush, :stop]` | `duration`, `entry_count`, `byte_size` | `block_id` |
| `[:timeless_logs, :query, :stop]` | `duration`, `total`, `blocks_read` | `filters` |
| `[:timeless_logs, :retention, :stop]` | `duration`, `blocks_deleted` | |
| `[:timeless_logs, :compaction, :stop]` | `duration`, `raw_blocks`, `entry_count`, `byte_size` | |
| `[:timeless_logs, :block, :error]` | | `file_path`, `reason` |

## How It Works

1. Your app logs normally via `Logger`
2. TimelessLogs captures log events via an OTP `:logger` handler
3. Events buffer in a GenServer, flushing every 1s or 1000 entries
4. Each flush writes a raw (uncompressed) block file
5. A background compactor merges raw blocks into OpenZL-compressed blocks (~12.8x ratio)
6. Block metadata and an inverted term index are stored in SQLite (WAL mode, single writer + reader pool) for crash-safe persistence
7. Queries use the SQLite reader pool to find relevant blocks, decompress only those in parallel, and filter entries
8. Real-time subscribers receive matching entries as they're buffered

## Benchmarks

Run on M5 Pro (18 cores). Reproduce with `mix timeless_logs.ingest_benchmark`, `mix timeless_logs.benchmark`, and `mix timeless_logs.search_benchmark`.

**Ingestion** (1.1M simulated Phoenix log entries, 1 week, 1000-entry blocks):

| Path | Throughput |
|------|------------|
| Raw to disk | **1.1M entries/sec** |
| Raw to memory | **4.0M entries/sec** |

**Compression** (1.1M entries, 1000-entry blocks):

| Engine | Level | Size | Ratio | Throughput |
|--------|-------|------|-------|------------|
| zstd | 1 | 23.9 MB | 10.3x | 3.6M entries/sec |
| zstd | 3 (default) | 24.7 MB | 10.0x | 6.3M entries/sec |
| zstd | 9 | 21.7 MB | 11.4x | 941K entries/sec |
| OpenZL | 1 | 22.0 MB | 11.2x | 978K entries/sec |
| OpenZL | 3 | 21.8 MB | 11.3x | 2.0M entries/sec |
| OpenZL | 9 (default) | 19.2 MB | **12.8x** | 793K entries/sec |
| OpenZL | 19 | 17.1 MB | **14.4x** | 21.1K entries/sec |

**Head-to-head** (default levels: zstd=3, OpenZL=9):

| Metric | zstd | OpenZL | Delta |
|--------|------|--------|-------|
| Compressed size | 24.7 MB | **19.2 MB** | 22.2% smaller |
| Compression time | **178 ms** | 1392 ms | 681.7% slower |
| Decompression | 3.1M entries/sec | **3.6M entries/sec** | 12.4% faster |
| Filtered query | 2864 ms | **379 ms** | 86.8% faster |
| Compaction | **3.4M entries/sec** | 2.6M entries/sec | 31.0% slower |

OpenZL columnar wins on filtered queries (86.8% faster) because it can skip irrelevant columns during decompression. Decompression (the read hot path) is 12.4% faster than zstd.

## License

MIT - see [LICENSE](LICENSE) for details.